diff --git a/apps/backend/webpack.config.prod.js b/apps/backend/webpack.config.prod.js index 252aadaee..ce4353030 100644 --- a/apps/backend/webpack.config.prod.js +++ b/apps/backend/webpack.config.prod.js @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const nodeExternals = require('webpack-node-externals'); -const { RunScriptWebpackPlugin } = require('run-script-webpack-plugin'); module.exports = function (options, webpack) { return { diff --git a/apps/web/components/filters-editor/field-filter.tsx b/apps/web/components/filters-editor/field-filter.tsx index d80df7fc6..74c882686 100644 --- a/apps/web/components/filters-editor/field-filter.tsx +++ b/apps/web/components/filters-editor/field-filter.tsx @@ -33,7 +33,7 @@ export const FieldFilter: React.FC = ({ schema, value, onChange, onRemov useEffect(() => { if (selectedField && operator) { - onChange(selectedField.createFilter(operator, fieldValue as any), index) + onChange(selectedField.createFilter(operator as any, fieldValue as any), index) } else { onChange(null, index) } diff --git a/apps/web/components/filters-editor/operator-selector.tsx b/apps/web/components/filters-editor/operator-selector.tsx index 8a0a7dd31..07cb8aaa9 100644 --- a/apps/web/components/filters-editor/operator-selector.tsx +++ b/apps/web/components/filters-editor/operator-selector.tsx @@ -17,6 +17,10 @@ export const OperatorSelector: React.FC = ({ value, field, onChange }) = data = [ { value: '$eq', label: 'equal' }, { value: '$neq', label: 'not equal' }, + { value: '$contains', label: 'contains' }, + { value: '$starts_with', label: 'startsWith' }, + { value: '$ends_with', label: 'endsWith' }, + { value: '$regex', label: 'regex' }, ] } else if (field instanceof NumberField) { data = [ diff --git a/packages/core/filter/__snapshots__/filter.test.ts.snap b/packages/core/filter/__snapshots__/filter.test.ts.snap index 9258e8a88..b6ada1e22 100644 --- a/packages/core/filter/__snapshots__/filter.test.ts.snap +++ b/packages/core/filter/__snapshots__/filter.test.ts.snap @@ -1,71 +1,85 @@ // Vitest Snapshot v1 -exports[`should convert root filter to record specification 1`] = ` +exports[`should create root filter 1`] = ` OptionType { Symbol(T): true, Symbol(Val): StringEqual { "name": "name", - "value": "value", + "value": "hello", }, } `; -exports[`should convert root filter to record specification 2`] = ` +exports[`should create root filter 2`] = ` OptionType { Symbol(T): true, - Symbol(Val): And { - "left": StringEqual { - "name": "field1", - "value": "1", - }, - "right": Not { - "spec": StringEqual { - "name": "field2", - "value": "2", - }, - }, + Symbol(Val): StringContain { + "name": "name", + "value": "hello", }, } `; -exports[`should convert root filter to record specification 3`] = ` +exports[`should create root filter 3`] = ` OptionType { Symbol(T): true, - Symbol(Val): Not { - "spec": StringEqual { - "name": "field2", - "value": "2", - }, + Symbol(Val): StringStartsWith { + "name": "name", + "value": "starts with", }, } `; -exports[`should convert root filter to record specification 4`] = ` +exports[`should create root filter 4`] = ` OptionType { Symbol(T): true, - Symbol(Val): Or { - "left": StringEqual { - "name": "field1", - "value": "1", - }, - "right": Not { - "spec": StringEqual { - "name": "field2", - "value": "2", - }, + Symbol(Val): StringEndsWith { + "name": "name", + "value": "ends with", + }, +} +`; + +exports[`should create root filter 5`] = ` +OptionType { + Symbol(T): true, + Symbol(Val): Not { + "spec": NumberEqual { + "name": "name", + "value": 1, }, }, } `; -exports[`should convert root filter to record specification 5`] = ` +exports[`should create root filter 6`] = ` OptionType { Symbol(T): true, Symbol(Val): Not { - "spec": StringEqual { - "name": "field1", - "value": "1", + "spec": NumberEqual { + "name": "name.nested", + "value": 1, }, }, } `; + +exports[`should create root filter 7`] = ` +OptionType { + Symbol(T): true, + Symbol(Val): StringEqual { + "name": "name", + "value": "hello", + }, +} +`; + +exports[`should create root filter 8`] = ` +OptionType { + Symbol(T): true, + Symbol(Val): StringEqual { + "name": "name", + "value": "hello", + }, +} +`; diff --git a/packages/core/filter/filter.test.ts b/packages/core/filter/filter.test.ts index b12512fa4..9cf74fb0e 100644 --- a/packages/core/filter/filter.test.ts +++ b/packages/core/filter/filter.test.ts @@ -7,6 +7,24 @@ test.each([ path: 'name', value: 'hello', }, + { + type: 'string', + operator: '$contains', + path: 'name', + value: 'hello', + }, + { + type: 'string', + operator: '$starts_with', + path: 'name', + value: 'starts with', + }, + { + type: 'string', + operator: '$ends_with', + path: 'name', + value: 'ends with', + }, { type: 'number', operator: '$neq', @@ -48,75 +66,7 @@ test.each([ ])('should create root filter', (filter) => { const parsed = rootFilter.parse(filter) expect(parsed).toEqual(filter) -}) -test.each([ - { - type: 'string', - value: 'value', - operator: '$eq', - path: 'name', - }, - [ - { - type: 'string', - value: '1', - operator: '$eq', - path: 'field1', - }, - { - type: 'string', - value: '2', - operator: '$neq', - path: 'field2', - }, - ], - [ - { - conjunction: '$or', - children: [ - { - type: 'string', - value: '2', - operator: '$neq', - path: 'field2', - }, - ], - }, - ], - [ - { - conjunction: '$or', - children: [ - { - type: 'string', - value: '1', - operator: '$eq', - path: 'field1', - }, - { - type: 'string', - value: '2', - operator: '$neq', - path: 'field2', - }, - ], - }, - ], - [ - { - conjunction: '$not', - children: [ - { - type: 'string', - value: '1', - operator: '$eq', - path: 'field1', - }, - ], - }, - ], -])('should convert root filter to record specification', (filter) => { const spec = convertFilterSpec(filter) expect(spec).toMatchSnapshot() }) diff --git a/packages/core/filter/filter.ts b/packages/core/filter/filter.ts index 35aa70e18..08d9d29a4 100644 --- a/packages/core/filter/filter.ts +++ b/packages/core/filter/filter.ts @@ -2,16 +2,20 @@ import type { CompositeSpecification } from '@egodb/domain' import type { Option } from 'oxide.ts' import { None, Some } from 'oxide.ts' import { z } from 'zod' -import { NumberEqual, StringEqual } from '../record' +import { NumberEqual, StringContain, StringEndsWith, StringEqual, StringStartsWith } from '../record' const $eq = z.literal('$eq') const $neq = z.literal('$neq') +const $contains = z.literal('$contains') +const $starts_with = z.literal('$starts_with') +const $ends_with = z.literal('$ends_with') +const $regex = z.literal('$regex') const baseFilter = z.object({ path: z.string().min(1), }) -const stringFilterOperators = z.union([$eq, $neq]) +const stringFilterOperators = z.union([$eq, $neq, $contains, $starts_with, $ends_with, $regex]) const stringFilter = z .object({ type: z.literal('string'), @@ -19,6 +23,7 @@ const stringFilter = z value: z.string().nullable(), }) .merge(baseFilter) + export type IStringFilter = z.infer export type IStringFilterOperator = z.infer @@ -86,8 +91,18 @@ const convertFilter = (filter: IFilter): Option => { case '$eq': { return Some(new StringEqual(filter.path, filter.value)) } - case '$neq': + case '$neq': { return Some(new StringEqual(filter.path, filter.value).not()) + } + case '$contains': { + return Some(new StringContain(filter.path, filter.value)) + } + case '$starts_with': { + return Some(new StringStartsWith(filter.path, filter.value)) + } + case '$ends_with': { + return Some(new StringEndsWith(filter.path, filter.value)) + } default: return None diff --git a/packages/core/record/specifications/interface.ts b/packages/core/record/specifications/interface.ts index 4b9624871..f726186b1 100644 --- a/packages/core/record/specifications/interface.ts +++ b/packages/core/record/specifications/interface.ts @@ -4,7 +4,7 @@ import { type Record } from '../record' import type { NumberEqual } from './number.specification' import type { WithRecordId } from './record-id.specifaction' import type { WithRecordTableId } from './record-table-id.specification' -import type { StringContain, StringEqual } from './string.specification' +import type { StringContain, StringEndsWith, StringEqual, StringRegex, StringStartsWith } from './string.specification' interface IRecordSpecVisitor { idEqual(s: WithRecordId): void @@ -14,6 +14,9 @@ interface IRecordSpecVisitor { interface IRecordValueVisitor { stringEqual(s: StringEqual): void stringContain(s: StringContain): void + stringStartsWith(s: StringStartsWith): void + stringEndsWith(s: StringEndsWith): void + stringRegex(s: StringRegex): void numberEqual(s: NumberEqual): void } diff --git a/packages/core/record/specifications/string.specification.ts b/packages/core/record/specifications/string.specification.ts index edc2408fc..7812dc8b0 100644 --- a/packages/core/record/specifications/string.specification.ts +++ b/packages/core/record/specifications/string.specification.ts @@ -36,3 +36,51 @@ export class StringContain extends RecordValueSpecifcationBase { return Ok(undefined) } } + +export class StringStartsWith extends RecordValueSpecifcationBase { + /** + * check whether string starts with given value + * @param r - record + * @returns boolean + */ + isSatisfiedBy(r: Record): boolean { + return r.values.getStringValue(this.name).mapOr(false, (value) => value.startsWith(this.value)) + } + + accept(v: IRecordVisitor): Result { + v.stringStartsWith(this) + return Ok(undefined) + } +} + +export class StringEndsWith extends RecordValueSpecifcationBase { + /** + * check whether string ends with given value + * @param r - record + * @returns boolean + */ + isSatisfiedBy(r: Record): boolean { + return r.values.getStringValue(this.name).mapOr(false, (value) => value.endsWith(this.value)) + } + + accept(v: IRecordVisitor): Result { + v.stringEndsWith(this) + return Ok(undefined) + } +} + +export class StringRegex extends RecordValueSpecifcationBase { + /** + * check whether string match given regex + * @param r - record + * @returns boolean + */ + isSatisfiedBy(r: Record): boolean { + return r.values.getStringValue(this.name).mapOr(false, (value) => new RegExp(this.value).test(value)) + } + + accept(v: IRecordVisitor): Result { + v.stringRegex(this) + return Ok(undefined) + } +} diff --git a/packages/repositories/in-memory-repository/repositories/record/record-in-memory.query-visitor.ts b/packages/repositories/in-memory-repository/repositories/record/record-in-memory.query-visitor.ts index ab8532101..d7f9c4d0b 100644 --- a/packages/repositories/in-memory-repository/repositories/record/record-in-memory.query-visitor.ts +++ b/packages/repositories/in-memory-repository/repositories/record/record-in-memory.query-visitor.ts @@ -3,11 +3,14 @@ import type { IRecordVisitor, NumberEqual, StringContain, + StringEndsWith, StringEqual, + StringRegex, + StringStartsWith, WithRecordId, WithRecordTableId, } from '@egodb/core' -import { contains, isNumber, isString } from '@fxts/core' +import { isNumber, isString } from '@fxts/core' import type { Result } from 'oxide.ts' import { Err, Ok } from 'oxide.ts' import type { RecordInMemory } from './record.type' @@ -74,7 +77,28 @@ export class RecordInMemoryQueryVisitor implements IRecordVisitor { stringContain(s: StringContain): void { this.predicate = (r) => { const value = r.values[s.name] - return isString(value) && contains(s.value, value) + return isString(value) && value.includes(s.value) + } + } + + stringStartsWith(s: StringStartsWith): void { + this.predicate = (r) => { + const value = r.values[s.name] + return isString(value) && value.startsWith(s.value) + } + } + + stringEndsWith(s: StringEndsWith): void { + this.predicate = (r) => { + const value = r.values[s.name] + return isString(value) && value.endsWith(s.value) + } + } + + stringRegex(s: StringRegex): void { + this.predicate = (r) => { + const value = r.values[s.name] + return isString(value) && new RegExp(s.value).test(value) } } }