@dxos/echo-query 0.8.4-main.9be5663bfe → 0.8.4-main.abd8ff62ef
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/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 +246 -226
- package/dist/query-lite/index.d.ts.map +1 -1
- package/dist/query-lite/index.js +16 -0
- 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 +13 -16
- package/src/parser/query-builder.ts +269 -9
- package/src/parser/query.test.ts +115 -2
- package/src/query-lite/query-lite.ts +18 -0
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.abd8ff62ef",
|
|
4
4
|
"description": "ECHO queries.",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -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/
|
|
40
|
-
"@dxos/echo": "0.8.4-main.
|
|
41
|
-
"@dxos/echo
|
|
42
|
-
"@dxos/
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/util": "0.8.4-main.
|
|
46
|
-
"@dxos/vendor-quickjs": "0.8.4-main.
|
|
35
|
+
"@dxos/debug": "0.8.4-main.abd8ff62ef",
|
|
36
|
+
"@dxos/errors": "0.8.4-main.abd8ff62ef",
|
|
37
|
+
"@dxos/echo-protocol": "0.8.4-main.abd8ff62ef",
|
|
38
|
+
"@dxos/echo": "0.8.4-main.abd8ff62ef",
|
|
39
|
+
"@dxos/invariant": "0.8.4-main.abd8ff62ef",
|
|
40
|
+
"@dxos/node-std": "0.8.4-main.abd8ff62ef",
|
|
41
|
+
"@dxos/context": "0.8.4-main.abd8ff62ef",
|
|
42
|
+
"@dxos/util": "0.8.4-main.abd8ff62ef",
|
|
43
|
+
"@dxos/vendor-quickjs": "0.8.4-main.abd8ff62ef"
|
|
47
44
|
},
|
|
48
45
|
"devDependencies": {
|
|
49
46
|
"@lezer/generator": "^1.7.1",
|
|
50
47
|
"tsdown": "^0.16.7",
|
|
51
|
-
"typescript": "^6.0.
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
48
|
+
"typescript": "^6.0.3",
|
|
49
|
+
"@dxos/echo-generator": "0.8.4-main.abd8ff62ef",
|
|
50
|
+
"@dxos/random": "0.8.4-main.abd8ff62ef"
|
|
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
|
});
|
|
@@ -573,6 +573,21 @@ class QueryClass implements Query$.Any {
|
|
|
573
573
|
options,
|
|
574
574
|
});
|
|
575
575
|
}
|
|
576
|
+
|
|
577
|
+
debugLabel(label: string): Query$.Any {
|
|
578
|
+
if (this.ast.type === 'options') {
|
|
579
|
+
return new QueryClass({
|
|
580
|
+
type: 'options',
|
|
581
|
+
query: this.ast.query,
|
|
582
|
+
options: { ...this.ast.options, debugLabel: label },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return new QueryClass({
|
|
586
|
+
type: 'options',
|
|
587
|
+
query: this.ast,
|
|
588
|
+
options: { debugLabel: label },
|
|
589
|
+
});
|
|
590
|
+
}
|
|
576
591
|
}
|
|
577
592
|
|
|
578
593
|
export const Query1: typeof Query$ = QueryClass;
|
|
@@ -701,6 +716,9 @@ const prettyQuery = (query: QueryAST.Query): string => {
|
|
|
701
716
|
if (query.options.deleted !== undefined) {
|
|
702
717
|
parts.push(`deleted: ${JSON.stringify(query.options.deleted)}`);
|
|
703
718
|
}
|
|
719
|
+
if (query.options.debugLabel !== undefined) {
|
|
720
|
+
parts.push(`debugLabel: ${JSON.stringify(query.options.debugLabel)}`);
|
|
721
|
+
}
|
|
704
722
|
return `${prettyQuery(query.query)}.options({ ${parts.join(', ')} })`;
|
|
705
723
|
}
|
|
706
724
|
case 'from': {
|