@gblikas/querykit 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.cursor/BUGBOT.md +21 -0
  2. package/.cursor/rules/01-project-structure.mdc +77 -0
  3. package/.cursor/rules/02-typescript-standards.mdc +105 -0
  4. package/.cursor/rules/03-testing-standards.mdc +78 -0
  5. package/.cursor/rules/04-query-language.mdc +79 -0
  6. package/.cursor/rules/05-solid-principles.mdc +118 -0
  7. package/.cursor/rules/liqe-readme-docs.mdc +438 -0
  8. package/.devcontainer/devcontainer.json +25 -0
  9. package/.eslintignore +1 -0
  10. package/.eslintrc.js +39 -0
  11. package/.github/dependabot.yml +12 -0
  12. package/.github/workflows/ci.yml +114 -0
  13. package/.github/workflows/publish.yml +61 -0
  14. package/.husky/pre-commit +30 -0
  15. package/.prettierrc +10 -0
  16. package/CONTRIBUTING.md +187 -0
  17. package/LICENSE +674 -0
  18. package/README.md +237 -0
  19. package/dist/adapters/drizzle/index.d.ts +122 -0
  20. package/dist/adapters/drizzle/index.js +166 -0
  21. package/dist/adapters/index.d.ts +7 -0
  22. package/dist/adapters/index.js +25 -0
  23. package/dist/adapters/types.d.ts +60 -0
  24. package/dist/adapters/types.js +8 -0
  25. package/dist/index.d.ts +75 -0
  26. package/dist/index.js +118 -0
  27. package/dist/parser/index.d.ts +2 -0
  28. package/dist/parser/index.js +18 -0
  29. package/dist/parser/parser.d.ts +51 -0
  30. package/dist/parser/parser.js +201 -0
  31. package/dist/parser/types.d.ts +68 -0
  32. package/dist/parser/types.js +5 -0
  33. package/dist/query/builder.d.ts +61 -0
  34. package/dist/query/builder.js +188 -0
  35. package/dist/query/index.d.ts +2 -0
  36. package/dist/query/index.js +18 -0
  37. package/dist/query/types.d.ts +79 -0
  38. package/dist/query/types.js +2 -0
  39. package/dist/security/index.d.ts +2 -0
  40. package/dist/security/index.js +18 -0
  41. package/dist/security/types.d.ts +181 -0
  42. package/dist/security/types.js +43 -0
  43. package/dist/security/validator.d.ts +191 -0
  44. package/dist/security/validator.js +344 -0
  45. package/dist/translators/drizzle/index.d.ts +73 -0
  46. package/dist/translators/drizzle/index.js +260 -0
  47. package/dist/translators/index.d.ts +8 -0
  48. package/dist/translators/index.js +27 -0
  49. package/dist/translators/sql/index.d.ts +108 -0
  50. package/dist/translators/sql/index.js +252 -0
  51. package/dist/translators/types.d.ts +39 -0
  52. package/dist/translators/types.js +8 -0
  53. package/examples/qk-next/README.md +35 -0
  54. package/examples/qk-next/app/favicon.ico +0 -0
  55. package/examples/qk-next/app/globals.css +122 -0
  56. package/examples/qk-next/app/layout.tsx +121 -0
  57. package/examples/qk-next/app/page.tsx +813 -0
  58. package/examples/qk-next/app/providers.tsx +80 -0
  59. package/examples/qk-next/components/aurora-background.tsx +12 -0
  60. package/examples/qk-next/components/github-stars.tsx +51 -0
  61. package/examples/qk-next/components/mode-toggle.tsx +27 -0
  62. package/examples/qk-next/components/reactbits/blocks/Backgrounds/Aurora/Aurora.tsx +217 -0
  63. package/examples/qk-next/components/reactbits/blocks/Backgrounds/LightRays/LightRays.tsx +474 -0
  64. package/examples/qk-next/components/theme-provider.tsx +11 -0
  65. package/examples/qk-next/components/ui/card.tsx +92 -0
  66. package/examples/qk-next/components/ui/command.tsx +184 -0
  67. package/examples/qk-next/components/ui/dialog.tsx +143 -0
  68. package/examples/qk-next/components/ui/drawer.tsx +135 -0
  69. package/examples/qk-next/components/ui/hover-card.tsx +44 -0
  70. package/examples/qk-next/components/ui/icons.tsx +148 -0
  71. package/examples/qk-next/components/ui/sonner.tsx +26 -0
  72. package/examples/qk-next/components/ui/table.tsx +117 -0
  73. package/examples/qk-next/components.json +21 -0
  74. package/examples/qk-next/eslint.config.mjs +21 -0
  75. package/examples/qk-next/jsrepo.json +13 -0
  76. package/examples/qk-next/lib/utils.ts +6 -0
  77. package/examples/qk-next/next.config.ts +8 -0
  78. package/examples/qk-next/package.json +48 -0
  79. package/examples/qk-next/pnpm-lock.yaml +5558 -0
  80. package/examples/qk-next/postcss.config.mjs +5 -0
  81. package/examples/qk-next/public/file.svg +1 -0
  82. package/examples/qk-next/public/globe.svg +1 -0
  83. package/examples/qk-next/public/next.svg +1 -0
  84. package/examples/qk-next/public/vercel.svg +1 -0
  85. package/examples/qk-next/public/window.svg +1 -0
  86. package/examples/qk-next/tsconfig.json +42 -0
  87. package/examples/qk-next/types/sonner.d.ts +3 -0
  88. package/jest.config.js +26 -0
  89. package/package.json +51 -0
  90. package/src/adapters/drizzle/drizzle-adapter.test.ts +115 -0
  91. package/src/adapters/drizzle/index.ts +299 -0
  92. package/src/adapters/index.ts +11 -0
  93. package/src/adapters/types.ts +72 -0
  94. package/src/index.ts +194 -0
  95. package/src/integration.test.ts +202 -0
  96. package/src/parser/index.ts +2 -0
  97. package/src/parser/parser.test.ts +1056 -0
  98. package/src/parser/parser.ts +268 -0
  99. package/src/parser/types.ts +97 -0
  100. package/src/query/builder.test.ts +272 -0
  101. package/src/query/builder.ts +274 -0
  102. package/src/query/index.ts +2 -0
  103. package/src/query/types.ts +107 -0
  104. package/src/security/index.ts +2 -0
  105. package/src/security/types.ts +210 -0
  106. package/src/security/validator.test.ts +459 -0
  107. package/src/security/validator.ts +395 -0
  108. package/src/security.test.ts +366 -0
  109. package/src/translators/drizzle/drizzle-translator.test.ts +128 -0
  110. package/src/translators/drizzle/index.test.ts +45 -0
  111. package/src/translators/drizzle/index.ts +346 -0
  112. package/src/translators/index.ts +14 -0
  113. package/src/translators/sql/index.test.ts +45 -0
  114. package/src/translators/sql/index.ts +331 -0
  115. package/src/translators/sql/sql-translator.test.ts +419 -0
  116. package/src/translators/types.ts +44 -0
  117. package/src/types/sonner.d.ts +3 -0
  118. package/tsconfig.json +34 -0
@@ -0,0 +1,268 @@
1
+ import { parse as liqeParse } from 'liqe';
2
+ import type {
3
+ BooleanOperatorToken,
4
+ ExpressionToken,
5
+ FieldToken,
6
+ ImplicitBooleanOperatorToken,
7
+ LiqeQuery,
8
+ LogicalExpressionToken,
9
+ ParenthesizedExpressionToken,
10
+ TagToken,
11
+ UnaryOperatorToken
12
+ } from 'liqe';
13
+ import {
14
+ ComparisonOperator,
15
+ IComparisonExpression,
16
+ ILogicalExpression,
17
+ IParserOptions,
18
+ IQueryParser,
19
+ QueryExpression,
20
+ QueryValue
21
+ } from './types';
22
+
23
+ /**
24
+ * Error thrown when query parsing fails
25
+ */
26
+ export class QueryParseError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'QueryParseError';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Implementation of the QueryKit parser using Liqe
35
+ */
36
+ export class QueryParser implements IQueryParser {
37
+ private options: Required<IParserOptions>;
38
+
39
+ constructor(options: IParserOptions = {}) {
40
+ this.options = {
41
+ caseInsensitiveFields: options.caseInsensitiveFields ?? false,
42
+ fieldMappings: options.fieldMappings ?? {}
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse a query string into a QueryKit AST
48
+ */
49
+ public parse(query: string): QueryExpression {
50
+ try {
51
+ const liqeAst = liqeParse(query);
52
+ return this.convertLiqeAst(liqeAst);
53
+ } catch (error) {
54
+ throw new QueryParseError(
55
+ `Failed to parse query: ${error instanceof Error ? error.message : String(error)}`
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Validate a query string
62
+ */
63
+ public validate(query: string): boolean {
64
+ try {
65
+ const ast = liqeParse(query);
66
+ this.convertLiqeAst(ast);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Convert a Liqe AST node to a QueryKit expression
75
+ */
76
+ private convertLiqeAst(node: LiqeQuery): QueryExpression {
77
+ if (!node || typeof node !== 'object') {
78
+ throw new QueryParseError('Invalid AST node');
79
+ }
80
+
81
+ switch (node.type) {
82
+ case 'LogicalExpression': {
83
+ const logicalNode = node as LogicalExpressionToken;
84
+ const operator = (logicalNode.operator as BooleanOperatorToken | ImplicitBooleanOperatorToken).operator;
85
+ return this.createLogicalExpression(
86
+ this.convertLogicalOperator(operator),
87
+ logicalNode.left,
88
+ logicalNode.right
89
+ );
90
+ }
91
+
92
+ case 'UnaryOperator': {
93
+ const unaryNode = node as UnaryOperatorToken;
94
+ return this.createLogicalExpression('NOT', unaryNode.operand);
95
+ }
96
+
97
+ case 'Tag': {
98
+ const tagNode = node as TagToken;
99
+ const field = tagNode.field as FieldToken;
100
+ const expression = tagNode.expression as ExpressionToken & { value: QueryValue };
101
+
102
+ if (!field || !expression) {
103
+ throw new QueryParseError('Invalid field or expression in Tag node');
104
+ }
105
+
106
+ const fieldName = this.normalizeFieldName(field.name);
107
+ const operator = this.convertLiqeOperator(tagNode.operator.operator);
108
+ const value = this.convertLiqeValue(expression.value);
109
+
110
+ // Check for wildcard patterns in string values
111
+ if (operator === '==' && typeof value === 'string' && (value.includes('*') || value.includes('?'))) {
112
+ return this.createComparisonExpression(
113
+ fieldName,
114
+ 'LIKE',
115
+ value
116
+ );
117
+ }
118
+
119
+ return this.createComparisonExpression(
120
+ fieldName,
121
+ operator,
122
+ value
123
+ );
124
+ }
125
+
126
+ case 'EmptyExpression':
127
+ if ('left' in node && node.left) {
128
+ return this.convertLiqeAst(node.left);
129
+ }
130
+ throw new QueryParseError('Invalid empty expression');
131
+
132
+ case 'ParenthesizedExpression': {
133
+ const parenNode = node as ParenthesizedExpressionToken;
134
+ if (parenNode.expression) {
135
+ return this.convertLiqeAst(parenNode.expression);
136
+ }
137
+ throw new QueryParseError('Invalid parenthesized expression');
138
+ }
139
+
140
+ default:
141
+ throw new QueryParseError(`Unsupported node type: ${(node as { type: string }).type}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Convert a Liqe logical operator to a QueryKit operator
147
+ */
148
+ private convertLogicalOperator(operator: string): 'AND' | 'OR' | 'NOT' {
149
+ switch (operator.toLowerCase()) {
150
+ case 'and':
151
+ return 'AND';
152
+ case 'or':
153
+ return 'OR';
154
+ case 'not':
155
+ return 'NOT';
156
+ default:
157
+ throw new QueryParseError(`Unsupported logical operator: ${operator}`);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Create a logical expression from Liqe nodes
163
+ */
164
+ private createLogicalExpression(
165
+ operator: 'AND' | 'OR' | 'NOT',
166
+ left: LiqeQuery,
167
+ right?: LiqeQuery
168
+ ): ILogicalExpression {
169
+ return {
170
+ type: 'logical',
171
+ operator,
172
+ left: this.convertLiqeAst(left),
173
+ ...(right && { right: this.convertLiqeAst(right) })
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Create a comparison expression
179
+ */
180
+ private createComparisonExpression(
181
+ field: string,
182
+ operator: ComparisonOperator,
183
+ value: QueryValue
184
+ ): IComparisonExpression {
185
+ return {
186
+ type: 'comparison',
187
+ field,
188
+ operator,
189
+ value
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Convert a Liqe operator to a QueryKit operator
195
+ */
196
+ private convertLiqeOperator(operator: string): ComparisonOperator {
197
+ // Handle the case where operator is part of the value for comparison operators
198
+ if (operator === ':') {
199
+ return '==';
200
+ }
201
+
202
+ // Check if the operator is prefixed with a colon
203
+ const actualOperator = operator.startsWith(':') ? operator.substring(1) : operator;
204
+
205
+ // Map Liqe operators to QueryKit operators
206
+ const operatorMap: Record<string, ComparisonOperator> = {
207
+ '=': '==',
208
+ '!=': '!=',
209
+ '>': '>',
210
+ '>=': '>=',
211
+ '<': '<',
212
+ '<=': '<=',
213
+ 'in': 'IN',
214
+ 'not in': 'NOT IN'
215
+ };
216
+
217
+ const queryKitOperator = operatorMap[actualOperator.toLowerCase()];
218
+ if (!queryKitOperator) {
219
+ throw new QueryParseError(`Unsupported operator: ${operator}`);
220
+ }
221
+
222
+ return queryKitOperator;
223
+ }
224
+
225
+ /**
226
+ * Convert a Liqe value to a QueryKit value
227
+ * Security: Strict type checking to prevent NoSQL injection via objects
228
+ */
229
+ private convertLiqeValue(value: unknown): QueryValue {
230
+ // Security fix: Strict type checking to prevent object injection
231
+ if (value === null) {
232
+ return null;
233
+ }
234
+
235
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
236
+ return value as QueryValue;
237
+ }
238
+
239
+ if (Array.isArray(value)) {
240
+ // Security fix: Recursively validate array elements
241
+ const validatedArray = value.map(item => {
242
+ if (typeof item === 'object' && item !== null) {
243
+ throw new QueryParseError('Object values are not allowed in arrays');
244
+ }
245
+ return this.convertLiqeValue(item);
246
+ });
247
+ return validatedArray as QueryValue;
248
+ }
249
+
250
+ // Security fix: Reject all object types to prevent NoSQL injection
251
+ if (typeof value === 'object') {
252
+ throw new QueryParseError('Object values are not supported for security reasons');
253
+ }
254
+
255
+ throw new QueryParseError(`Unsupported value type: ${typeof value}`);
256
+ }
257
+
258
+ /**
259
+ * Normalize a field name based on parser options
260
+ */
261
+ private normalizeFieldName(field: string): string {
262
+ const normalizedField = this.options.caseInsensitiveFields
263
+ ? field.toLowerCase()
264
+ : field;
265
+
266
+ return this.options.fieldMappings[normalizedField] ?? normalizedField;
267
+ }
268
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Core AST types for QueryKit's parser
3
+ */
4
+
5
+ /**
6
+ * Represents a comparison operator in a query expression
7
+ */
8
+ export type ComparisonOperator =
9
+ | '=='
10
+ | '!='
11
+ | '>'
12
+ | '>='
13
+ | '<'
14
+ | '<='
15
+ | 'IN'
16
+ | 'NOT IN'
17
+ | 'LIKE';
18
+
19
+ /**
20
+ * Represents a logical operator in a query expression
21
+ */
22
+ export type LogicalOperator =
23
+ | 'AND'
24
+ | 'OR'
25
+ | 'NOT';
26
+
27
+ /**
28
+ * Represents a value that can be used in a query expression
29
+ */
30
+ export type QueryValue =
31
+ | string
32
+ | number
33
+ | boolean
34
+ | null
35
+ | Array<string | number | boolean | null>;
36
+
37
+ /**
38
+ * Represents a comparison expression node in the AST
39
+ */
40
+ export interface IComparisonExpression {
41
+ type: 'comparison';
42
+ field: string;
43
+ operator: ComparisonOperator;
44
+ value: QueryValue;
45
+ }
46
+
47
+ /**
48
+ * Represents a logical expression node in the AST
49
+ */
50
+ export interface ILogicalExpression {
51
+ type: 'logical';
52
+ operator: LogicalOperator;
53
+ left: QueryExpression;
54
+ right?: QueryExpression;
55
+ }
56
+
57
+ /**
58
+ * Represents any valid query expression node
59
+ */
60
+ export type QueryExpression =
61
+ | IComparisonExpression
62
+ | ILogicalExpression;
63
+
64
+ /**
65
+ * Configuration options for the parser
66
+ */
67
+ export interface IParserOptions {
68
+ /**
69
+ * Whether to allow case-insensitive field names
70
+ */
71
+ caseInsensitiveFields?: boolean;
72
+
73
+ /**
74
+ * Custom field name mappings
75
+ */
76
+ fieldMappings?: Record<string, string>;
77
+ }
78
+
79
+ /**
80
+ * Interface for the query parser
81
+ */
82
+ export interface IQueryParser {
83
+ /**
84
+ * Parse a query string into an AST
85
+ * @param query The query string to parse
86
+ * @returns The parsed AST
87
+ * @throws {QueryParseError} If the query is invalid
88
+ */
89
+ parse(query: string): QueryExpression;
90
+
91
+ /**
92
+ * Validate a query string without fully parsing it
93
+ * @param query The query string to validate
94
+ * @returns true if the query is valid, false otherwise
95
+ */
96
+ validate(query: string): boolean;
97
+ }
@@ -0,0 +1,272 @@
1
+ import { QueryBuilder } from './builder';
2
+ import { ComparisonOperator } from './types';
3
+
4
+ interface ITodo {
5
+ id: number;
6
+ title: string;
7
+ priority: number;
8
+ status: string;
9
+ dueDate: string;
10
+ }
11
+
12
+ describe('QueryBuilder', () => {
13
+ let builder: QueryBuilder<ITodo>;
14
+
15
+ beforeEach(() => {
16
+ builder = new QueryBuilder<ITodo>();
17
+ });
18
+
19
+ describe('where', () => {
20
+ it('should create a simple comparison query', () => {
21
+ const query = builder.where('priority', '>', 2).toString();
22
+
23
+ expect(query).toBe('priority:>2');
24
+ });
25
+
26
+ it('should handle string values', () => {
27
+ const query = builder.where('status', '==', 'active').toString();
28
+
29
+ expect(query).toBe('status:"active"');
30
+ });
31
+
32
+ it('should handle null values', () => {
33
+ const query = builder.where('dueDate', '==', null).toString();
34
+
35
+ expect(query).toBe('dueDate:null');
36
+ });
37
+
38
+ it('should handle array values', () => {
39
+ const query = builder
40
+ .where('status', 'IN', ['active', 'pending'])
41
+ .toString();
42
+
43
+ expect(query).toBe('status:in["active","pending"]');
44
+ });
45
+
46
+ it('should accept direct query string syntax', () => {
47
+ const query = builder.where('priority:>2').toString();
48
+
49
+ expect(query).toBe('priority:>2');
50
+ });
51
+
52
+ it('should combine direct query strings with AND', () => {
53
+ const query = builder
54
+ .where('priority:>2')
55
+ .andWhere('status:"active"')
56
+ .toString();
57
+
58
+ expect(query).toBe('(priority:>2) AND status:"active"');
59
+ });
60
+ });
61
+
62
+ describe('andWhere', () => {
63
+ it('should combine conditions with AND', () => {
64
+ const query = builder
65
+ .where('priority', '>', 2)
66
+ .andWhere('status', '==', 'active')
67
+ .toString();
68
+
69
+ expect(query).toBe('(priority:>2) AND status:"active"');
70
+ });
71
+
72
+ it('should handle multiple AND conditions', () => {
73
+ const query = builder
74
+ .where('priority', '>', 2)
75
+ .andWhere('status', '==', 'active')
76
+ .andWhere('dueDate', '!=', null)
77
+ .toString();
78
+
79
+ expect(query).toBe(
80
+ '((priority:>2) AND status:"active") AND dueDate:!=null'
81
+ );
82
+ });
83
+
84
+ it('should handle andWhere as first condition with field-operator-value', () => {
85
+ const query = builder.andWhere('priority', '>', 2).toString();
86
+
87
+ expect(query).toBe('priority:>2');
88
+ });
89
+
90
+ it('should handle andWhere as first condition with query string', () => {
91
+ const query = builder.andWhere('priority:>2').toString();
92
+
93
+ expect(query).toBe('priority:>2');
94
+ });
95
+ });
96
+
97
+ describe('orWhere', () => {
98
+ it('should combine conditions with OR', () => {
99
+ const query = builder
100
+ .where('status', '==', 'active')
101
+ .orWhere('status', '==', 'pending')
102
+ .toString();
103
+
104
+ expect(query).toBe('(status:"active") OR status:"pending"');
105
+ });
106
+
107
+ it('should handle multiple OR conditions', () => {
108
+ const query = builder
109
+ .where('status', '==', 'active')
110
+ .orWhere('status', '==', 'pending')
111
+ .orWhere('status', '==', 'inactive')
112
+ .toString();
113
+
114
+ expect(query).toBe(
115
+ '((status:"active") OR status:"pending") OR status:"inactive"'
116
+ );
117
+ });
118
+
119
+ it('should handle orWhere as first condition with field-operator-value', () => {
120
+ const query = builder.orWhere('priority', '>', 2).toString();
121
+
122
+ expect(query).toBe('priority:>2');
123
+ });
124
+
125
+ it('should handle orWhere as first condition with query string', () => {
126
+ const query = builder.orWhere('priority:>2').toString();
127
+
128
+ expect(query).toBe('priority:>2');
129
+ });
130
+ });
131
+
132
+ describe('notWhere', () => {
133
+ it('should create a NOT condition', () => {
134
+ const query = builder.notWhere('status', '==', 'inactive').toString();
135
+
136
+ expect(query).toBe('NOT status:"inactive"');
137
+ });
138
+
139
+ it('should combine NOT with other conditions', () => {
140
+ const query = builder
141
+ .where('priority', '>', 2)
142
+ .notWhere('status', '==', 'inactive')
143
+ .toString();
144
+
145
+ expect(query).toBe('(priority:>2) AND NOT status:"inactive"');
146
+ });
147
+
148
+ it('should handle notWhere as first condition with query string', () => {
149
+ const query = builder.notWhere('status:"inactive"').toString();
150
+
151
+ expect(query).toBe('NOT status:"inactive"');
152
+ });
153
+
154
+ it('should handle notWhere with query string and existing expression', () => {
155
+ const query = builder
156
+ .where('priority', '>', 2)
157
+ .notWhere('status:"inactive"')
158
+ .toString();
159
+
160
+ expect(query).toBe('(priority:>2) AND NOT status:"inactive"');
161
+ });
162
+ });
163
+
164
+ describe('orderBy', () => {
165
+ it('should add an ORDER BY clause', () => {
166
+ const query = builder
167
+ .where('priority', '>', 2)
168
+ .orderBy('title', 'asc')
169
+ .toString();
170
+
171
+ expect(query).toBe('priority:>2 ORDER BY title ASC');
172
+ });
173
+
174
+ it('should use ASC as default direction', () => {
175
+ const query = builder
176
+ .where('priority', '>', 2)
177
+ .orderBy('title')
178
+ .toString();
179
+
180
+ expect(query).toBe('priority:>2 ORDER BY title ASC');
181
+ });
182
+ });
183
+
184
+ describe('limit and offset', () => {
185
+ it('should add LIMIT and OFFSET clauses', () => {
186
+ const query = builder
187
+ .where('priority', '>', 2)
188
+ .limit(10)
189
+ .offset(20)
190
+ .toString();
191
+
192
+ expect(query).toBe('priority:>2 LIMIT 10 OFFSET 20');
193
+ });
194
+ });
195
+
196
+ describe('getExpression', () => {
197
+ it('should return a valid expression', () => {
198
+ const expression = builder
199
+ .where('priority', '>', 2)
200
+ .andWhere('status', '==', 'active')
201
+ .getExpression();
202
+
203
+ expect(expression).toEqual({
204
+ type: 'logical',
205
+ operator: 'AND',
206
+ left: {
207
+ type: 'comparison',
208
+ field: 'priority',
209
+ operator: '>',
210
+ value: 2
211
+ },
212
+ right: {
213
+ type: 'comparison',
214
+ field: 'status',
215
+ operator: '==',
216
+ value: 'active'
217
+ }
218
+ });
219
+ });
220
+ });
221
+
222
+ describe('wildcard support', () => {
223
+ it('should support wildcard syntax in direct query strings', () => {
224
+ const query = builder.where('title:Task*').toString();
225
+ expect(query).toBe('title:Task*'); // Preserves the wildcard in the query
226
+ });
227
+
228
+ it('should support wildcard syntax in field-operator-value API', () => {
229
+ const query = builder
230
+ .where('title', 'LIKE' as ComparisonOperator, 'Task*')
231
+ .toString();
232
+ expect(query).toBe('title:Task*'); // Should use the colon format for LIKE
233
+ });
234
+
235
+ it('should support combined wildcards', () => {
236
+ const query = builder.where('title:*Important*').toString();
237
+ expect(query).toBe('title:*Important*');
238
+ });
239
+
240
+ it('should support ? wildcards', () => {
241
+ const query = builder.where('code:ABC?').toString();
242
+ expect(query).toBe('code:ABC?');
243
+ });
244
+ });
245
+
246
+ describe('custom operator handling', () => {
247
+ it('should handle NOT IN operator properly', () => {
248
+ const query = builder
249
+ .where('status', 'NOT IN' as ComparisonOperator, [
250
+ 'inactive',
251
+ 'deleted'
252
+ ])
253
+ .toString();
254
+
255
+ expect(query).toBe('status:not in["inactive","deleted"]');
256
+ });
257
+ });
258
+
259
+ describe('constructor options', () => {
260
+ it('should initialize with custom options', () => {
261
+ const options = {
262
+ caseInsensitiveFields: true,
263
+ fieldMappings: { title: 'task_title' }
264
+ };
265
+
266
+ const customBuilder = new QueryBuilder<ITodo>(options);
267
+ const query = customBuilder.where('title', '==', 'Bug fix').toString();
268
+
269
+ expect(query).toBe('title:"Bug fix"');
270
+ });
271
+ });
272
+ });