@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.
- package/CHANGELOG.md +263 -0
- package/LICENSE +44 -0
- package/README.md +82 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +333 -0
- package/package.json +32 -0
- package/src/index.ts +1 -0
- package/src/vector/filter.test.ts +857 -0
- package/src/vector/filter.ts +289 -0
- package/src/vector/index.test.ts +726 -0
- package/src/vector/index.ts +145 -0
- package/tsconfig.json +10 -0
- package/vitest.config.ts +11 -0
|
@@ -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
|
+
}
|