@mastra/lance 0.1.1-alpha.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.
@@ -0,0 +1,423 @@
1
+ import type { VectorFilter } from '@mastra/core/vector/filter';
2
+ import { BaseFilterTranslator } from '@mastra/core/vector/filter';
3
+
4
+ export class LanceFilterTranslator extends BaseFilterTranslator {
5
+ translate(filter: VectorFilter): string {
6
+ if (!filter || Object.keys(filter).length === 0) {
7
+ return '';
8
+ }
9
+
10
+ // Check for fields with periods that aren't nested at top level
11
+ if (typeof filter === 'object' && filter !== null) {
12
+ const keys = Object.keys(filter);
13
+ for (const key of keys) {
14
+ if (key.includes('.') && !this.isNormalNestedField(key)) {
15
+ throw new Error(`Field names containing periods (.) are not supported: ${key}`);
16
+ }
17
+ }
18
+ }
19
+
20
+ return this.processFilter(filter);
21
+ }
22
+
23
+ private processFilter(filter: unknown, parentPath = ''): string {
24
+ // Handle null case
25
+ if (filter === null) {
26
+ return `${parentPath} IS NULL`;
27
+ }
28
+
29
+ // Handle Date objects at top level
30
+ if (filter instanceof Date) {
31
+ return `${parentPath} = ${this.formatValue(filter)}`;
32
+ }
33
+
34
+ // Handle top-level operators
35
+ if (typeof filter === 'object' && filter !== null) {
36
+ const obj = filter as Record<string, unknown>;
37
+ const keys = Object.keys(obj);
38
+
39
+ // Handle logical operators at top level
40
+ if (keys.length === 1 && this.isOperator(keys[0]!)) {
41
+ const operator = keys[0]!;
42
+ const operatorValue = obj[operator];
43
+
44
+ if (this.isLogicalOperator(operator)) {
45
+ if (operator === '$and' || operator === '$or') {
46
+ return this.processLogicalOperator(operator, operatorValue as unknown[]);
47
+ }
48
+ throw new Error(BaseFilterTranslator.ErrorMessages.UNSUPPORTED_OPERATOR(operator));
49
+ }
50
+
51
+ throw new Error(BaseFilterTranslator.ErrorMessages.INVALID_TOP_LEVEL_OPERATOR(operator));
52
+ }
53
+
54
+ // Check for fields with periods that aren't nested
55
+ for (const key of keys) {
56
+ if (key.includes('.') && !this.isNormalNestedField(key)) {
57
+ throw new Error(`Field names containing periods (.) are not supported: ${key}`);
58
+ }
59
+ }
60
+
61
+ // Handle multiple fields (implicit AND)
62
+ if (keys.length > 1) {
63
+ const conditions = keys.map(key => {
64
+ const value = obj[key];
65
+ // Check if key is a nested path or a field
66
+ if (this.isNestedObject(value) && !this.isDateObject(value)) {
67
+ return this.processNestedObject(key, value);
68
+ } else {
69
+ return this.processField(key, value);
70
+ }
71
+ });
72
+ return conditions.join(' AND ');
73
+ }
74
+
75
+ // Handle single field
76
+ if (keys.length === 1) {
77
+ const key = keys[0]!;
78
+ const value = obj[key]!;
79
+
80
+ if (this.isNestedObject(value) && !this.isDateObject(value)) {
81
+ return this.processNestedObject(key!, value);
82
+ } else {
83
+ return this.processField(key!, value);
84
+ }
85
+ }
86
+ }
87
+
88
+ return '';
89
+ }
90
+
91
+ private processLogicalOperator(operator: string, conditions: unknown[]): string {
92
+ if (!Array.isArray(conditions)) {
93
+ throw new Error(`Logical operator ${operator} must have an array value`);
94
+ }
95
+
96
+ if (conditions.length === 0) {
97
+ return operator === '$and' ? 'true' : 'false';
98
+ }
99
+
100
+ const sqlOperator = operator === '$and' ? 'AND' : 'OR';
101
+
102
+ const processedConditions = conditions.map(condition => {
103
+ if (typeof condition !== 'object' || condition === null) {
104
+ throw new Error(BaseFilterTranslator.ErrorMessages.INVALID_LOGICAL_OPERATOR_CONTENT(operator));
105
+ }
106
+
107
+ // Check if condition is a nested logical operator
108
+ const condObj = condition as Record<string, unknown>;
109
+ const keys = Object.keys(condObj);
110
+
111
+ if (keys.length === 1 && this.isOperator(keys[0]!)) {
112
+ if (this.isLogicalOperator(keys[0])) {
113
+ return `(${this.processLogicalOperator(keys[0], condObj[keys[0]] as unknown[])})`;
114
+ } else {
115
+ throw new Error(BaseFilterTranslator.ErrorMessages.UNSUPPORTED_OPERATOR(keys[0]));
116
+ }
117
+ }
118
+
119
+ // Handle multiple fields within a logical condition (implicit AND)
120
+ if (keys.length > 1) {
121
+ return `(${this.processFilter(condition)})`;
122
+ }
123
+
124
+ return this.processFilter(condition);
125
+ });
126
+
127
+ return processedConditions.join(` ${sqlOperator} `);
128
+ }
129
+
130
+ private processNestedObject(path: string, value: unknown): string {
131
+ if (typeof value !== 'object' || value === null) {
132
+ throw new Error(`Expected object for nested path ${path}`);
133
+ }
134
+
135
+ const obj = value as Record<string, unknown>;
136
+ const keys = Object.keys(obj);
137
+
138
+ // Handle empty object
139
+ if (keys.length === 0) {
140
+ return `${path} = {}`;
141
+ }
142
+
143
+ // Handle operators on a field
144
+ if (keys.every(k => this.isOperator(k))) {
145
+ return this.processOperators(path, obj);
146
+ }
147
+
148
+ // Process each nested field and join with AND
149
+ const conditions = keys.map(key => {
150
+ const nestedPath = key.includes('.')
151
+ ? `${path}.${key}` // Key already contains dots (pre-dotted path)
152
+ : `${path}.${key}`; // Normal nested field
153
+
154
+ if (this.isNestedObject(obj[key]) && !this.isDateObject(obj[key])) {
155
+ return this.processNestedObject(nestedPath, obj[key]);
156
+ } else {
157
+ return this.processField(nestedPath, obj[key]);
158
+ }
159
+ });
160
+
161
+ return conditions.join(' AND ');
162
+ }
163
+
164
+ private processField(field: string, value: unknown): string {
165
+ // Check for illegal field names
166
+ if (field.includes('.') && !this.isNormalNestedField(field)) {
167
+ throw new Error(`Field names containing periods (.) are not supported: ${field}`);
168
+ }
169
+
170
+ // Escape field name if needed
171
+ const escapedField = this.escapeFieldName(field);
172
+
173
+ // Handle null value
174
+ if (value === null) {
175
+ return `${escapedField} IS NULL`;
176
+ }
177
+
178
+ // Handle Date objects properly
179
+ if (value instanceof Date) {
180
+ return `${escapedField} = ${this.formatValue(value)}`;
181
+ }
182
+
183
+ // Handle arrays (convert to IN)
184
+ if (Array.isArray(value)) {
185
+ if (value.length === 0) {
186
+ return 'false'; // Empty array is usually false in SQL
187
+ }
188
+ const normalizedValues = this.normalizeArrayValues(value);
189
+ return `${escapedField} IN (${this.formatArrayValues(normalizedValues)})`;
190
+ }
191
+
192
+ // Handle operator objects
193
+ if (this.isOperatorObject(value)) {
194
+ return this.processOperators(field, value as Record<string, unknown>);
195
+ }
196
+
197
+ // Handle basic values (normalize dates and other special values)
198
+ return `${escapedField} = ${this.formatValue(this.normalizeComparisonValue(value))}`;
199
+ }
200
+
201
+ private processOperators(field: string, operators: Record<string, unknown>): string {
202
+ const escapedField = this.escapeFieldName(field);
203
+ const operatorKeys = Object.keys(operators);
204
+
205
+ // Check for logical operators at field level
206
+ if (operatorKeys.some(op => this.isLogicalOperator(op))) {
207
+ const logicalOp = operatorKeys.find(op => this.isLogicalOperator(op)) || '';
208
+ throw new Error(`Unsupported operator: ${logicalOp} cannot be used at field level`);
209
+ }
210
+
211
+ // Process each operator and join with AND
212
+ return operatorKeys
213
+ .map(op => {
214
+ const value = operators[op];
215
+
216
+ // Check if this is a supported operator
217
+ if (!this.isFieldOperator(op) && !this.isCustomOperator(op)) {
218
+ throw new Error(BaseFilterTranslator.ErrorMessages.UNSUPPORTED_OPERATOR(op));
219
+ }
220
+
221
+ switch (op) {
222
+ case '$eq':
223
+ if (value === null) {
224
+ return `${escapedField} IS NULL`;
225
+ }
226
+ return `${escapedField} = ${this.formatValue(this.normalizeComparisonValue(value))}`;
227
+ case '$ne':
228
+ if (value === null) {
229
+ return `${escapedField} IS NOT NULL`;
230
+ }
231
+ return `${escapedField} != ${this.formatValue(this.normalizeComparisonValue(value))}`;
232
+ case '$gt':
233
+ return `${escapedField} > ${this.formatValue(this.normalizeComparisonValue(value))}`;
234
+ case '$gte':
235
+ return `${escapedField} >= ${this.formatValue(this.normalizeComparisonValue(value))}`;
236
+ case '$lt':
237
+ return `${escapedField} < ${this.formatValue(this.normalizeComparisonValue(value))}`;
238
+ case '$lte':
239
+ return `${escapedField} <= ${this.formatValue(this.normalizeComparisonValue(value))}`;
240
+ case '$in':
241
+ if (!Array.isArray(value)) {
242
+ throw new Error(`$in operator requires array value for field: ${field}`);
243
+ }
244
+ if (value.length === 0) {
245
+ return 'false'; // Empty IN is false
246
+ }
247
+ const normalizedValues = this.normalizeArrayValues(value);
248
+ return `${escapedField} IN (${this.formatArrayValues(normalizedValues)})`;
249
+ case '$like':
250
+ return `${escapedField} LIKE ${this.formatValue(value)}`;
251
+ case '$notLike':
252
+ return `${escapedField} NOT LIKE ${this.formatValue(value)}`;
253
+ case '$regex':
254
+ return `regexp_match(${escapedField}, ${this.formatValue(value)})`;
255
+ default:
256
+ throw new Error(BaseFilterTranslator.ErrorMessages.UNSUPPORTED_OPERATOR(op));
257
+ }
258
+ })
259
+ .join(' AND ');
260
+ }
261
+
262
+ private formatValue(value: unknown): string {
263
+ if (value === null) {
264
+ return 'NULL';
265
+ }
266
+
267
+ if (typeof value === 'string') {
268
+ // Escape single quotes in SQL strings by doubling them
269
+ return `'${value.replace(/'/g, "''")}'`;
270
+ }
271
+
272
+ if (typeof value === 'number') {
273
+ return value.toString();
274
+ }
275
+
276
+ if (typeof value === 'boolean') {
277
+ return value ? 'true' : 'false';
278
+ }
279
+
280
+ if (value instanceof Date) {
281
+ return `timestamp '${value.toISOString()}'`;
282
+ }
283
+
284
+ if (typeof value === 'object') {
285
+ if (value instanceof Date) {
286
+ return `timestamp '${value.toISOString()}'`;
287
+ }
288
+ return JSON.stringify(value);
289
+ }
290
+
291
+ return String(value);
292
+ }
293
+
294
+ private formatArrayValues(array: unknown[]): string {
295
+ return array.map(item => this.formatValue(item)).join(', ');
296
+ }
297
+
298
+ normalizeArrayValues(array: unknown[]): unknown[] {
299
+ return array.map(item => {
300
+ if (item instanceof Date) {
301
+ return item; // Keep Date objects as is to properly format them later
302
+ }
303
+ return this.normalizeComparisonValue(item);
304
+ });
305
+ }
306
+
307
+ normalizeComparisonValue(value: unknown): unknown {
308
+ // Date objects should be preserved as is, not converted to strings
309
+ if (value instanceof Date) {
310
+ return value;
311
+ }
312
+
313
+ return super.normalizeComparisonValue(value);
314
+ }
315
+
316
+ private isOperatorObject(value: unknown): boolean {
317
+ if (typeof value !== 'object' || value === null) {
318
+ return false;
319
+ }
320
+
321
+ const obj = value as Record<string, unknown>;
322
+ const keys = Object.keys(obj);
323
+
324
+ return keys.length > 0 && keys.some(key => this.isOperator(key));
325
+ }
326
+
327
+ private isNestedObject(value: unknown): boolean {
328
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
329
+ }
330
+
331
+ private isNormalNestedField(field: string): boolean {
332
+ // Check if field is a proper nested field name
333
+ const parts = field.split('.');
334
+ // A valid nested field shouldn't have empty parts or start/end with a dot
335
+ return !field.startsWith('.') && !field.endsWith('.') && parts.every(part => part.trim().length > 0);
336
+ }
337
+
338
+ private escapeFieldName(field: string): string {
339
+ // If field contains special characters or is a SQL keyword, escape with backticks
340
+ if (field.includes(' ') || field.includes('-') || /^[A-Z]+$/.test(field) || this.isSqlKeyword(field)) {
341
+ // For nested fields, escape each part
342
+ if (field.includes('.')) {
343
+ return field
344
+ .split('.')
345
+ .map(part => `\`${part}\``)
346
+ .join('.');
347
+ }
348
+ return `\`${field}\``;
349
+ }
350
+
351
+ return field;
352
+ }
353
+
354
+ private isSqlKeyword(str: string): boolean {
355
+ // Common SQL keywords that might need escaping
356
+ const sqlKeywords = [
357
+ 'SELECT',
358
+ 'FROM',
359
+ 'WHERE',
360
+ 'AND',
361
+ 'OR',
362
+ 'NOT',
363
+ 'INSERT',
364
+ 'UPDATE',
365
+ 'DELETE',
366
+ 'CREATE',
367
+ 'ALTER',
368
+ 'DROP',
369
+ 'TABLE',
370
+ 'VIEW',
371
+ 'INDEX',
372
+ 'JOIN',
373
+ 'INNER',
374
+ 'OUTER',
375
+ 'LEFT',
376
+ 'RIGHT',
377
+ 'FULL',
378
+ 'UNION',
379
+ 'ALL',
380
+ 'DISTINCT',
381
+ 'AS',
382
+ 'ON',
383
+ 'BETWEEN',
384
+ 'LIKE',
385
+ 'IN',
386
+ 'IS',
387
+ 'NULL',
388
+ 'TRUE',
389
+ 'FALSE',
390
+ 'ASC',
391
+ 'DESC',
392
+ 'GROUP',
393
+ 'ORDER',
394
+ 'BY',
395
+ 'HAVING',
396
+ 'LIMIT',
397
+ 'OFFSET',
398
+ 'CASE',
399
+ 'WHEN',
400
+ 'THEN',
401
+ 'ELSE',
402
+ 'END',
403
+ 'CAST',
404
+ 'CUBE',
405
+ ];
406
+
407
+ return sqlKeywords.includes(str.toUpperCase());
408
+ }
409
+
410
+ private isDateObject(value: unknown): boolean {
411
+ return value instanceof Date;
412
+ }
413
+
414
+ /**
415
+ * Override getSupportedOperators to add custom operators for LanceDB
416
+ */
417
+ protected override getSupportedOperators() {
418
+ return {
419
+ ...BaseFilterTranslator.DEFAULT_OPERATORS,
420
+ custom: ['$like', '$notLike', '$regex'],
421
+ };
422
+ }
423
+ }