@mastra/qdrant 0.1.0-alpha.28

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,289 @@
1
+ import { BaseFilterTranslator, FieldCondition, Filter, LogicalOperator, OperatorSupport } from '@mastra/core/filter';
2
+
3
+ /**
4
+ * Translates MongoDB-style filters to Qdrant compatible filters.
5
+ *
6
+ * Key transformations:
7
+ * - $and -> must
8
+ * - $or -> should
9
+ * - $not -> must_not
10
+ * - { field: { $op: value } } -> { key: field, match/range: { value/gt/lt: value } }
11
+ *
12
+ * Custom operators (Qdrant-specific):
13
+ * - $count -> values_count (array length/value count)
14
+ * - $geo -> geo filters (box, radius, polygon)
15
+ * - $hasId -> has_id filter
16
+ * - $nested -> nested object filters
17
+ * - $hasVector -> vector existence check
18
+ * - $datetime -> RFC 3339 datetime range
19
+ * - $null -> is_null check
20
+ * - $empty -> is_empty check
21
+ */
22
+ export class QdrantFilterTranslator extends BaseFilterTranslator {
23
+ protected override isLogicalOperator(key: string): key is LogicalOperator {
24
+ return super.isLogicalOperator(key) || key === '$hasId' || key === '$hasVector';
25
+ }
26
+
27
+ protected override getSupportedOperators(): OperatorSupport {
28
+ return {
29
+ ...BaseFilterTranslator.DEFAULT_OPERATORS,
30
+ logical: ['$and', '$or', '$not'],
31
+ array: ['$in', '$nin'],
32
+ regex: ['$regex'],
33
+ custom: ['$count', '$geo', '$nested', '$datetime', '$null', '$empty', '$hasId', '$hasVector'],
34
+ };
35
+ }
36
+
37
+ translate(filter?: Filter): Filter | undefined {
38
+ if (this.isEmpty(filter)) return filter;
39
+ this.validateFilter(filter as Filter);
40
+ return this.translateNode(filter);
41
+ }
42
+
43
+ private createCondition(type: string, value: any, fieldKey?: string) {
44
+ const condition = { [type]: value };
45
+ return fieldKey ? { key: fieldKey, ...condition } : condition;
46
+ }
47
+
48
+ private translateNode(node: Filter | FieldCondition, isNested: boolean = false, fieldKey?: string): any {
49
+ if (!this.isEmpty(node) && typeof node === 'object' && 'must' in node) {
50
+ return node;
51
+ }
52
+
53
+ if (this.isPrimitive(node)) {
54
+ if (node === null) {
55
+ return { is_null: { key: fieldKey } };
56
+ }
57
+ return this.createCondition('match', { value: this.normalizeComparisonValue(node) }, fieldKey);
58
+ }
59
+
60
+ if (this.isRegex(node)) {
61
+ throw new Error('Direct regex pattern format is not supported in Qdrant');
62
+ }
63
+
64
+ if (Array.isArray(node)) {
65
+ return node.length === 0
66
+ ? { is_empty: { key: fieldKey } }
67
+ : this.createCondition('match', { any: this.normalizeArrayValues(node) }, fieldKey);
68
+ }
69
+
70
+ const entries = Object.entries(node as Record<string, any>);
71
+
72
+ // Handle logical operators first
73
+ const logicalResult = this.handleLogicalOperators(entries, isNested);
74
+ if (logicalResult) {
75
+ return logicalResult;
76
+ }
77
+
78
+ // Handle field conditions
79
+ const { conditions, range, matchCondition } = this.handleFieldConditions(entries, fieldKey);
80
+
81
+ if (Object.keys(range).length > 0) {
82
+ conditions.push({ key: fieldKey, range });
83
+ }
84
+
85
+ if (matchCondition) {
86
+ conditions.push({ key: fieldKey, match: matchCondition });
87
+ }
88
+
89
+ return this.buildFinalConditions(conditions, isNested);
90
+ }
91
+
92
+ private buildFinalConditions(conditions: any[], isNested: boolean): any {
93
+ if (conditions.length === 0) {
94
+ return {};
95
+ } else if (conditions.length === 1 && isNested) {
96
+ return conditions[0];
97
+ } else {
98
+ return { must: conditions };
99
+ }
100
+ }
101
+
102
+ private handleLogicalOperators(entries: [string, any][], isNested: boolean): any | null {
103
+ const firstKey = entries[0]?.[0];
104
+
105
+ if (firstKey && this.isLogicalOperator(firstKey) && !this.isCustomOperator(firstKey)) {
106
+ const [key, value] = entries[0]!;
107
+ const qdrantOp = this.getQdrantLogicalOp(key);
108
+ return {
109
+ [qdrantOp]: Array.isArray(value)
110
+ ? value.map(v => this.translateNode(v, true))
111
+ : [this.translateNode(value, true)],
112
+ };
113
+ }
114
+
115
+ if (
116
+ entries.length > 1 &&
117
+ !isNested &&
118
+ entries.every(([key]) => !this.isOperator(key) && !this.isCustomOperator(key))
119
+ ) {
120
+ return {
121
+ must: entries.map(([key, value]) => this.translateNode(value, true, key)),
122
+ };
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ private handleFieldConditions(
129
+ entries: [string, any][],
130
+ fieldKey?: string,
131
+ ): { conditions: any[]; range: Record<string, any>; matchCondition: Record<string, any> | null } {
132
+ const conditions = [];
133
+ let range: Record<string, any> = {};
134
+ let matchCondition: Record<string, any> | null = null;
135
+
136
+ for (const [key, value] of entries) {
137
+ if (this.isCustomOperator(key)) {
138
+ const customOp = this.translateCustomOperator(key, value, fieldKey);
139
+ conditions.push(customOp);
140
+ } else if (this.isOperator(key)) {
141
+ const opResult = this.translateOperatorValue(key, value);
142
+ if (opResult.range) {
143
+ Object.assign(range, opResult.range);
144
+ } else {
145
+ matchCondition = opResult;
146
+ }
147
+ } else {
148
+ const nestedKey = fieldKey ? `${fieldKey}.${key}` : key;
149
+ const nestedCondition = this.translateNode(value, true, nestedKey);
150
+
151
+ if (nestedCondition.must) {
152
+ conditions.push(...nestedCondition.must);
153
+ } else if (!this.isEmpty(nestedCondition)) {
154
+ conditions.push(nestedCondition);
155
+ }
156
+ }
157
+ }
158
+
159
+ return { conditions, range, matchCondition };
160
+ }
161
+
162
+ private translateCustomOperator(op: string, value: any, fieldKey?: string): any {
163
+ switch (op) {
164
+ case '$count':
165
+ const countConditions = Object.entries(value).reduce(
166
+ (acc, [k, v]) => ({
167
+ ...acc,
168
+ [k.replace('$', '')]: v,
169
+ }),
170
+ {},
171
+ );
172
+ return { key: fieldKey, values_count: countConditions };
173
+ case '$geo':
174
+ const geoOp = this.translateGeoFilter(value.type, value);
175
+ return { key: fieldKey, ...geoOp };
176
+ case '$hasId':
177
+ return { has_id: Array.isArray(value) ? value : [value] };
178
+ case '$nested':
179
+ return {
180
+ nested: {
181
+ key: fieldKey,
182
+ filter: this.translateNode(value),
183
+ },
184
+ };
185
+ case '$hasVector':
186
+ return { has_vector: value };
187
+ case '$datetime':
188
+ return {
189
+ key: fieldKey,
190
+ range: this.normalizeDatetimeRange(value.range),
191
+ };
192
+ case '$null':
193
+ return { is_null: { key: fieldKey } };
194
+ case '$empty':
195
+ return { is_empty: { key: fieldKey } };
196
+ default:
197
+ throw new Error(`Unsupported custom operator: ${op}`);
198
+ }
199
+ }
200
+
201
+ private getQdrantLogicalOp(op: string): string {
202
+ switch (op) {
203
+ case '$and':
204
+ return 'must';
205
+ case '$or':
206
+ return 'should';
207
+ case '$not':
208
+ return 'must_not';
209
+ default:
210
+ throw new Error(`Unsupported logical operator: ${op}`);
211
+ }
212
+ }
213
+
214
+ private translateOperatorValue(operator: string, value: any): any {
215
+ const normalizedValue = this.normalizeComparisonValue(value);
216
+
217
+ switch (operator) {
218
+ case '$eq':
219
+ return { value: normalizedValue };
220
+ case '$ne':
221
+ return { except: [normalizedValue] };
222
+ case '$gt':
223
+ return { range: { gt: normalizedValue } };
224
+ case '$gte':
225
+ return { range: { gte: normalizedValue } };
226
+ case '$lt':
227
+ return { range: { lt: normalizedValue } };
228
+ case '$lte':
229
+ return { range: { lte: normalizedValue } };
230
+ case '$in':
231
+ return { any: this.normalizeArrayValues(value) };
232
+ case '$nin':
233
+ return { except: this.normalizeArrayValues(value) };
234
+ case '$regex':
235
+ return { text: value };
236
+ case 'exists':
237
+ return value
238
+ ? {
239
+ must_not: [{ is_null: { key: value } }, { is_empty: { key: value } }],
240
+ }
241
+ : {
242
+ is_empty: { key: value },
243
+ };
244
+ default:
245
+ throw new Error(`Unsupported operator: ${operator}`);
246
+ }
247
+ }
248
+
249
+ private translateGeoFilter(type: string, value: any): any {
250
+ switch (type) {
251
+ case 'box':
252
+ return {
253
+ geo_bounding_box: {
254
+ top_left: value.top_left,
255
+ bottom_right: value.bottom_right,
256
+ },
257
+ };
258
+ case 'radius':
259
+ return {
260
+ geo_radius: {
261
+ center: value.center,
262
+ radius: value.radius,
263
+ },
264
+ };
265
+ case 'polygon':
266
+ return {
267
+ geo_polygon: {
268
+ exterior: value.exterior,
269
+ interiors: value.interiors,
270
+ },
271
+ };
272
+ default:
273
+ throw new Error(`Unsupported geo filter type: ${type}`);
274
+ }
275
+ }
276
+
277
+ private normalizeDatetimeRange(value: any): any {
278
+ const range: Record<string, string> = {};
279
+ for (const [op, val] of Object.entries(value)) {
280
+ if (val instanceof Date) {
281
+ range[op] = val.toISOString();
282
+ } else if (typeof val === 'string') {
283
+ // Assume string is already in proper format
284
+ range[op] = val;
285
+ }
286
+ }
287
+ return range;
288
+ }
289
+ }