@mastra/opensearch 0.11.5-alpha.0 → 0.11.8-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.
@@ -1,479 +0,0 @@
1
- import type {
2
- BlacklistedRootOperators,
3
- LogicalOperatorValueMap,
4
- OperatorSupport,
5
- OperatorValueMap,
6
- QueryOperator,
7
- VectorFilter,
8
- } from '@mastra/core/vector/filter';
9
- import { BaseFilterTranslator } from '@mastra/core/vector/filter';
10
-
11
- type OpenSearchOperatorValueMap = Omit<OperatorValueMap, '$options' | '$nor' | '$elemMatch'>;
12
-
13
- type OpenSearchLogicalOperatorValueMap = Omit<LogicalOperatorValueMap, '$nor'>;
14
-
15
- type OpenSearchBlacklisted = BlacklistedRootOperators | '$nor';
16
-
17
- export type OpenSearchVectorFilter = VectorFilter<
18
- keyof OpenSearchOperatorValueMap,
19
- OpenSearchOperatorValueMap,
20
- OpenSearchLogicalOperatorValueMap,
21
- OpenSearchBlacklisted
22
- >;
23
- /**
24
- * Translator for OpenSearch filter queries.
25
- * Maintains OpenSearch-compatible syntax while ensuring proper validation
26
- * and normalization of values.
27
- */
28
- export class OpenSearchFilterTranslator extends BaseFilterTranslator<OpenSearchVectorFilter> {
29
- protected override getSupportedOperators(): OperatorSupport {
30
- return {
31
- ...BaseFilterTranslator.DEFAULT_OPERATORS,
32
- logical: ['$and', '$or', '$not'],
33
- array: ['$in', '$nin', '$all'],
34
- regex: ['$regex'],
35
- custom: [],
36
- };
37
- }
38
-
39
- translate(filter?: OpenSearchVectorFilter): OpenSearchVectorFilter {
40
- if (this.isEmpty(filter)) return undefined;
41
- this.validateFilter(filter);
42
- return this.translateNode(filter);
43
- }
44
-
45
- private translateNode(node: OpenSearchVectorFilter): any {
46
- // Handle primitive values and arrays
47
- if (this.isPrimitive(node) || Array.isArray(node)) {
48
- return node;
49
- }
50
-
51
- const entries = Object.entries(node as Record<string, any>);
52
-
53
- // Extract logical operators and field conditions
54
- const logicalOperators: [string, any][] = [];
55
- const fieldConditions: [string, any][] = [];
56
-
57
- entries.forEach(([key, value]) => {
58
- if (this.isLogicalOperator(key)) {
59
- logicalOperators.push([key, value]);
60
- } else {
61
- fieldConditions.push([key, value]);
62
- }
63
- });
64
-
65
- // If we have a single logical operator
66
- if (logicalOperators.length === 1 && fieldConditions.length === 0) {
67
- const [operator, value] = logicalOperators[0] as [QueryOperator, any];
68
- if (!Array.isArray(value) && typeof value !== 'object') {
69
- throw new Error(`Invalid logical operator structure: ${operator} must have an array or object value`);
70
- }
71
- return this.translateLogicalOperator(operator, value);
72
- }
73
-
74
- // Process field conditions
75
- const fieldConditionQueries = fieldConditions.map(([key, value]) => {
76
- // Handle nested objects
77
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
78
- // Check if the object contains operators
79
- const hasOperators = Object.keys(value).some(k => this.isOperator(k));
80
-
81
- // Use a more direct approach based on whether operators are present
82
- const nestedField = `metadata.${key}`;
83
- return hasOperators
84
- ? this.translateFieldConditions(nestedField, value)
85
- : this.translateNestedObject(nestedField, value);
86
- }
87
-
88
- // Handle arrays
89
- if (Array.isArray(value)) {
90
- const fieldWithKeyword = this.addKeywordIfNeeded(`metadata.${key}`, value);
91
- return { terms: { [fieldWithKeyword]: value } };
92
- }
93
-
94
- // Handle simple field equality
95
- const fieldWithKeyword = this.addKeywordIfNeeded(`metadata.${key}`, value);
96
- return { term: { [fieldWithKeyword]: value } };
97
- });
98
-
99
- // Handle case with both logical operators and field conditions or multiple logical operators
100
- if (logicalOperators.length > 0) {
101
- const logicalConditions = logicalOperators.map(([operator, value]) =>
102
- this.translateOperator(operator as QueryOperator, value),
103
- );
104
-
105
- return {
106
- bool: {
107
- must: [...logicalConditions, ...fieldConditionQueries],
108
- },
109
- };
110
- }
111
-
112
- // If we only have field conditions
113
- if (fieldConditionQueries.length > 1) {
114
- return {
115
- bool: {
116
- must: fieldConditionQueries,
117
- },
118
- };
119
- }
120
-
121
- // If we have only one field condition
122
- if (fieldConditionQueries.length === 1) {
123
- return fieldConditionQueries[0];
124
- }
125
-
126
- // If we have no conditions (e.g., only empty $and arrays)
127
- return { match_all: {} };
128
- }
129
-
130
- /**
131
- * Handles translation of nested objects with dot notation fields
132
- */
133
- private translateNestedObject(field: string, value: Record<string, any>): any {
134
- const conditions = Object.entries(value).map(([subField, subValue]) => {
135
- const fullField = `${field}.${subField}`;
136
-
137
- // Check if this is an operator in a nested field
138
- if (this.isOperator(subField)) {
139
- return this.translateOperator(subField as QueryOperator, subValue, field);
140
- }
141
-
142
- if (typeof subValue === 'object' && subValue !== null && !Array.isArray(subValue)) {
143
- // Check if the nested object contains operators
144
- const hasOperators = Object.keys(subValue).some(k => this.isOperator(k));
145
- if (hasOperators) {
146
- return this.translateFieldConditions(fullField, subValue);
147
- }
148
- return this.translateNestedObject(fullField, subValue);
149
- }
150
- const fieldWithKeyword = this.addKeywordIfNeeded(fullField, subValue);
151
- return { term: { [fieldWithKeyword]: subValue } };
152
- });
153
-
154
- return {
155
- bool: {
156
- must: conditions,
157
- },
158
- };
159
- }
160
-
161
- private translateLogicalOperator(operator: QueryOperator, value: any): any {
162
- const conditions = Array.isArray(value) ? value.map(item => this.translateNode(item)) : [this.translateNode(value)];
163
- switch (operator) {
164
- case '$and':
165
- // For empty $and, return a query that matches everything
166
- if (Array.isArray(value) && value.length === 0) {
167
- return { match_all: {} };
168
- }
169
- return {
170
- bool: {
171
- must: conditions,
172
- },
173
- };
174
- case '$or':
175
- // For empty $or, return a query that matches nothing
176
- if (Array.isArray(value) && value.length === 0) {
177
- return {
178
- bool: {
179
- must_not: [{ match_all: {} }],
180
- },
181
- };
182
- }
183
- return {
184
- bool: {
185
- should: conditions,
186
- },
187
- };
188
- case '$not':
189
- return {
190
- bool: {
191
- must_not: conditions,
192
- },
193
- };
194
- default:
195
- return value;
196
- }
197
- }
198
-
199
- private translateFieldOperator(field: string, operator: QueryOperator, value: any): any {
200
- // Handle basic comparison operators
201
- if (this.isBasicOperator(operator)) {
202
- const normalizedValue = this.normalizeComparisonValue(value);
203
- const fieldWithKeyword = this.addKeywordIfNeeded(field, value);
204
- switch (operator) {
205
- case '$eq':
206
- return { term: { [fieldWithKeyword]: normalizedValue } };
207
- case '$ne':
208
- return {
209
- bool: {
210
- must_not: [{ term: { [fieldWithKeyword]: normalizedValue } }],
211
- },
212
- };
213
- default:
214
- return { term: { [fieldWithKeyword]: normalizedValue } };
215
- }
216
- }
217
-
218
- // Handle numeric operators
219
- if (this.isNumericOperator(operator)) {
220
- const normalizedValue = this.normalizeComparisonValue(value);
221
- const rangeOp = operator.replace('$', '');
222
- return { range: { [field]: { [rangeOp]: normalizedValue } } };
223
- }
224
-
225
- // Handle array operators
226
- if (this.isArrayOperator(operator)) {
227
- if (!Array.isArray(value)) {
228
- throw new Error(`Invalid array operator value: ${operator} requires an array value`);
229
- }
230
- const normalizedValues = this.normalizeArrayValues(value);
231
- const fieldWithKeyword = this.addKeywordIfNeeded(field, value);
232
- switch (operator) {
233
- case '$in':
234
- return { terms: { [fieldWithKeyword]: normalizedValues } };
235
- case '$nin':
236
- // For empty arrays, return a query that matches everything
237
- if (normalizedValues.length === 0) {
238
- return { match_all: {} };
239
- }
240
- return {
241
- bool: {
242
- must_not: [{ terms: { [fieldWithKeyword]: normalizedValues } }],
243
- },
244
- };
245
- case '$all':
246
- // For empty arrays, return a query that will match nothing
247
- if (normalizedValues.length === 0) {
248
- return {
249
- bool: {
250
- must_not: [{ match_all: {} }],
251
- },
252
- };
253
- }
254
- return {
255
- bool: {
256
- must: normalizedValues.map(v => ({ term: { [fieldWithKeyword]: v } })),
257
- },
258
- };
259
- default:
260
- return { terms: { [fieldWithKeyword]: normalizedValues } };
261
- }
262
- }
263
-
264
- // Handle element operators
265
- if (this.isElementOperator(operator)) {
266
- switch (operator) {
267
- case '$exists':
268
- return value ? { exists: { field } } : { bool: { must_not: [{ exists: { field } }] } };
269
- default:
270
- return { exists: { field } };
271
- }
272
- }
273
-
274
- // Handle regex operators
275
- if (this.isRegexOperator(operator)) {
276
- return this.translateRegexOperator(field, value);
277
- }
278
-
279
- const fieldWithKeyword = this.addKeywordIfNeeded(field, value);
280
- return { term: { [fieldWithKeyword]: value } };
281
- }
282
-
283
- /**
284
- * Translates regex patterns to OpenSearch query syntax
285
- */
286
- private translateRegexOperator(field: string, value: any): any {
287
- // Convert value to string if it's not already
288
- const regexValue = typeof value === 'string' ? value : value.toString();
289
-
290
- // Check for problematic patterns (like newlines, etc.)
291
- if (regexValue.includes('\n') || regexValue.includes('\r')) {
292
- // For patterns with newlines, use a simpler approach
293
- // OpenSearch doesn't support dotall flag like JavaScript
294
- return { match: { [field]: value } };
295
- }
296
-
297
- // Process regex pattern to handle anchors properly
298
- let processedRegex = regexValue;
299
- const hasStartAnchor = regexValue.startsWith('^');
300
- const hasEndAnchor = regexValue.endsWith('$');
301
-
302
- // If we have anchors, use wildcard query for better handling
303
- if (hasStartAnchor || hasEndAnchor) {
304
- // Remove anchors
305
- if (hasStartAnchor) {
306
- processedRegex = processedRegex.substring(1);
307
- }
308
- if (hasEndAnchor) {
309
- processedRegex = processedRegex.substring(0, processedRegex.length - 1);
310
- }
311
-
312
- // Create wildcard pattern
313
- let wildcardPattern = processedRegex;
314
- if (!hasStartAnchor) {
315
- wildcardPattern = '*' + wildcardPattern;
316
- }
317
- if (!hasEndAnchor) {
318
- wildcardPattern = wildcardPattern + '*';
319
- }
320
-
321
- return { wildcard: { [field]: wildcardPattern } };
322
- }
323
-
324
- // Use regexp for other regex patterns
325
- // Escape any backslashes to prevent OpenSearch from misinterpreting them
326
- const escapedRegex = regexValue.replace(/\\/g, '\\\\');
327
- return { regexp: { [field]: escapedRegex } };
328
- }
329
-
330
- private addKeywordIfNeeded(field: string, value: any): string {
331
- // Add .keyword suffix for string fields
332
- if (typeof value === 'string') {
333
- return `${field}.keyword`;
334
- }
335
- // Add .keyword suffix for string array fields
336
- if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
337
- return `${field}.keyword`;
338
- }
339
- return field;
340
- }
341
-
342
- /**
343
- * Helper method to handle special cases for the $not operator
344
- */
345
- private handleNotOperatorSpecialCases(value: any, field: string): any | null {
346
- // For "not null", we need to use exists query
347
- if (value === null) {
348
- return { exists: { field } };
349
- }
350
-
351
- if (typeof value === 'object' && value !== null) {
352
- // For "not {$eq: null}", we need to use exists query
353
- if ('$eq' in value && value.$eq === null) {
354
- return { exists: { field } };
355
- }
356
-
357
- // For "not {$ne: null}", we need to use must_not exists query
358
- if ('$ne' in value && value.$ne === null) {
359
- return {
360
- bool: {
361
- must_not: [{ exists: { field } }],
362
- },
363
- };
364
- }
365
- }
366
-
367
- return null; // No special case applies
368
- }
369
-
370
- private translateOperator(operator: QueryOperator, value: any, field?: string): any {
371
- // Check if this is a valid operator
372
- if (!this.isOperator(operator)) {
373
- throw new Error(`Unsupported operator: ${operator}`);
374
- }
375
-
376
- // Special case for $not with null or $eq: null
377
- if (operator === '$not' && field) {
378
- const specialCaseResult = this.handleNotOperatorSpecialCases(value, field);
379
- if (specialCaseResult) {
380
- return specialCaseResult;
381
- }
382
- }
383
-
384
- // Handle logical operators
385
- if (this.isLogicalOperator(operator)) {
386
- // For $not operator with field context and nested operators, handle specially
387
- if (operator === '$not' && field && typeof value === 'object' && value !== null && !Array.isArray(value)) {
388
- const entries = Object.entries(value);
389
-
390
- // Handle multiple operators in $not
391
- if (entries.length > 0) {
392
- // If all entries are operators, handle them as a single condition
393
- if (entries.every(([op]) => this.isOperator(op))) {
394
- const translatedCondition = this.translateFieldConditions(field, value);
395
- return {
396
- bool: {
397
- must_not: [translatedCondition],
398
- },
399
- };
400
- }
401
-
402
- // Handle single nested operator
403
- if (entries.length === 1 && entries[0] && this.isOperator(entries[0][0])) {
404
- const [nestedOp, nestedVal] = entries[0] as [QueryOperator, any];
405
- const translatedNested = this.translateFieldOperator(field, nestedOp, nestedVal);
406
- return {
407
- bool: {
408
- must_not: [translatedNested],
409
- },
410
- };
411
- }
412
- }
413
- }
414
- return this.translateLogicalOperator(operator, value);
415
- }
416
-
417
- // If a field is provided, use translateFieldOperator for more specific translation
418
- if (field) {
419
- return this.translateFieldOperator(field, operator, value);
420
- }
421
-
422
- // For non-logical operators without a field context, just return the value
423
- // The actual translation happens in translateFieldConditions where we have the field context
424
- return value;
425
- }
426
-
427
- /**
428
- * Translates field conditions to OpenSearch query syntax
429
- * Handles special cases like range queries and multiple operators
430
- */
431
- private translateFieldConditions(field: string, conditions: Record<string, any>): any {
432
- // Special case: Optimize multiple numeric operators into a single range query
433
- if (this.canOptimizeToRangeQuery(conditions)) {
434
- return this.createRangeQuery(field, conditions);
435
- }
436
-
437
- // Handle all other operators consistently
438
- const queryConditions: any[] = [];
439
- Object.entries(conditions).forEach(([operator, value]) => {
440
- if (this.isOperator(operator)) {
441
- queryConditions.push(this.translateOperator(operator as QueryOperator, value, field));
442
- } else {
443
- // Handle non-operator keys (should not happen in normal usage)
444
- const fieldWithKeyword = this.addKeywordIfNeeded(`${field}.${operator}`, value);
445
- queryConditions.push({ term: { [fieldWithKeyword]: value } });
446
- }
447
- });
448
-
449
- // Return single condition without wrapping
450
- if (queryConditions.length === 1) {
451
- return queryConditions[0];
452
- }
453
-
454
- // Combine multiple conditions with AND logic
455
- return {
456
- bool: {
457
- must: queryConditions,
458
- },
459
- };
460
- }
461
-
462
- /**
463
- * Checks if conditions can be optimized to a range query
464
- */
465
- private canOptimizeToRangeQuery(conditions: Record<string, any>): boolean {
466
- return Object.keys(conditions).every(op => this.isNumericOperator(op)) && Object.keys(conditions).length > 0;
467
- }
468
-
469
- /**
470
- * Creates a range query from numeric operators
471
- */
472
- private createRangeQuery(field: string, conditions: Record<string, any>): any {
473
- const rangeParams = Object.fromEntries(
474
- Object.entries(conditions).map(([op, val]) => [op.replace('$', ''), this.normalizeComparisonValue(val)]),
475
- );
476
-
477
- return { range: { [field]: rangeParams } };
478
- }
479
- }