@dxos/echo-query 0.8.4-main.84f28bd → 0.8.4-main.a4bbb77

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,124 @@
1
+ //
2
+ // lezer Query grammar
3
+ //
4
+
5
+ @top Query { expression }
6
+
7
+ expression {
8
+ Filter |
9
+ !Not Not expression |
10
+ expression !ImplicitAnd expression |
11
+ expression !And And expression |
12
+ expression !Or Or expression |
13
+ expression !Relation Relation expression |
14
+ "(" expression ")"
15
+ }
16
+
17
+ Not {
18
+ @specialize<Identifier, "NOT" | "not" | "!">
19
+ }
20
+ And {
21
+ @specialize<Identifier, "AND" | "and">
22
+ }
23
+ Or {
24
+ @specialize<Identifier, "OR" | "or">
25
+ }
26
+
27
+ Relation {
28
+ ArrowRight | ArrowLeft
29
+ }
30
+
31
+ //
32
+ // Filters
33
+ //
34
+
35
+ Filter {
36
+ TagFilter |
37
+ TextFilter |
38
+ TypeFilter |
39
+ PropertyFilter |
40
+ ObjectLiteral
41
+ }
42
+
43
+ TagFilter {
44
+ Tag
45
+ }
46
+
47
+ TextFilter {
48
+ String
49
+ }
50
+
51
+ TypeFilter {
52
+ @specialize[@name=TypeKeyword]<Identifier, "type"> ":" Identifier
53
+ }
54
+
55
+ PropertyFilter {
56
+ PropertyPath ":" Value
57
+ }
58
+
59
+ PropertyPath {
60
+ Identifier ("." Identifier)*
61
+ }
62
+
63
+ ObjectLiteral {
64
+ "{" (ObjectProperty ("," ObjectProperty)*)? "}"
65
+ }
66
+
67
+ ObjectProperty {
68
+ Identifier ":" Value
69
+ }
70
+
71
+ ArrayLiteral {
72
+ "[" (Value ("," Value)*)? "]"
73
+ }
74
+
75
+ Value {
76
+ String |
77
+ Number |
78
+ Boolean |
79
+ Null |
80
+ ObjectLiteral |
81
+ ArrayLiteral
82
+ }
83
+
84
+ @tokens {
85
+ // Supports variables and DXNs
86
+ Identifier {
87
+ $[a-zA-Z_]$[a-zA-Z0-9_./\-]*
88
+ }
89
+
90
+ Tag {
91
+ "#" $[a-zA-Z0-9_\-]+
92
+ }
93
+
94
+ String {
95
+ '"' (!["\\] | "\\" _)* '"' |
96
+ "'" (!['\\] | "\\" _)* "'"
97
+ }
98
+
99
+ Number {
100
+ "-"? @digit+ ("." @digit+)? (("e" | "E") ("+" | "-")? @digit+)?
101
+ }
102
+
103
+ Boolean { "true" | "false" }
104
+
105
+ Null { "null" }
106
+
107
+ ArrowRight { "->" }
108
+ ArrowLeft { "<-" }
109
+
110
+ space { @whitespace+ }
111
+
112
+ "{" "}" "[" "]" "(" ")"
113
+ ":" "," "."
114
+ }
115
+
116
+ @skip { space }
117
+
118
+ @precedence {
119
+ Not @right,
120
+ ImplicitAnd @left,
121
+ And @left,
122
+ Or @left,
123
+ Relation @left
124
+ }
@@ -0,0 +1,360 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Tree } from '@lezer/common';
6
+ import { describe, it } from 'vitest';
7
+
8
+ import { Filter } from '@dxos/echo';
9
+
10
+ import { QueryDSL } from './gen';
11
+ import { QueryBuilder } from './query-builder';
12
+
13
+ // TODO(burdon): Ref/Relation traversal.
14
+
15
+ describe('query', () => {
16
+ it('parse', ({ expect }) => {
17
+ const queryParser = QueryDSL.Parser.configure({ strict: true });
18
+
19
+ type Test = { input: string; expected: string[] };
20
+ const tests: Test[] = [
21
+ // Tags
22
+ {
23
+ input: '#foo',
24
+ expected: ['Query', 'Filter', 'TagFilter', 'Tag'],
25
+ },
26
+ {
27
+ input: '#foo AND #bar',
28
+ expected: ['Query', 'Filter', 'TagFilter', 'Tag', 'And', 'Filter', 'TagFilter', 'Tag'],
29
+ },
30
+ {
31
+ input: '#foo #bar',
32
+ expected: ['Query', 'Filter', 'TagFilter', 'Tag', 'Filter', 'TagFilter', 'Tag'],
33
+ },
34
+ // Text
35
+ {
36
+ input: '"foo"',
37
+ expected: ['Query', 'Filter', 'TextFilter', 'String'],
38
+ },
39
+ {
40
+ input: '"foo" OR "bar"',
41
+ expected: ['Query', 'Filter', 'TextFilter', 'String', 'Or', 'Filter', 'TextFilter', 'String'],
42
+ },
43
+ // Mixed
44
+ {
45
+ input: '#foo AND "bar"',
46
+ expected: ['Query', 'Filter', 'TagFilter', 'Tag', 'And', 'Filter', 'TextFilter', 'String'],
47
+ },
48
+ {
49
+ input: '#foo "bar"',
50
+ expected: ['Query', 'Filter', 'TagFilter', 'Tag', 'Filter', 'TextFilter', 'String'],
51
+ },
52
+ // Type
53
+ {
54
+ input: 'type:dxos.org/type/Person',
55
+ expected: [
56
+ 'Query',
57
+ // type:dxos.org/type/Person
58
+ 'Filter',
59
+ 'TypeFilter',
60
+ 'TypeKeyword',
61
+ ':',
62
+ 'Identifier',
63
+ ],
64
+ },
65
+ {
66
+ input: '{ name: "DXOS" }',
67
+ expected: [
68
+ 'Query',
69
+ // { name: "DXOS" }
70
+ 'Filter',
71
+ 'ObjectLiteral',
72
+ '{',
73
+ 'ObjectProperty',
74
+ 'Identifier',
75
+ ':',
76
+ 'Value',
77
+ 'String',
78
+ '}',
79
+ ],
80
+ },
81
+ {
82
+ input: '{ value: 100 }',
83
+ expected: [
84
+ 'Query',
85
+ // { value: 100 }
86
+ 'Filter',
87
+ 'ObjectLiteral',
88
+ '{',
89
+ 'ObjectProperty',
90
+ 'Identifier',
91
+ ':',
92
+ 'Value',
93
+ 'Number',
94
+ '}',
95
+ ],
96
+ },
97
+ {
98
+ input: '{ value: true }',
99
+ expected: [
100
+ 'Query',
101
+ // { value: true }
102
+ 'Filter',
103
+ 'ObjectLiteral',
104
+ '{',
105
+ 'ObjectProperty',
106
+ 'Identifier',
107
+ ':',
108
+ 'Value',
109
+ 'Boolean',
110
+ '}',
111
+ ],
112
+ },
113
+ {
114
+ input: 'type:dxos.org/type/Person OR type:dxos.org/type/Organization',
115
+ expected: [
116
+ 'Query',
117
+ // type:dxos.org/type/Person
118
+ 'Filter',
119
+ 'TypeFilter',
120
+ 'TypeKeyword',
121
+ ':',
122
+ 'Identifier',
123
+ // OR
124
+ 'Or',
125
+ // type:dxos.org/type/Organization
126
+ 'Filter',
127
+ 'TypeFilter',
128
+ 'TypeKeyword',
129
+ ':',
130
+ 'Identifier',
131
+ ],
132
+ },
133
+ {
134
+ input: '(type:dxos.org/type/Person OR type:dxos.org/type/Organization) AND { name: "DXOS" }',
135
+ expected: [
136
+ 'Query',
137
+ '(',
138
+ // type:dxos.org/type/Person
139
+ 'Filter',
140
+ 'TypeFilter',
141
+ 'TypeKeyword',
142
+ ':',
143
+ 'Identifier',
144
+ // OR
145
+ 'Or',
146
+ // type:dxos.org/type/Organization
147
+ 'Filter',
148
+ 'TypeFilter',
149
+ 'TypeKeyword',
150
+ ':',
151
+ 'Identifier',
152
+ ')',
153
+ 'And',
154
+ // { name: "DXOS" }
155
+ 'Filter',
156
+ 'ObjectLiteral',
157
+ '{',
158
+ 'ObjectProperty',
159
+ 'Identifier',
160
+ ':',
161
+ 'Value',
162
+ 'String',
163
+ '}',
164
+ ],
165
+ },
166
+ {
167
+ input: 'type:dxos.org/type/Person -> type:dxos.org/type/Organization',
168
+ expected: [
169
+ 'Query',
170
+ // type:dxos.org/type/Person
171
+ 'Filter',
172
+ 'TypeFilter',
173
+ 'TypeKeyword',
174
+ ':',
175
+ 'Identifier',
176
+ 'Relation',
177
+ 'ArrowRight',
178
+ // type:dxos.org/type/Organization
179
+ 'Filter',
180
+ 'TypeFilter',
181
+ 'TypeKeyword',
182
+ ':',
183
+ 'Identifier',
184
+ ],
185
+ },
186
+ {
187
+ input: 'type:dxos.org/type/Organization <- type:dxos.org/type/Person',
188
+ expected: [
189
+ 'Query',
190
+ // type:dxos.org/type/Organization
191
+ 'Filter',
192
+ 'TypeFilter',
193
+ 'TypeKeyword',
194
+ ':',
195
+ 'Identifier',
196
+ 'Relation',
197
+ 'ArrowLeft',
198
+ // type:dxos.org/type/Person
199
+ 'Filter',
200
+ 'TypeFilter',
201
+ 'TypeKeyword',
202
+ ':',
203
+ 'Identifier',
204
+ ],
205
+ },
206
+ {
207
+ // Persons for Organizations with name "DXOS"
208
+ // TODO(burdon): Filter relations.
209
+ input: '((type:dxos.org/type/Organization AND { name: "DXOS" }) -> type:dxos.org/type/Person)',
210
+ expected: [
211
+ 'Query',
212
+ '(',
213
+ '(',
214
+ 'Filter',
215
+ 'TypeFilter',
216
+ 'TypeKeyword',
217
+ ':',
218
+ 'Identifier',
219
+ 'And',
220
+ 'Filter',
221
+ 'ObjectLiteral',
222
+ '{',
223
+ 'ObjectProperty',
224
+ 'Identifier',
225
+ ':',
226
+ 'Value',
227
+ 'String',
228
+ '}',
229
+ ')',
230
+ 'Relation',
231
+ 'ArrowRight',
232
+ 'Filter',
233
+ 'TypeFilter',
234
+ 'TypeKeyword',
235
+ ':',
236
+ 'Identifier',
237
+ ')',
238
+ ],
239
+ },
240
+ {
241
+ input: 'type:dxos.org/type/Person and { name: "DXOS" }',
242
+ expected: [
243
+ 'Query',
244
+ 'Filter',
245
+ 'TypeFilter',
246
+ 'TypeKeyword',
247
+ ':',
248
+ 'Identifier',
249
+ 'And',
250
+ 'Filter',
251
+ 'ObjectLiteral',
252
+ '{',
253
+ 'ObjectProperty',
254
+ 'Identifier',
255
+ ':',
256
+ 'Value',
257
+ 'String',
258
+ '}',
259
+ ],
260
+ },
261
+ ];
262
+
263
+ for (const { input, expected } of tests) {
264
+ let tree: Tree;
265
+ try {
266
+ tree = queryParser.parse(input);
267
+ } catch (err) {
268
+ console.error(new Error(`Failed to parse: ${input}`, { cause: err }));
269
+ continue;
270
+ }
271
+
272
+ const cursor = tree.cursor();
273
+ const result: string[] = [];
274
+ do {
275
+ result.push(cursor.node.name);
276
+ } while (cursor.next());
277
+ expect(result, input).toEqual(expected);
278
+ }
279
+ });
280
+
281
+ it('build', ({ expect }) => {
282
+ const queryBuilder = new QueryBuilder();
283
+
284
+ // TODO(burdon): Test "not"
285
+ type Test = { input: string; expected: Filter.Any };
286
+ const tests: Test[] = [
287
+ // Types
288
+ {
289
+ input: 'type:dxos.org/type/Person',
290
+ expected: Filter.typename('dxos.org/type/Person'),
291
+ },
292
+ // Tags
293
+ {
294
+ input: '#foo',
295
+ expected: Filter.tag('foo'),
296
+ },
297
+ {
298
+ input: '#foo AND #bar',
299
+ expected: Filter.and(Filter.tag('foo'), Filter.tag('bar')),
300
+ },
301
+ {
302
+ input: '#foo #bar',
303
+ expected: Filter.and(Filter.tag('foo'), Filter.tag('bar')),
304
+ },
305
+ // Text
306
+ {
307
+ input: '"test"',
308
+ expected: Filter.text('test'),
309
+ },
310
+ // Mixed
311
+ {
312
+ input: '#foo "test"',
313
+ expected: Filter.and(Filter.tag('foo'), Filter.text('test')),
314
+ },
315
+ // Props
316
+ {
317
+ input: '{ name: "DXOS" }',
318
+ expected: Filter.props({ name: 'DXOS' }),
319
+ },
320
+ {
321
+ input: '{ value: 100 }',
322
+ expected: Filter.props({ value: 100 }),
323
+ },
324
+ {
325
+ input: 'type:dxos.org/type/Person OR type:dxos.org/type/Organization',
326
+ expected: Filter.or(Filter.typename('dxos.org/type/Person'), Filter.typename('dxos.org/type/Organization')),
327
+ },
328
+ {
329
+ input: '(type:dxos.org/type/Person OR type:dxos.org/type/Organization) AND { name: "DXOS" }',
330
+ expected: Filter.and(
331
+ Filter.or(Filter.typename('dxos.org/type/Person'), Filter.typename('dxos.org/type/Organization')),
332
+ Filter.props({ name: 'DXOS' }),
333
+ ),
334
+ },
335
+ {
336
+ input: 'type:dxos.org/type/Person and { name: "DXOS" }',
337
+ expected: Filter.and(Filter.typename('dxos.org/type/Person'), Filter.props({ name: 'DXOS' })),
338
+ },
339
+ // TODO(burdon): Convert Query/Filter expr to AST.
340
+ // TODO(burdon): Person -> Organization (many-to-many relation).
341
+ // Get Research Note objects for Organization objects for Person objects with jobTitle.
342
+ //
343
+ // Cypher: MATCH (p:Person)-[:WorksAt]->(o:Organization)<-[:ResearchOn]-(r:ResearchNote) WHERE p.jotTitle IS NOT NULL
344
+ // ((type:Person AND { jobTitle: "investor" }) -[:WorksAt]-> type:Organization) <-[:ResearchOn]- type:ResearchNote
345
+ //
346
+ // {
347
+ // input: '',
348
+ // expected: Query.select(Filter.typename('dxos.org/type/Person', { jobTitle: 'investor' }))
349
+ // .reference('organization')
350
+ // .targetOf(Relation.of('dxos.org/relation/ResearchOn')) // TODO(burdon): Invert?
351
+ // .source(),
352
+ // },
353
+ ];
354
+
355
+ tests.forEach(({ input, expected }) => {
356
+ const result = queryBuilder.build(input);
357
+ expect(result, JSON.stringify({ input, result, expected }, null, 2)).toEqual(expected);
358
+ });
359
+ });
360
+ });