@dxos/echo-query 0.8.4-main.74a063c4e0 → 0.8.4-main.765dc60934
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/LICENSE +102 -5
- package/README.md +1 -1
- package/dist/lib/neutral/index.mjs +215 -17
- package/dist/lib/neutral/index.mjs.map +3 -3
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/query-lite/index.d.ts +299 -226
- package/dist/query-lite/index.d.ts.map +1 -1
- package/dist/query-lite/index.js +50 -11
- package/dist/query-lite/index.js.map +1 -1
- package/dist/types/src/parser/gen/index.d.ts.map +1 -1
- package/dist/types/src/parser/query-builder.d.ts +7 -0
- package/dist/types/src/parser/query-builder.d.ts.map +1 -1
- package/dist/types/src/query-lite/query-lite.d.ts.map +1 -1
- package/dist/types/src/sandbox/query-sandbox.d.ts.map +1 -1
- package/dist/types/src/sandbox/quickjs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +14 -17
- package/src/parser/query-builder.ts +269 -9
- package/src/parser/query.test.ts +115 -2
- package/src/query-lite/query-lite.ts +67 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/echo-query",
|
|
3
|
-
"version": "0.8.4-main.
|
|
3
|
+
"version": "0.8.4-main.765dc60934",
|
|
4
4
|
"description": "ECHO queries.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/dxos/dxos"
|
|
10
10
|
},
|
|
11
|
-
"license": "
|
|
11
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
12
12
|
"author": "info@dxos.org",
|
|
13
13
|
"sideEffects": false,
|
|
14
14
|
"type": "module",
|
|
@@ -24,9 +24,6 @@
|
|
|
24
24
|
"./api.d.ts": "./dist/query-lite/index.d.ts"
|
|
25
25
|
},
|
|
26
26
|
"types": "dist/types/src/index.d.ts",
|
|
27
|
-
"typesVersions": {
|
|
28
|
-
"*": {}
|
|
29
|
-
},
|
|
30
27
|
"files": [
|
|
31
28
|
"dist",
|
|
32
29
|
"src"
|
|
@@ -35,22 +32,22 @@
|
|
|
35
32
|
"@lezer/common": "^1.2.2",
|
|
36
33
|
"@lezer/lezer": "^1.1.2",
|
|
37
34
|
"@lezer/lr": "^1.4.2",
|
|
38
|
-
"@dxos/
|
|
39
|
-
"@dxos/echo
|
|
40
|
-
"@dxos/
|
|
41
|
-
"@dxos/
|
|
42
|
-
"@dxos/invariant": "0.8.4-main.
|
|
43
|
-
"@dxos/node-std": "0.8.4-main.
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
35
|
+
"@dxos/debug": "0.8.4-main.765dc60934",
|
|
36
|
+
"@dxos/echo": "0.8.4-main.765dc60934",
|
|
37
|
+
"@dxos/context": "0.8.4-main.765dc60934",
|
|
38
|
+
"@dxos/echo-protocol": "0.8.4-main.765dc60934",
|
|
39
|
+
"@dxos/invariant": "0.8.4-main.765dc60934",
|
|
40
|
+
"@dxos/node-std": "0.8.4-main.765dc60934",
|
|
41
|
+
"@dxos/errors": "0.8.4-main.765dc60934",
|
|
42
|
+
"@dxos/util": "0.8.4-main.765dc60934",
|
|
43
|
+
"@dxos/vendor-quickjs": "0.8.4-main.765dc60934"
|
|
47
44
|
},
|
|
48
45
|
"devDependencies": {
|
|
49
46
|
"@lezer/generator": "^1.7.1",
|
|
50
47
|
"tsdown": "^0.16.7",
|
|
51
|
-
"typescript": "^
|
|
52
|
-
"@dxos/echo-generator": "0.8.4-main.
|
|
53
|
-
"@dxos/random": "0.8.4-main.
|
|
48
|
+
"typescript": "^6.0.3",
|
|
49
|
+
"@dxos/echo-generator": "0.8.4-main.765dc60934",
|
|
50
|
+
"@dxos/random": "0.8.4-main.765dc60934"
|
|
54
51
|
},
|
|
55
52
|
"publishConfig": {
|
|
56
53
|
"access": "public"
|
|
@@ -28,7 +28,7 @@ export class QueryBuilder {
|
|
|
28
28
|
*/
|
|
29
29
|
validate(input: string): boolean {
|
|
30
30
|
try {
|
|
31
|
-
const tree = this._parser.parse(input);
|
|
31
|
+
const tree = this._parser.parse(normalizeInput(input));
|
|
32
32
|
return tree.cursor().node.name === 'Query';
|
|
33
33
|
} catch {
|
|
34
34
|
return false;
|
|
@@ -40,8 +40,9 @@ export class QueryBuilder {
|
|
|
40
40
|
*/
|
|
41
41
|
build(input: string): BuildResult {
|
|
42
42
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
43
|
+
const normalized = normalizeInput(input);
|
|
44
|
+
const tree = this._parser.parse(normalized);
|
|
45
|
+
return this.buildQuery(tree, normalized);
|
|
45
46
|
} catch {
|
|
46
47
|
return {};
|
|
47
48
|
}
|
|
@@ -245,8 +246,9 @@ export class QueryBuilder {
|
|
|
245
246
|
let exprEnd = cursor.to;
|
|
246
247
|
|
|
247
248
|
while (cursor.nextSibling() && depth > 0) {
|
|
248
|
-
if (cursor.node.name === '(')
|
|
249
|
-
|
|
249
|
+
if (cursor.node.name === '(') {
|
|
250
|
+
depth++;
|
|
251
|
+
} else if (cursor.node.name === ')') {
|
|
250
252
|
depth--;
|
|
251
253
|
if (depth === 0) {
|
|
252
254
|
exprEnd = cursor.from;
|
|
@@ -355,8 +357,8 @@ export class QueryBuilder {
|
|
|
355
357
|
cursor.firstChild(); // Move to String node.
|
|
356
358
|
const text = this._getNodeText(cursor, input);
|
|
357
359
|
cursor.parent(); // Go back to TextFilter.
|
|
358
|
-
// Remove quotes.
|
|
359
|
-
return Filter.text(text.slice(1, -1));
|
|
360
|
+
// Remove quotes and decode escapes.
|
|
361
|
+
return Filter.text(unescapeStringLiteral(text.slice(1, -1)));
|
|
360
362
|
}
|
|
361
363
|
|
|
362
364
|
/**
|
|
@@ -479,9 +481,9 @@ export class QueryBuilder {
|
|
|
479
481
|
|
|
480
482
|
switch (valueType) {
|
|
481
483
|
case 'String': {
|
|
482
|
-
// Remove quotes.
|
|
484
|
+
// Remove quotes and decode escapes.
|
|
483
485
|
const str = this._getNodeText(cursor, input);
|
|
484
|
-
return str.slice(1, -1);
|
|
486
|
+
return unescapeStringLiteral(str.slice(1, -1));
|
|
485
487
|
}
|
|
486
488
|
|
|
487
489
|
case 'Number':
|
|
@@ -537,3 +539,261 @@ export class QueryBuilder {
|
|
|
537
539
|
return input.slice(cursor.from, cursor.to);
|
|
538
540
|
}
|
|
539
541
|
}
|
|
542
|
+
|
|
543
|
+
const KEYWORDS = new Set(['AND', 'OR', 'NOT']);
|
|
544
|
+
const VALUE_LITERALS = new Set(['true', 'false', 'null']);
|
|
545
|
+
const SPECIAL_CHARS = /[\s(){}\[\],"']/;
|
|
546
|
+
const PROPERTY_KEY = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
|
|
547
|
+
const NUMBER_LITERAL = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
548
|
+
|
|
549
|
+
// Common URL schemes — `http://...`, `mailto:rich@...` etc. should be searched as text,
|
|
550
|
+
// not auto-promoted to property filters.
|
|
551
|
+
const URL_SCHEMES = new Set([
|
|
552
|
+
'http',
|
|
553
|
+
'https',
|
|
554
|
+
'ftp',
|
|
555
|
+
'ftps',
|
|
556
|
+
'file',
|
|
557
|
+
'mailto',
|
|
558
|
+
'tel',
|
|
559
|
+
'sms',
|
|
560
|
+
'data',
|
|
561
|
+
'javascript',
|
|
562
|
+
'ws',
|
|
563
|
+
'wss',
|
|
564
|
+
'ssh',
|
|
565
|
+
'git',
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Normalize raw user input into a form the lezer grammar can parse.
|
|
570
|
+
* - Bare text fragments (e.g. `foo`) are wrapped in quotes so they parse as TextFilter.
|
|
571
|
+
* - Property values that aren't already quoted/numeric/boolean (e.g. `from:rich@dxos.org`) are quoted.
|
|
572
|
+
* - Tags, type filters, operators, parens, braces, quoted strings, and assignments pass through unchanged.
|
|
573
|
+
*/
|
|
574
|
+
export const normalizeInput = (input: string): string => {
|
|
575
|
+
const out: string[] = [];
|
|
576
|
+
let pos = 0;
|
|
577
|
+
while (pos < input.length) {
|
|
578
|
+
const currentChar = input[pos];
|
|
579
|
+
|
|
580
|
+
// Whitespace.
|
|
581
|
+
if (/\s/.test(currentChar)) {
|
|
582
|
+
out.push(currentChar);
|
|
583
|
+
pos++;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Quoted string (already a valid TextFilter / Value).
|
|
588
|
+
// Single-quote only opens a string if the previous char isn't a word-char,
|
|
589
|
+
// so apostrophes inside barewords (e.g. `don't`, `O'Connor`) stay attached.
|
|
590
|
+
const prevChar = pos > 0 ? input[pos - 1] : '';
|
|
591
|
+
const isStringOpener =
|
|
592
|
+
currentChar === '"' || (currentChar === "'" && (pos === 0 || !/[a-zA-Z0-9_]/.test(prevChar)));
|
|
593
|
+
if (isStringOpener) {
|
|
594
|
+
const quoteChar = currentChar;
|
|
595
|
+
let scanIndex = pos + 1;
|
|
596
|
+
while (scanIndex < input.length && input[scanIndex] !== quoteChar) {
|
|
597
|
+
if (input[scanIndex] === '\\' && scanIndex + 1 < input.length) {
|
|
598
|
+
scanIndex += 2;
|
|
599
|
+
} else {
|
|
600
|
+
scanIndex++;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
out.push(input.slice(pos, Math.min(scanIndex + 1, input.length)));
|
|
604
|
+
pos = scanIndex + 1;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Object/array literals: pass through (contents already structured).
|
|
609
|
+
if (currentChar === '{' || currentChar === '[') {
|
|
610
|
+
const closeChar = currentChar === '{' ? '}' : ']';
|
|
611
|
+
let depth = 1;
|
|
612
|
+
let scanIndex = pos + 1;
|
|
613
|
+
while (scanIndex < input.length && depth > 0) {
|
|
614
|
+
const innerChar = input[scanIndex];
|
|
615
|
+
if (innerChar === '"' || innerChar === "'") {
|
|
616
|
+
const quoteChar = innerChar;
|
|
617
|
+
scanIndex++;
|
|
618
|
+
while (scanIndex < input.length && input[scanIndex] !== quoteChar) {
|
|
619
|
+
if (input[scanIndex] === '\\' && scanIndex + 1 < input.length) {
|
|
620
|
+
scanIndex += 2;
|
|
621
|
+
} else {
|
|
622
|
+
scanIndex++;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
scanIndex++;
|
|
626
|
+
} else if (innerChar === currentChar) {
|
|
627
|
+
depth++;
|
|
628
|
+
scanIndex++;
|
|
629
|
+
} else if (innerChar === closeChar) {
|
|
630
|
+
depth--;
|
|
631
|
+
scanIndex++;
|
|
632
|
+
} else {
|
|
633
|
+
scanIndex++;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
out.push(input.slice(pos, scanIndex));
|
|
637
|
+
pos = scanIndex;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Single-char structural tokens. `}` / `]` only reach here when unmatched —
|
|
642
|
+
// pass them through so the lezer parser can produce a clear error rather than spinning.
|
|
643
|
+
if (
|
|
644
|
+
currentChar === '(' ||
|
|
645
|
+
currentChar === ')' ||
|
|
646
|
+
currentChar === '=' ||
|
|
647
|
+
currentChar === ',' ||
|
|
648
|
+
currentChar === '}' ||
|
|
649
|
+
currentChar === ']'
|
|
650
|
+
) {
|
|
651
|
+
out.push(currentChar);
|
|
652
|
+
pos++;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Relations.
|
|
657
|
+
if ((currentChar === '-' && input[pos + 1] === '>') || (currentChar === '<' && input[pos + 1] === '-')) {
|
|
658
|
+
out.push(input.slice(pos, pos + 2));
|
|
659
|
+
pos += 2;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// NOT prefix (`!`) — single token.
|
|
664
|
+
if (currentChar === '!') {
|
|
665
|
+
out.push(currentChar);
|
|
666
|
+
pos++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Tag.
|
|
671
|
+
if (currentChar === '#') {
|
|
672
|
+
let scanIndex = pos + 1;
|
|
673
|
+
while (scanIndex < input.length && /[a-zA-Z0-9_-]/.test(input[scanIndex])) {
|
|
674
|
+
scanIndex++;
|
|
675
|
+
}
|
|
676
|
+
out.push(input.slice(pos, scanIndex));
|
|
677
|
+
pos = scanIndex;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Bareword: scan until next whitespace or special char.
|
|
682
|
+
let scanIndex = pos;
|
|
683
|
+
while (scanIndex < input.length) {
|
|
684
|
+
const innerChar = input[scanIndex];
|
|
685
|
+
if (innerChar === '"') {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
if (innerChar === "'" && scanIndex > pos && !/[a-zA-Z0-9_]/.test(input[scanIndex - 1])) {
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
if (innerChar === "'" && scanIndex === pos) {
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
if (innerChar !== "'" && SPECIAL_CHARS.test(innerChar)) {
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
if (innerChar === '-' && input[scanIndex + 1] === '>') {
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
if (innerChar === '<' && input[scanIndex + 1] === '-') {
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
scanIndex++;
|
|
704
|
+
}
|
|
705
|
+
// Defensive: if no characters were consumed, advance one to avoid infinite loops.
|
|
706
|
+
if (scanIndex === pos) {
|
|
707
|
+
out.push(currentChar);
|
|
708
|
+
pos++;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const token = input.slice(pos, scanIndex);
|
|
712
|
+
pos = scanIndex;
|
|
713
|
+
|
|
714
|
+
// Operators.
|
|
715
|
+
if (KEYWORDS.has(token.toUpperCase())) {
|
|
716
|
+
out.push(token);
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Property/type filter (`key:value`).
|
|
721
|
+
const colonIdx = token.indexOf(':');
|
|
722
|
+
if (colonIdx > 0) {
|
|
723
|
+
const key = token.slice(0, colonIdx);
|
|
724
|
+
const rest = token.slice(colonIdx + 1);
|
|
725
|
+
|
|
726
|
+
// type:typename — leave for grammar's TypeFilter (Identifier value).
|
|
727
|
+
if (key === 'type') {
|
|
728
|
+
out.push(token);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// URLs (`http://...`, `mailto:rich@...`) and URL-paths starting with `//`
|
|
733
|
+
// are searched as text rather than auto-promoted to property filters.
|
|
734
|
+
const isUrlScheme = URL_SCHEMES.has(key.toLowerCase()) || rest.startsWith('//');
|
|
735
|
+
if (!isUrlScheme && PROPERTY_KEY.test(key)) {
|
|
736
|
+
if (rest.length === 0) {
|
|
737
|
+
// Trailing colon while typing — pass through.
|
|
738
|
+
out.push(token);
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const firstValueChar = rest[0];
|
|
742
|
+
if (firstValueChar === '"' || firstValueChar === "'") {
|
|
743
|
+
// Already quoted.
|
|
744
|
+
out.push(token);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (firstValueChar === '{' || firstValueChar === '[') {
|
|
748
|
+
// Object/array literal value.
|
|
749
|
+
out.push(token);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
if (VALUE_LITERALS.has(rest) || NUMBER_LITERAL.test(rest)) {
|
|
753
|
+
// Boolean / null / number literal.
|
|
754
|
+
out.push(token);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
out.push(`${key}:"${escapeStringLiteral(rest)}"`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// URL or unknown key shape — fall through to text.
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Identifier followed by `=` is the LHS of an Assignment — keep as-is.
|
|
765
|
+
if (PROPERTY_KEY.test(token)) {
|
|
766
|
+
let lookahead = pos;
|
|
767
|
+
while (lookahead < input.length && /\s/.test(input[lookahead])) {
|
|
768
|
+
lookahead++;
|
|
769
|
+
}
|
|
770
|
+
if (input[lookahead] === '=') {
|
|
771
|
+
out.push(token);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Bare text fragment: quote so it parses as TextFilter.
|
|
777
|
+
out.push(`"${escapeStringLiteral(token)}"`);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return out.join('');
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/** Escape a raw value into a string literal body (without surrounding quotes). */
|
|
784
|
+
const escapeStringLiteral = (value: string): string => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
785
|
+
|
|
786
|
+
/** Decode `\\` and `\"` escapes inside a string literal body. */
|
|
787
|
+
const unescapeStringLiteral = (literalBody: string): string => {
|
|
788
|
+
let out = '';
|
|
789
|
+
for (let i = 0; i < literalBody.length; i++) {
|
|
790
|
+
const ch = literalBody[i];
|
|
791
|
+
if (ch === '\\' && i + 1 < literalBody.length) {
|
|
792
|
+
out += literalBody[i + 1];
|
|
793
|
+
i++;
|
|
794
|
+
} else {
|
|
795
|
+
out += ch;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return out;
|
|
799
|
+
};
|
package/src/parser/query.test.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { type Tree } from '@lezer/common';
|
|
6
|
-
import { describe, it } from 'vitest';
|
|
6
|
+
import { describe, it, test } from 'vitest';
|
|
7
7
|
|
|
8
8
|
import { Filter, Tag } from '@dxos/echo';
|
|
9
9
|
|
|
10
10
|
import { QueryDSL } from './gen';
|
|
11
|
-
import { type BuildResult, QueryBuilder } from './query-builder';
|
|
11
|
+
import { type BuildResult, QueryBuilder, normalizeInput } from './query-builder';
|
|
12
12
|
|
|
13
13
|
// TODO(burdon): Ref/Relation traversal.
|
|
14
14
|
|
|
@@ -413,4 +413,117 @@ describe('query', () => {
|
|
|
413
413
|
expect(result, JSON.stringify({ input, result, expected }, null, 2)).toEqual(expected);
|
|
414
414
|
});
|
|
415
415
|
});
|
|
416
|
+
|
|
417
|
+
test('normalizeInput', ({ expect }) => {
|
|
418
|
+
type Test = { input: string; expected: string };
|
|
419
|
+
const tests: Test[] = [
|
|
420
|
+
{ input: 'foo', expected: '"foo"' },
|
|
421
|
+
{ input: 'foo bar', expected: '"foo" "bar"' },
|
|
422
|
+
{ input: 'foo bar', expected: '"foo" "bar"' },
|
|
423
|
+
{ input: '"already" bare', expected: '"already" "bare"' },
|
|
424
|
+
{ input: 'from:rich@dxos.org', expected: 'from:"rich@dxos.org"' },
|
|
425
|
+
{ input: 'from:rich@dxos.org urgent', expected: 'from:"rich@dxos.org" "urgent"' },
|
|
426
|
+
{ input: 'name:DXOS', expected: 'name:"DXOS"' },
|
|
427
|
+
{ input: 'count:42', expected: 'count:42' },
|
|
428
|
+
{ input: 'active:true', expected: 'active:true' },
|
|
429
|
+
{ input: 'value:null', expected: 'value:null' },
|
|
430
|
+
{ input: 'name:"DXOS"', expected: 'name:"DXOS"' },
|
|
431
|
+
{ input: 'type:org.dxos.type.person', expected: 'type:org.dxos.type.person' },
|
|
432
|
+
{ input: '#tag', expected: '#tag' },
|
|
433
|
+
{ input: '#tag foo', expected: '#tag "foo"' },
|
|
434
|
+
{ input: 'foo AND bar', expected: '"foo" AND "bar"' },
|
|
435
|
+
{ input: 'foo OR bar', expected: '"foo" OR "bar"' },
|
|
436
|
+
{ input: 'NOT foo', expected: 'NOT "foo"' },
|
|
437
|
+
{ input: '!foo', expected: '!"foo"' },
|
|
438
|
+
{ input: '(foo bar)', expected: '("foo" "bar")' },
|
|
439
|
+
{ input: 'x = ( foo )', expected: 'x = ( "foo" )' },
|
|
440
|
+
{ input: '{ name: "DXOS" }', expected: '{ name: "DXOS" }' },
|
|
441
|
+
// Apostrophes inside barewords don't open a quoted string.
|
|
442
|
+
{ input: "don't", expected: '"don\'t"' },
|
|
443
|
+
{ input: "O'Connor", expected: '"O\'Connor"' },
|
|
444
|
+
{ input: "don't worry", expected: '"don\'t" "worry"' },
|
|
445
|
+
// Unmatched closing brace/bracket — passed through, no infinite loop.
|
|
446
|
+
{ input: 'foo}', expected: '"foo"}' },
|
|
447
|
+
{ input: 'foo]bar', expected: '"foo"]"bar"' },
|
|
448
|
+
// Genuine single-quoted string still works.
|
|
449
|
+
{ input: "'foo bar'", expected: "'foo bar'" },
|
|
450
|
+
// URLs are searched as text rather than auto-promoted to property filters.
|
|
451
|
+
{ input: 'https://dxos.org', expected: '"https://dxos.org"' },
|
|
452
|
+
{ input: 'http://example.com/foo', expected: '"http://example.com/foo"' },
|
|
453
|
+
{ input: 'mailto:rich@dxos.org', expected: '"mailto:rich@dxos.org"' },
|
|
454
|
+
// Escapes round-trip: backslashes are escaped in the literal body.
|
|
455
|
+
{ input: 'foo\\bar', expected: '"foo\\\\bar"' },
|
|
456
|
+
];
|
|
457
|
+
|
|
458
|
+
for (const { input, expected } of tests) {
|
|
459
|
+
expect(normalizeInput(input), input).toEqual(expected);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('build with property and text fragments', ({ expect }) => {
|
|
464
|
+
const queryBuilder = new QueryBuilder({
|
|
465
|
+
tag_1: Tag.make({ label: 'foo' }),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
type Test = { input: string; expected: BuildResult };
|
|
469
|
+
const tests: Test[] = [
|
|
470
|
+
// Property filter from `key:value` text input.
|
|
471
|
+
{
|
|
472
|
+
input: 'from:rich@dxos.org',
|
|
473
|
+
expected: { filter: Filter.props({ from: 'rich@dxos.org' }) },
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
input: 'name:DXOS',
|
|
477
|
+
expected: { filter: Filter.props({ name: 'DXOS' }) },
|
|
478
|
+
},
|
|
479
|
+
// Bare text fragment becomes text search.
|
|
480
|
+
{
|
|
481
|
+
input: 'urgent',
|
|
482
|
+
expected: { filter: Filter.text('urgent') },
|
|
483
|
+
},
|
|
484
|
+
// Multiple bare fragments are AND-joined.
|
|
485
|
+
{
|
|
486
|
+
input: 'urgent review',
|
|
487
|
+
expected: { filter: Filter.and(Filter.text('urgent'), Filter.text('review')) },
|
|
488
|
+
},
|
|
489
|
+
// Mixed property + text fragment.
|
|
490
|
+
{
|
|
491
|
+
input: 'from:rich@dxos.org urgent',
|
|
492
|
+
expected: {
|
|
493
|
+
filter: Filter.and(Filter.props({ from: 'rich@dxos.org' }), Filter.text('urgent')),
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
// Tag + text fragment.
|
|
497
|
+
{
|
|
498
|
+
input: '#foo bar',
|
|
499
|
+
expected: { filter: Filter.and(Filter.tag('tag_1'), Filter.text('bar')) },
|
|
500
|
+
},
|
|
501
|
+
// Three fragments AND-joined.
|
|
502
|
+
{
|
|
503
|
+
input: 'a b c',
|
|
504
|
+
expected: {
|
|
505
|
+
filter: Filter.and(Filter.text('a'), Filter.text('b'), Filter.text('c')),
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
// URLs are text-searched, not promoted to property filters.
|
|
509
|
+
{
|
|
510
|
+
input: 'https://dxos.org',
|
|
511
|
+
expected: { filter: Filter.text('https://dxos.org') },
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
input: 'mailto:rich@dxos.org',
|
|
515
|
+
expected: { filter: Filter.text('mailto:rich@dxos.org') },
|
|
516
|
+
},
|
|
517
|
+
// Escapes decode back to the original input.
|
|
518
|
+
{
|
|
519
|
+
input: 'foo\\bar',
|
|
520
|
+
expected: { filter: Filter.text('foo\\bar') },
|
|
521
|
+
},
|
|
522
|
+
];
|
|
523
|
+
|
|
524
|
+
tests.forEach(({ input, expected }) => {
|
|
525
|
+
const result = queryBuilder.build(input);
|
|
526
|
+
expect(result, JSON.stringify({ input, result, expected }, null, 2)).toEqual(expected);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
416
529
|
});
|
|
@@ -111,7 +111,7 @@ class FilterClass implements Filter$.Any {
|
|
|
111
111
|
}
|
|
112
112
|
return new FilterClass({
|
|
113
113
|
type: 'object',
|
|
114
|
-
typename:
|
|
114
|
+
typename: makeTypeDXN(schema),
|
|
115
115
|
...propsFilterToAst(props ?? {}),
|
|
116
116
|
});
|
|
117
117
|
}
|
|
@@ -119,7 +119,7 @@ class FilterClass implements Filter$.Any {
|
|
|
119
119
|
static typename(typename: string): Filter$.Any {
|
|
120
120
|
return new FilterClass({
|
|
121
121
|
type: 'object',
|
|
122
|
-
typename:
|
|
122
|
+
typename: makeTypeDXN(typename),
|
|
123
123
|
props: {},
|
|
124
124
|
});
|
|
125
125
|
}
|
|
@@ -139,6 +139,16 @@ class FilterClass implements Filter$.Any {
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
static key(key: string, options?: Filter$.KeyFilterOptions): Filter$.Any {
|
|
143
|
+
return new FilterClass({
|
|
144
|
+
type: 'object',
|
|
145
|
+
typename: null,
|
|
146
|
+
props: {},
|
|
147
|
+
metaKey: key,
|
|
148
|
+
metaVersion: options?.version,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
static props<T>(props: Filter$.Props<T>): Filter$.Filter<T> {
|
|
143
153
|
return new FilterClass({
|
|
144
154
|
type: 'object',
|
|
@@ -244,14 +254,29 @@ class FilterClass implements Filter$.Any {
|
|
|
244
254
|
}
|
|
245
255
|
|
|
246
256
|
static updated(range: { after?: Date | number; before?: Date | number }): Filter$.Any {
|
|
247
|
-
return FilterClass
|
|
257
|
+
return FilterClass._timeRangeFilter('updatedAt', range);
|
|
248
258
|
}
|
|
249
259
|
|
|
250
260
|
static created(range: { after?: Date | number; before?: Date | number }): Filter$.Any {
|
|
251
|
-
return FilterClass
|
|
261
|
+
return FilterClass._timeRangeFilter('createdAt', range);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
static childOf(parents: unknown | DXN | (unknown | DXN)[], options?: { transitive?: boolean }): Filter$.Any {
|
|
265
|
+
const items = Array.isArray(parents) ? parents : [parents];
|
|
266
|
+
const dxns = items.map((item) => {
|
|
267
|
+
if (isDxnLike(item)) {
|
|
268
|
+
return item.toString();
|
|
269
|
+
}
|
|
270
|
+
throw new TypeError('childOf requires DXN values in query-lite');
|
|
271
|
+
});
|
|
272
|
+
return new FilterClass({
|
|
273
|
+
type: 'child-of',
|
|
274
|
+
parents: dxns,
|
|
275
|
+
transitive: options?.transitive ?? true,
|
|
276
|
+
});
|
|
252
277
|
}
|
|
253
278
|
|
|
254
|
-
static
|
|
279
|
+
private static _timeRangeFilter(
|
|
255
280
|
field: 'updatedAt' | 'createdAt',
|
|
256
281
|
range: { after?: Date | number; before?: Date | number },
|
|
257
282
|
): Filter$.Any {
|
|
@@ -436,7 +461,7 @@ class QueryClass implements Query$.Any {
|
|
|
436
461
|
from: {
|
|
437
462
|
_tag: 'scope',
|
|
438
463
|
scope: {
|
|
439
|
-
...(options?.includeFeeds ? {
|
|
464
|
+
...(options?.includeFeeds ? { allFeedsFromSpaces: true } : {}),
|
|
440
465
|
},
|
|
441
466
|
},
|
|
442
467
|
});
|
|
@@ -558,6 +583,21 @@ class QueryClass implements Query$.Any {
|
|
|
558
583
|
options,
|
|
559
584
|
});
|
|
560
585
|
}
|
|
586
|
+
|
|
587
|
+
debugLabel(label: string): Query$.Any {
|
|
588
|
+
if (this.ast.type === 'options') {
|
|
589
|
+
return new QueryClass({
|
|
590
|
+
type: 'options',
|
|
591
|
+
query: this.ast.query,
|
|
592
|
+
options: { ...this.ast.options, debugLabel: label },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return new QueryClass({
|
|
596
|
+
type: 'options',
|
|
597
|
+
query: this.ast,
|
|
598
|
+
options: { debugLabel: label },
|
|
599
|
+
});
|
|
600
|
+
}
|
|
561
601
|
}
|
|
562
602
|
|
|
563
603
|
export const Query1: typeof Query$ = QueryClass;
|
|
@@ -568,13 +608,23 @@ const isRef = (obj: any): obj is Ref.Ref<any> => {
|
|
|
568
608
|
return obj && typeof obj === 'object' && RefTypeId in obj;
|
|
569
609
|
};
|
|
570
610
|
|
|
571
|
-
const
|
|
611
|
+
const makeTypeDXN = (typename: string) => {
|
|
572
612
|
assertArgument(typeof typename === 'string', 'typename');
|
|
573
613
|
assertArgument(!typename.startsWith('dxn:'), 'typename');
|
|
574
614
|
return `dxn:type:${typename}`;
|
|
575
615
|
};
|
|
576
616
|
|
|
577
|
-
const
|
|
617
|
+
const isDxnLike = (value: unknown): value is DXN => {
|
|
618
|
+
return (
|
|
619
|
+
typeof value === 'object' &&
|
|
620
|
+
value !== null &&
|
|
621
|
+
'toString' in value &&
|
|
622
|
+
typeof value.toString === 'function' &&
|
|
623
|
+
value.toString().startsWith('dxn:')
|
|
624
|
+
);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const SCOPE_KEYS = new Set(['spaceIds', 'feeds', 'allFeedsFromSpaces']);
|
|
578
628
|
|
|
579
629
|
const _isScopeLike = (value: unknown): value is QueryAST.Scope => {
|
|
580
630
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -612,6 +662,8 @@ const prettyFilter = (filter: QueryAST.Filter): string => {
|
|
|
612
662
|
return `Filter.text(${JSON.stringify(filter.text)})`;
|
|
613
663
|
case 'tag':
|
|
614
664
|
return `Filter.tag(${JSON.stringify(filter.tag)})`;
|
|
665
|
+
case 'child-of':
|
|
666
|
+
return `Filter.childOf([${filter.parents.map((parent) => JSON.stringify(parent)).join(', ')}], { transitive: ${filter.transitive} })`;
|
|
615
667
|
case 'timestamp':
|
|
616
668
|
return `Filter.${filter.field}.${filter.operator}(${filter.value})`;
|
|
617
669
|
case 'not':
|
|
@@ -674,6 +726,9 @@ const prettyQuery = (query: QueryAST.Query): string => {
|
|
|
674
726
|
if (query.options.deleted !== undefined) {
|
|
675
727
|
parts.push(`deleted: ${JSON.stringify(query.options.deleted)}`);
|
|
676
728
|
}
|
|
729
|
+
if (query.options.debugLabel !== undefined) {
|
|
730
|
+
parts.push(`debugLabel: ${JSON.stringify(query.options.debugLabel)}`);
|
|
731
|
+
}
|
|
677
732
|
return `${prettyQuery(query.query)}.options({ ${parts.join(', ')} })`;
|
|
678
733
|
}
|
|
679
734
|
case 'from': {
|
|
@@ -683,11 +738,11 @@ const prettyQuery = (query: QueryAST.Query): string => {
|
|
|
683
738
|
if (scope.spaceIds !== undefined) {
|
|
684
739
|
parts.push(`spaceIds: [${scope.spaceIds.join(', ')}]`);
|
|
685
740
|
}
|
|
686
|
-
if (scope.
|
|
687
|
-
parts.push(`
|
|
741
|
+
if (scope.feeds !== undefined) {
|
|
742
|
+
parts.push(`feeds: [${scope.feeds.join(', ')}]`);
|
|
688
743
|
}
|
|
689
|
-
if (scope.
|
|
690
|
-
parts.push(`
|
|
744
|
+
if (scope.allFeedsFromSpaces !== undefined) {
|
|
745
|
+
parts.push(`allFeedsFromSpaces: ${scope.allFeedsFromSpaces}`);
|
|
691
746
|
}
|
|
692
747
|
return `${prettyQuery(query.query)}.from({ ${parts.join(', ')} })`;
|
|
693
748
|
}
|