diff --git a/content/docs/references/data/ComparisonOperator.mdx b/content/docs/references/data/ComparisonOperator.mdx new file mode 100644 index 000000000..360fa1b57 --- /dev/null +++ b/content/docs/references/data/ComparisonOperator.mdx @@ -0,0 +1,13 @@ +--- +title: ComparisonOperator +description: ComparisonOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$gt** | `number \| string` | optional | | +| **$gte** | `number \| string` | optional | | +| **$lt** | `number \| string` | optional | | +| **$lte** | `number \| string` | optional | | diff --git a/content/docs/references/data/EqualityOperator.mdx b/content/docs/references/data/EqualityOperator.mdx new file mode 100644 index 000000000..b7c3a4064 --- /dev/null +++ b/content/docs/references/data/EqualityOperator.mdx @@ -0,0 +1,11 @@ +--- +title: EqualityOperator +description: EqualityOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$eq** | `any` | optional | | +| **$ne** | `any` | optional | | diff --git a/content/docs/references/data/FieldOperators.mdx b/content/docs/references/data/FieldOperators.mdx new file mode 100644 index 000000000..0eb2bf8ce --- /dev/null +++ b/content/docs/references/data/FieldOperators.mdx @@ -0,0 +1,23 @@ +--- +title: FieldOperators +description: FieldOperators Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$eq** | `any` | optional | | +| **$ne** | `any` | optional | | +| **$gt** | `number \| string` | optional | | +| **$gte** | `number \| string` | optional | | +| **$lt** | `number \| string` | optional | | +| **$lte** | `number \| string` | optional | | +| **$in** | `any[]` | optional | | +| **$nin** | `any[]` | optional | | +| **$between** | `any[]` | optional | | +| **$contains** | `string` | optional | | +| **$startsWith** | `string` | optional | | +| **$endsWith** | `string` | optional | | +| **$null** | `boolean` | optional | | +| **$exist** | `boolean` | optional | | diff --git a/content/docs/references/data/FilterCondition.mdx b/content/docs/references/data/FilterCondition.mdx new file mode 100644 index 000000000..622d19051 --- /dev/null +++ b/content/docs/references/data/FilterCondition.mdx @@ -0,0 +1,5 @@ +--- +title: FilterCondition +description: FilterCondition Schema Reference +--- + diff --git a/content/docs/references/data/NormalizedFilter.mdx b/content/docs/references/data/NormalizedFilter.mdx new file mode 100644 index 000000000..babf352d3 --- /dev/null +++ b/content/docs/references/data/NormalizedFilter.mdx @@ -0,0 +1,12 @@ +--- +title: NormalizedFilter +description: NormalizedFilter Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$and** | `Record \| any[]` | optional | | +| **$or** | `Record \| any[]` | optional | | +| **$not** | `Record \| any` | optional | | diff --git a/content/docs/references/data/QueryFilter.mdx b/content/docs/references/data/QueryFilter.mdx new file mode 100644 index 000000000..ca1b37363 --- /dev/null +++ b/content/docs/references/data/QueryFilter.mdx @@ -0,0 +1,10 @@ +--- +title: QueryFilter +description: QueryFilter Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **where** | `any` | optional | | diff --git a/content/docs/references/data/RangeOperator.mdx b/content/docs/references/data/RangeOperator.mdx new file mode 100644 index 000000000..618f6c323 --- /dev/null +++ b/content/docs/references/data/RangeOperator.mdx @@ -0,0 +1,10 @@ +--- +title: RangeOperator +description: RangeOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$between** | `any[]` | optional | | diff --git a/content/docs/references/data/SetOperator.mdx b/content/docs/references/data/SetOperator.mdx new file mode 100644 index 000000000..c3b5290c7 --- /dev/null +++ b/content/docs/references/data/SetOperator.mdx @@ -0,0 +1,11 @@ +--- +title: SetOperator +description: SetOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$in** | `any[]` | optional | | +| **$nin** | `any[]` | optional | | diff --git a/content/docs/references/data/SpecialOperator.mdx b/content/docs/references/data/SpecialOperator.mdx new file mode 100644 index 000000000..65aa64c98 --- /dev/null +++ b/content/docs/references/data/SpecialOperator.mdx @@ -0,0 +1,11 @@ +--- +title: SpecialOperator +description: SpecialOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$null** | `boolean` | optional | | +| **$exist** | `boolean` | optional | | diff --git a/content/docs/references/data/StringOperator.mdx b/content/docs/references/data/StringOperator.mdx new file mode 100644 index 000000000..a2b0b6f94 --- /dev/null +++ b/content/docs/references/data/StringOperator.mdx @@ -0,0 +1,12 @@ +--- +title: StringOperator +description: StringOperator Schema Reference +--- + +## Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **$contains** | `string` | optional | | +| **$startsWith** | `string` | optional | | +| **$endsWith** | `string` | optional | | diff --git a/packages/spec/json-schema/ComparisonOperator.json b/packages/spec/json-schema/ComparisonOperator.json new file mode 100644 index 000000000..aca7f2858 --- /dev/null +++ b/packages/spec/json-schema/ComparisonOperator.json @@ -0,0 +1,56 @@ +{ + "$ref": "#/definitions/ComparisonOperator", + "definitions": { + "ComparisonOperator": { + "type": "object", + "properties": { + "$gt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$gte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/EqualityOperator.json b/packages/spec/json-schema/EqualityOperator.json new file mode 100644 index 000000000..3c2f6e03f --- /dev/null +++ b/packages/spec/json-schema/EqualityOperator.json @@ -0,0 +1,14 @@ +{ + "$ref": "#/definitions/EqualityOperator", + "definitions": { + "EqualityOperator": { + "type": "object", + "properties": { + "$eq": {}, + "$ne": {} + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/FieldOperators.json b/packages/spec/json-schema/FieldOperators.json new file mode 100644 index 000000000..18e46df33 --- /dev/null +++ b/packages/spec/json-schema/FieldOperators.json @@ -0,0 +1,108 @@ +{ + "$ref": "#/definitions/FieldOperators", + "definitions": { + "FieldOperators": { + "type": "object", + "properties": { + "$eq": {}, + "$ne": {}, + "$gt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$gte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$in": { + "type": "array" + }, + "$nin": { + "type": "array" + }, + "$between": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + ] + }, + "$contains": { + "type": "string" + }, + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$null": { + "type": "boolean" + }, + "$exist": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/FilterCondition.json b/packages/spec/json-schema/FilterCondition.json new file mode 100644 index 000000000..a895df008 --- /dev/null +++ b/packages/spec/json-schema/FilterCondition.json @@ -0,0 +1,28 @@ +{ + "$ref": "#/definitions/FilterCondition", + "definitions": { + "FilterCondition": { + "allOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "$and": { + "type": "array", + "items": {} + }, + "$or": { + "type": "array", + "items": {} + }, + "$not": {} + } + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/NormalizedFilter.json b/packages/spec/json-schema/NormalizedFilter.json new file mode 100644 index 000000000..265f4f3a3 --- /dev/null +++ b/packages/spec/json-schema/NormalizedFilter.json @@ -0,0 +1,348 @@ +{ + "$ref": "#/definitions/NormalizedFilter", + "definitions": { + "NormalizedFilter": { + "type": "object", + "properties": { + "$and": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "$eq": {}, + "$ne": {}, + "$gt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$gte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$in": { + "type": "array" + }, + "$nin": { + "type": "array" + }, + "$between": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + ] + }, + "$contains": { + "type": "string" + }, + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$null": { + "type": "boolean" + }, + "$exist": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + {} + ] + } + }, + "$or": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "$eq": {}, + "$ne": {}, + "$gt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$gte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$in": { + "type": "array" + }, + "$nin": { + "type": "array" + }, + "$between": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + ] + }, + "$contains": { + "type": "string" + }, + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$null": { + "type": "boolean" + }, + "$exist": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + {} + ] + } + }, + "$not": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "$eq": {}, + "$ne": {}, + "$gt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$gte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$lte": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "$in": { + "type": "array" + }, + "$nin": { + "type": "array" + }, + "$between": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + ] + }, + "$contains": { + "type": "string" + }, + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + }, + "$null": { + "type": "boolean" + }, + "$exist": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + {} + ] + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/QueryFilter.json b/packages/spec/json-schema/QueryFilter.json new file mode 100644 index 000000000..747e8a4a6 --- /dev/null +++ b/packages/spec/json-schema/QueryFilter.json @@ -0,0 +1,34 @@ +{ + "$ref": "#/definitions/QueryFilter", + "definitions": { + "QueryFilter": { + "type": "object", + "properties": { + "where": { + "allOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "$and": { + "type": "array", + "items": {} + }, + "$or": { + "type": "array", + "items": {} + }, + "$not": {} + } + } + ] + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/RangeOperator.json b/packages/spec/json-schema/RangeOperator.json new file mode 100644 index 000000000..abfdcdbe6 --- /dev/null +++ b/packages/spec/json-schema/RangeOperator.json @@ -0,0 +1,41 @@ +{ + "$ref": "#/definitions/RangeOperator", + "definitions": { + "RangeOperator": { + "type": "object", + "properties": { + "$between": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "format": "date-time" + } + ] + } + ] + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/SetOperator.json b/packages/spec/json-schema/SetOperator.json new file mode 100644 index 000000000..d170434b1 --- /dev/null +++ b/packages/spec/json-schema/SetOperator.json @@ -0,0 +1,18 @@ +{ + "$ref": "#/definitions/SetOperator", + "definitions": { + "SetOperator": { + "type": "object", + "properties": { + "$in": { + "type": "array" + }, + "$nin": { + "type": "array" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/SpecialOperator.json b/packages/spec/json-schema/SpecialOperator.json new file mode 100644 index 000000000..b4f09d43c --- /dev/null +++ b/packages/spec/json-schema/SpecialOperator.json @@ -0,0 +1,18 @@ +{ + "$ref": "#/definitions/SpecialOperator", + "definitions": { + "SpecialOperator": { + "type": "object", + "properties": { + "$null": { + "type": "boolean" + }, + "$exist": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/StringOperator.json b/packages/spec/json-schema/StringOperator.json new file mode 100644 index 000000000..e3bf8b2ae --- /dev/null +++ b/packages/spec/json-schema/StringOperator.json @@ -0,0 +1,21 @@ +{ + "$ref": "#/definitions/StringOperator", + "definitions": { + "StringOperator": { + "type": "object", + "properties": { + "$contains": { + "type": "string" + }, + "$startsWith": { + "type": "string" + }, + "$endsWith": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/data/filter.test.ts b/packages/spec/src/data/filter.test.ts new file mode 100644 index 000000000..57fab6cbe --- /dev/null +++ b/packages/spec/src/data/filter.test.ts @@ -0,0 +1,622 @@ +import { describe, it, expect } from 'vitest'; +import { + FilterConditionSchema, + QueryFilterSchema, + FieldOperatorsSchema, + EqualityOperatorSchema, + ComparisonOperatorSchema, + SetOperatorSchema, + RangeOperatorSchema, + StringOperatorSchema, + SpecialOperatorSchema, + FILTER_OPERATORS, + LOGICAL_OPERATORS, + ALL_OPERATORS, + type Filter, + type QueryFilter, + type FieldOperators, +} from './filter.zod'; + +// ============================================================================ +// 3.1 Comparison Operators Tests +// ============================================================================ + +describe('EqualityOperatorSchema', () => { + it('should accept $eq operator', () => { + const filter = { $eq: 'active' }; + expect(() => EqualityOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $ne operator', () => { + const filter = { $ne: 'inactive' }; + expect(() => EqualityOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept any data type', () => { + expect(() => EqualityOperatorSchema.parse({ $eq: 'string' })).not.toThrow(); + expect(() => EqualityOperatorSchema.parse({ $eq: 123 })).not.toThrow(); + expect(() => EqualityOperatorSchema.parse({ $eq: true })).not.toThrow(); + expect(() => EqualityOperatorSchema.parse({ $eq: null })).not.toThrow(); + }); +}); + +describe('ComparisonOperatorSchema', () => { + it('should accept numeric comparisons', () => { + expect(() => ComparisonOperatorSchema.parse({ $gt: 18 })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $gte: 21 })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $lt: 100 })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $lte: 99 })).not.toThrow(); + }); + + it('should accept date comparisons', () => { + const date = new Date('2024-01-01'); + expect(() => ComparisonOperatorSchema.parse({ $gt: date })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $gte: date })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $lt: date })).not.toThrow(); + expect(() => ComparisonOperatorSchema.parse({ $lte: date })).not.toThrow(); + }); +}); + +// ============================================================================ +// 3.2 Set & Range Operators Tests +// ============================================================================ + +describe('SetOperatorSchema', () => { + it('should accept $in operator with array', () => { + const filter = { $in: ['admin', 'editor', 'viewer'] }; + expect(() => SetOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $nin operator with array', () => { + const filter = { $nin: ['guest', 'anonymous'] }; + expect(() => SetOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept arrays of different types', () => { + expect(() => SetOperatorSchema.parse({ $in: [1, 2, 3] })).not.toThrow(); + expect(() => SetOperatorSchema.parse({ $in: ['a', 'b', 'c'] })).not.toThrow(); + }); +}); + +describe('RangeOperatorSchema', () => { + it('should accept $between with numeric range', () => { + const filter = { $between: [18, 65] as [number, number] }; + expect(() => RangeOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $between with date range', () => { + const filter = { + $between: [new Date('2024-01-01'), new Date('2024-12-31')] as [Date, Date] + }; + expect(() => RangeOperatorSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// 3.3 String-Specific Operators Tests +// ============================================================================ + +describe('StringOperatorSchema', () => { + it('should accept $contains operator', () => { + const filter = { $contains: '@company.com' }; + expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $startsWith operator', () => { + const filter = { $startsWith: 'admin_' }; + expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $endsWith operator', () => { + const filter = { $endsWith: '.pdf' }; + expect(() => StringOperatorSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// 3.5 Special Operators Tests +// ============================================================================ + +describe('SpecialOperatorSchema', () => { + it('should accept $null operator', () => { + expect(() => SpecialOperatorSchema.parse({ $null: true })).not.toThrow(); + expect(() => SpecialOperatorSchema.parse({ $null: false })).not.toThrow(); + }); + + it('should accept $exist operator', () => { + expect(() => SpecialOperatorSchema.parse({ $exist: true })).not.toThrow(); + expect(() => SpecialOperatorSchema.parse({ $exist: false })).not.toThrow(); + }); +}); + +// ============================================================================ +// Combined Field Operators Tests +// ============================================================================ + +describe('FieldOperatorsSchema', () => { + it('should accept multiple operators combined', () => { + const filter: FieldOperators = { + $gte: 18, + $lte: 65, + }; + expect(() => FieldOperatorsSchema.parse(filter)).not.toThrow(); + }); + + it('should accept all operator types', () => { + const filter: FieldOperators = { + $eq: 'value', + $ne: 'other', + $gt: 10, + $gte: 10, + $lt: 100, + $lte: 100, + $in: ['a', 'b'], + $nin: ['c', 'd'], + $between: [1, 10] as [number, number], + $contains: 'test', + $startsWith: 'prefix', + $endsWith: 'suffix', + $null: false, + $exist: true, + }; + expect(() => FieldOperatorsSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// Filter Condition Tests - Basic +// ============================================================================ + +describe('FilterConditionSchema - Implicit Equality', () => { + it('should accept simple implicit equality', () => { + const filter = { status: 'active' }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept multiple implicit equalities', () => { + const filter = { + status: 'active', + role: 'admin', + verified: true, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); +}); + +describe('FilterConditionSchema - Explicit Operators', () => { + it('should accept explicit comparison operators', () => { + const filter = { + age: { $gte: 18 }, + score: { $lt: 100 }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept explicit string operators', () => { + const filter = { + email: { $contains: '@company.com' }, + username: { $startsWith: 'admin_' }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept explicit set operators', () => { + const filter = { + status: { $in: ['active', 'pending'] }, + role: { $nin: ['guest', 'anonymous'] }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// Filter Condition Tests - Logical Operators +// ============================================================================ + +describe('FilterConditionSchema - Logical Operators', () => { + it('should accept $and operator', () => { + const filter = { + $and: [ + { status: 'active' }, + { age: { $gte: 18 } }, + ], + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $or operator', () => { + const filter = { + $or: [ + { role: 'admin' }, + { role: 'editor' }, + ], + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept $not operator', () => { + const filter = { + $not: { status: 'deleted' }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept nested logical operators', () => { + const filter = { + $and: [ + { status: 'active' }, + { + $or: [ + { role: 'admin' }, + { permissions: { $contains: 'edit' } }, + ], + }, + ], + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// Filter Condition Tests - Relation Queries +// ============================================================================ + +describe('FilterConditionSchema - Nested Relations', () => { + it('should accept nested object filters', () => { + const filter = { + profile: { + verified: true, + }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept deeply nested relations', () => { + const filter = { + department: { + company: { + country: 'USA', + }, + }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept nested relations with operators', () => { + const filter = { + department: { + name: { $eq: 'IT' }, + employeeCount: { $gt: 10 }, + }, + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// Query Filter Tests - Complete Examples +// ============================================================================ + +describe('QueryFilterSchema - Complete Examples', () => { + it('should accept the example from specification', () => { + const filter: QueryFilter = { + where: { + status: 'active', // Implicit equality (AND) + age: { $gte: 18 }, // Explicit comparison (AND) + $or: [ // Logical branch + { role: 'admin' }, + { email: { $contains: '@company.com' } }, + ], + profile: { // Relation query + verified: true, + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept complex nested query', () => { + const filter: QueryFilter = { + where: { + $and: [ + { status: { $in: ['active', 'pending'] } }, + { createdAt: { $gte: new Date('2024-01-01') } }, + { + $or: [ + { priority: 'high' }, + { assignee: { $null: false } }, + ], + }, + ], + tags: { + name: { $contains: 'urgent' }, + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept query with all operator types', () => { + const filter: QueryFilter = { + where: { + // Equality + status: 'active', + archived: { $ne: true }, + + // Comparison + age: { $gte: 18, $lte: 65 }, + score: { $gt: 80 }, + + // Set + role: { $in: ['admin', 'editor'] }, + category: { $nin: ['spam', 'deleted'] }, + + // Range + createdAt: { + $between: [ + new Date('2024-01-01'), + new Date('2024-12-31') + ] as [Date, Date] + }, + + // String + email: { $contains: '@example.com' }, + username: { $startsWith: 'user_' }, + filename: { $endsWith: '.pdf' }, + + // Special + deletedAt: { $null: true }, + metadata: { $exist: true }, + + // Logical + $or: [ + { priority: 'high' }, + { urgent: true }, + ], + + // Nested relation + owner: { + department: { + name: 'Engineering', + }, + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// TypeScript Type Tests +// ============================================================================ + +describe('TypeScript Type System', () => { + it('should infer correct types for simple filter', () => { + interface User { + id: number; + name: string; + age: number; + email: string; + active: boolean; + } + + const filter: Filter = { + age: { $gte: 18 }, + email: { $contains: '@example.com' }, + active: true, + }; + + expect(filter).toBeDefined(); + }); + + it('should infer correct types for nested relations', () => { + interface User { + id: number; + name: string; + profile: { + verified: boolean; + bio: string; + }; + } + + const filter: Filter = { + name: { $startsWith: 'John' }, + profile: { + verified: true, + bio: { $contains: 'developer' }, + }, + }; + + expect(filter).toBeDefined(); + }); + + it('should support logical operators', () => { + interface Task { + title: string; + status: string; + priority: number; + } + + const filter: Filter = { + $or: [ + { status: 'urgent' }, + { priority: { $gt: 8 } }, + ], + $and: [ + { title: { $contains: 'bug' } }, + { status: { $ne: 'closed' } }, + ], + }; + + expect(filter).toBeDefined(); + }); +}); + +// ============================================================================ +// Constants Tests +// ============================================================================ + +describe('Filter Operator Constants', () => { + it('should export all filter operators', () => { + expect(FILTER_OPERATORS).toContain('$eq'); + expect(FILTER_OPERATORS).toContain('$ne'); + expect(FILTER_OPERATORS).toContain('$gt'); + expect(FILTER_OPERATORS).toContain('$gte'); + expect(FILTER_OPERATORS).toContain('$lt'); + expect(FILTER_OPERATORS).toContain('$lte'); + expect(FILTER_OPERATORS).toContain('$in'); + expect(FILTER_OPERATORS).toContain('$nin'); + expect(FILTER_OPERATORS).toContain('$between'); + expect(FILTER_OPERATORS).toContain('$contains'); + expect(FILTER_OPERATORS).toContain('$startsWith'); + expect(FILTER_OPERATORS).toContain('$endsWith'); + expect(FILTER_OPERATORS).toContain('$null'); + expect(FILTER_OPERATORS).toContain('$exist'); + }); + + it('should export all logical operators', () => { + expect(LOGICAL_OPERATORS).toContain('$and'); + expect(LOGICAL_OPERATORS).toContain('$or'); + expect(LOGICAL_OPERATORS).toContain('$not'); + }); + + it('should export all operators combined', () => { + expect(ALL_OPERATORS.length).toBe(FILTER_OPERATORS.length + LOGICAL_OPERATORS.length); + }); +}); + +// ============================================================================ +// Edge Cases & Validation +// ============================================================================ + +describe('FilterConditionSchema - Edge Cases', () => { + it('should accept empty where clause', () => { + const filter: QueryFilter = { where: {} }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept undefined where clause', () => { + const filter: QueryFilter = {}; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should accept empty logical arrays', () => { + const filter = { + $and: [], + $or: [], + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); + + it('should accept complex deeply nested structure', () => { + const filter = { + $and: [ + { + $or: [ + { status: 'active' }, + { + $and: [ + { archived: false }, + { deletedAt: { $null: true } }, + ], + }, + ], + }, + { + $not: { + role: { $in: ['banned', 'suspended'] }, + }, + }, + ], + }; + expect(() => FilterConditionSchema.parse(filter)).not.toThrow(); + }); +}); + +// ============================================================================ +// Real-World Use Case Examples +// ============================================================================ + +describe('Real-World Use Cases', () => { + it('should support user search query', () => { + const filter: QueryFilter = { + where: { + $or: [ + { name: { $contains: 'john' } }, + { email: { $contains: 'john' } }, + { username: { $contains: 'john' } }, + ], + status: 'active', + role: { $in: ['user', 'admin'] }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should support e-commerce order filtering', () => { + const filter: QueryFilter = { + where: { + status: { $in: ['pending', 'processing', 'shipped'] }, + totalAmount: { $gte: 100 }, + createdAt: { + $between: [ + new Date('2024-01-01'), + new Date('2024-12-31'), + ] as [Date, Date], + }, + customer: { + tier: 'premium', + country: { $in: ['US', 'CA', 'UK'] }, + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should support project task filtering', () => { + const filter: QueryFilter = { + where: { + $and: [ + { + $or: [ + { priority: 'high' }, + { dueDate: { $lt: new Date() } }, + ], + }, + { status: { $ne: 'completed' } }, + { assignee: { $null: false } }, + ], + project: { + status: 'active', + team: { + department: 'Engineering', + }, + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); + + it('should support content management filtering', () => { + const filter: QueryFilter = { + where: { + $and: [ + { published: true }, + { deletedAt: { $null: true } }, + ], + $or: [ + { title: { $contains: 'tutorial' } }, + { tags: { name: { $in: ['tutorial', 'guide', 'howto'] } } }, + ], + author: { + verified: true, + role: { $in: ['editor', 'admin'] }, + }, + publishedAt: { + $gte: new Date('2024-01-01'), + }, + }, + }; + expect(() => QueryFilterSchema.parse(filter)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts new file mode 100644 index 000000000..7ef376896 --- /dev/null +++ b/packages/spec/src/data/filter.zod.ts @@ -0,0 +1,354 @@ +import { z } from 'zod'; + +/** + * Unified Query DSL Specification + * + * Based on industry best practices from: + * - Prisma ORM + * - Strapi CMS + * - TypeORM + * - LoopBack Framework + * + * Version: 1.0.0 + * Status: Draft + * + * Objective: Define a JSON-based, database-agnostic query syntax standard + * for data filtering interactions between frontend and backend APIs. + * + * Design Principles: + * 1. Declarative: Frontend describes "what data to get", not "how to query" + * 2. Database Agnostic: Syntax contains no database-specific directives + * 3. Type Safe: Structure can be statically inferred by TypeScript + * 4. Convention over Configuration: Implicit syntax for common queries + */ + +// ============================================================================ +// 3.1 Comparison Operators +// ============================================================================ + +/** + * Comparison operators for equality and inequality checks. + * Supported data types: Any + */ +export const EqualityOperatorSchema = z.object({ + /** Equal to (default) - SQL: = | MongoDB: $eq */ + $eq: z.any().optional(), + + /** Not equal to - SQL: <> or != | MongoDB: $ne */ + $ne: z.any().optional(), +}); + +/** + * Comparison operators for numeric and date comparisons. + * Supported data types: Number, Date + */ +export const ComparisonOperatorSchema = z.object({ + /** Greater than - SQL: > | MongoDB: $gt */ + $gt: z.union([z.number(), z.date()]).optional(), + + /** Greater than or equal to - SQL: >= | MongoDB: $gte */ + $gte: z.union([z.number(), z.date()]).optional(), + + /** Less than - SQL: < | MongoDB: $lt */ + $lt: z.union([z.number(), z.date()]).optional(), + + /** Less than or equal to - SQL: <= | MongoDB: $lte */ + $lte: z.union([z.number(), z.date()]).optional(), +}); + +// ============================================================================ +// 3.2 Set & Range Operators +// ============================================================================ + +/** + * Set operators for membership checks. + */ +export const SetOperatorSchema = z.object({ + /** In list - SQL: IN (?, ?, ?) | MongoDB: $in */ + $in: z.array(z.any()).optional(), + + /** Not in list - SQL: NOT IN (...) | MongoDB: $nin */ + $nin: z.array(z.any()).optional(), +}); + +/** + * Range operator for interval checks (closed interval). + * SQL: BETWEEN ? AND ? | MongoDB: $gte AND $lte + */ +export const RangeOperatorSchema = z.object({ + /** Between (inclusive) - takes [min, max] array */ + $between: z.tuple([ + z.union([z.number(), z.date()]), + z.union([z.number(), z.date()]) + ]).optional(), +}); + +// ============================================================================ +// 3.3 String-Specific Operators +// ============================================================================ + +/** + * String pattern matching operators. + * Note: Case sensitivity should be handled at backend level. + */ +export const StringOperatorSchema = z.object({ + /** Contains substring - SQL: LIKE %?% | MongoDB: $regex */ + $contains: z.string().optional(), + + /** Starts with prefix - SQL: LIKE ?% | MongoDB: $regex */ + $startsWith: z.string().optional(), + + /** Ends with suffix - SQL: LIKE %? | MongoDB: $regex */ + $endsWith: z.string().optional(), +}); + +// ============================================================================ +// 3.5 Special Operators +// ============================================================================ + +/** + * Special check operators for null and existence. + */ +export const SpecialOperatorSchema = z.object({ + /** Is null check - SQL: IS NULL (true) / IS NOT NULL (false) | MongoDB: field: null */ + $null: z.boolean().optional(), + + /** Field exists check (primarily for NoSQL) - MongoDB: $exists */ + $exist: z.boolean().optional(), +}); + +// ============================================================================ +// Combined Field Operators +// ============================================================================ + +/** + * All field-level operators combined. + * These can be applied to individual fields in a filter. + */ +export const FieldOperatorsSchema = z.object({ + // Equality + $eq: z.any().optional(), + $ne: z.any().optional(), + + // Comparison (numeric/date) + $gt: z.union([z.number(), z.date()]).optional(), + $gte: z.union([z.number(), z.date()]).optional(), + $lt: z.union([z.number(), z.date()]).optional(), + $lte: z.union([z.number(), z.date()]).optional(), + + // Set & Range + $in: z.array(z.any()).optional(), + $nin: z.array(z.any()).optional(), + $between: z.tuple([ + z.union([z.number(), z.date()]), + z.union([z.number(), z.date()]) + ]).optional(), + + // String-specific + $contains: z.string().optional(), + $startsWith: z.string().optional(), + $endsWith: z.string().optional(), + + // Special + $null: z.boolean().optional(), + $exist: z.boolean().optional(), +}); + +// ============================================================================ +// 3.4 Logical Operators & Recursive Filter Structure +// ============================================================================ + +/** + * Recursive filter type that supports: + * 1. Implicit equality: { field: value } + * 2. Explicit operators: { field: { $op: value } } + * 3. Logical combinations: { $and: [...], $or: [...], $not: {...} } + * 4. Nested relations: { relation: { field: value } } + */ +export type FilterCondition = { + [key: string]: + | any // Implicit equality: key: value + | z.infer // Explicit operators: key: { $op: value } + | FilterCondition; // Nested relation: key: { nested: ... } +} & { + /** Logical AND - combines all conditions that must be true */ + $and?: FilterCondition[]; + + /** Logical OR - at least one condition must be true */ + $or?: FilterCondition[]; + + /** Logical NOT - negates the condition */ + $not?: FilterCondition; +}; + +/** + * Zod schema for recursive filter validation. + * Uses z.lazy() to handle recursive structure. + */ +export const FilterConditionSchema: z.ZodType = z.lazy(() => + z.record(z.string(), z.any()).and( + z.object({ + $and: z.array(FilterConditionSchema).optional(), + $or: z.array(FilterConditionSchema).optional(), + $not: FilterConditionSchema.optional(), + }) + ) +); + +// ============================================================================ +// Query Filter Wrapper +// ============================================================================ + +/** + * Top-level query filter wrapper. + * This is typically used as the "where" clause in a query. + * + * @example + * ```typescript + * const filter: QueryFilter = { + * where: { + * status: "active", // Implicit equality + * age: { $gte: 18 }, // Explicit operator + * $or: [ // Logical combination + * { role: "admin" }, + * { email: { $contains: "@company.com" } } + * ], + * profile: { // Nested relation + * verified: true + * } + * } + * } + * ``` + */ +export const QueryFilterSchema = z.object({ + where: FilterConditionSchema.optional(), +}); + +// ============================================================================ +// TypeScript Type Exports +// ============================================================================ + +/** + * Type-safe filter operators for use in TypeScript. + * + * @example + * ```typescript + * type UserFilter = Filter; + * + * const filter: UserFilter = { + * age: { $gte: 18 }, + * email: { $contains: "@example.com" } + * }; + * ``` + */ +export type Filter = { + [K in keyof T]?: + | T[K] // Implicit equality + | { + $eq?: T[K]; + $ne?: T[K]; + $gt?: T[K] extends number | Date ? T[K] : never; + $gte?: T[K] extends number | Date ? T[K] : never; + $lt?: T[K] extends number | Date ? T[K] : never; + $lte?: T[K] extends number | Date ? T[K] : never; + $in?: T[K][]; + $nin?: T[K][]; + $between?: T[K] extends number | Date ? [T[K], T[K]] : never; + $contains?: T[K] extends string ? string : never; + $startsWith?: T[K] extends string ? string : never; + $endsWith?: T[K] extends string ? string : never; + $null?: boolean; + $exist?: boolean; + } + | (T[K] extends object ? Filter : never); // Nested relation +} & { + $and?: Filter[]; + $or?: Filter[]; + $not?: Filter; +}; + +/** + * Scalar types supported by the filter system. + */ +export type Scalar = string | number | boolean | Date | null; + +// Export inferred types +export type FieldOperators = z.infer; +export type QueryFilter = z.infer; + +// ============================================================================ +// Normalization Utilities (Internal Representation) +// ============================================================================ + +/** + * Normalized filter AST structure. + * This is the internal representation after converting all syntactic sugar + * to explicit operators. + * + * Stage 1: Normalization Pass + * Input: { age: 18, role: "admin" } + * Output: { $and: [{ age: { $eq: 18 } }, { role: { $eq: "admin" } }] } + * + * This simplifies adapter implementation by providing a consistent structure. + */ +export const NormalizedFilterSchema: z.ZodType = z.lazy(() => + z.object({ + $and: z.array( + z.union([ + // Field condition: { field: { $op: value } } + z.record(z.string(), FieldOperatorsSchema), + // Nested logical group + NormalizedFilterSchema, + ]) + ).optional(), + + $or: z.array( + z.union([ + z.record(z.string(), FieldOperatorsSchema), + NormalizedFilterSchema, + ]) + ).optional(), + + $not: z.union([ + z.record(z.string(), FieldOperatorsSchema), + NormalizedFilterSchema, + ]).optional(), + }) +); + +export type NormalizedFilter = z.infer; + +// ============================================================================ +// Constants & Metadata +// ============================================================================ + +/** + * All supported operator keys. + * Useful for validation and parsing. + */ +export const FILTER_OPERATORS = [ + // Equality + '$eq', '$ne', + // Comparison + '$gt', '$gte', '$lt', '$lte', + // Set & Range + '$in', '$nin', '$between', + // String + '$contains', '$startsWith', '$endsWith', + // Special + '$null', '$exist', +] as const; + +/** + * Logical operator keys. + */ +export const LOGICAL_OPERATORS = ['$and', '$or', '$not'] as const; + +/** + * All operator keys (field + logical). + */ +export const ALL_OPERATORS = [...FILTER_OPERATORS, ...LOGICAL_OPERATORS] as const; + +export type FilterOperatorKey = typeof FILTER_OPERATORS[number]; +export type LogicalOperatorKey = typeof LOGICAL_OPERATORS[number]; +export type OperatorKey = typeof ALL_OPERATORS[number]; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index c82e5fd33..1bc27b4d5 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -17,6 +17,7 @@ export * from './data/workflow.zod'; export * from './data/flow.zod'; export * from './data/dataset.zod'; export * from './data/query.zod'; +export * from './data/filter.zod'; // Unified Query DSL export * from './data/mapping.zod'; export * from './data/trigger.zod';