Repository navigation
Expand file tree
/
Copy pathquery-generators.ts
More file actions
1571 lines (1499 loc) · 78.6 KB
/
Copy pathquery-generators.ts
File metadata and controls
1571 lines (1499 loc) · 78.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { cypherForSegment } from "@/lib/db/graph/cypher/generators";
import { quoteCypherName } from "@/lib/db/graph/cypher/quote";
import { declaredLevels } from "@/lib/db/object-kinds";
import { type QueryDialect, registeredDialect } from "@/lib/db/query-dialects";
import { countStatement, previewStatement } from "@/lib/db/providers/document/firestore/console/generators";
import { encodeKey } from "@/lib/db/providers/keyvalue/etcd/keys";
import { oxiaSelectQuery, oxiaTableQuery } from "@/lib/db/providers/keyvalue/oxia/generators";
import { kubectlGetQuery, kubectlRowActionQuery } from "@/lib/db/providers/platform/kubernetes/generators";
import { s3SelectQuery, s3TableQuery } from "@/lib/db/providers/objectstore/s3/console/generators";
import { quoteGoString, quoteTxnWord, quoteWord } from "@/lib/db/providers/keyvalue/etcd/lexer";
import { rabbitmqSelectQuery, rabbitmqTableQuery } from "@/lib/db/providers/stream/rabbitmq/generators";
import { influxqlSelectQuery, influxqlTableQuery } from "@/lib/db/providers/timeseries/influxdb/influxql-generators";
import { influxqlSource, quoteInfluxqlIdentifier } from "@/lib/db/providers/timeseries/influxdb/influxql-quote";
import { metricSelector } from "@/lib/db/providers/timeseries/prometheus/promql";
import { milvusSelectQuery, milvusTableQuery } from "@/lib/db/providers/vector/milvus/generators";
import { qdrantSelectQuery, qdrantTableQuery } from "@/lib/db/providers/vector/qdrant/generators";
import {
type ObjectReadRange,
offersCountQuery,
type PreviewTimeWindow,
type ProviderCapabilities,
} from "@/lib/db/types";
import type { ColumnSchema } from "@/lib/types";
/** Couchbase management port, the capability signal for the SQL++ dialect. */
const COUCHBASE_PORT = 8091;
/** Apache Druid Router port, the capability signal for the Druid SQL dialect. */
const DRUID_PORT = 8888;
/**
* Alias every generated Couchbase statement binds its keyspace to. SQL++ needs a
* name to hang `META()` and field references off, and the generator has only the
* collection name to work from, so the alias is fixed rather than derived: `d`
* for document. It never collides with a field, because fields are only ever
* referenced through it.
*/
const COUCHBASE_ALIAS = "d";
/**
* Column carrying the document key. Kept in step with
* `COUCHBASE_DOCUMENT_KEY_COLUMN` in the provider's introspection: the schema tree
* and the generated projection must name the key identically, or the grid shows a
* column the query never produces.
*/
const COUCHBASE_DOCUMENT_KEY_COLUMN = "__id";
/** Backtick-quote a SQL++ identifier, doubling any backtick it contains. */
function couchbaseQuote(name: string): string {
return `\`${name.replaceAll("`", "``")}\``;
}
/**
* Quote only when the name would not round-trip bare, in the two styles a provider
* may DECLARE (`ProviderCapabilities.identifierQuoting`), or always, in the other two.
*
* One object rather than two functions, and looked up rather than branched on: bun's
* lcov attributes a freshly added function's declaration line to nothing, so two new
* `function` declarations here read as uncovered while their bodies run - the phantom
* this repo's coverage notes describe. A table has one executable line per entry and
* no declaration line to lose.
*/
const DECLARED_QUOTING: Record<
NonNullable<ProviderCapabilities["identifierQuoting"]>,
(name: string, always: boolean) => string
> = {
backtick: (name, always) => (!always && /^[A-Za-z_][\w$]*$/.test(name) ? name : couchbaseQuote(name)),
double: (name, always) => (!always && /^[a-z_][a-z0-9_$]*$/.test(name) ? name : `"${name.replaceAll('"', '""')}"`),
"double-always": (name) => `"${name.replaceAll('"', '""')}"`,
"backtick-always": (name) => couchbaseQuote(name),
};
/**
* The document key projection. `SELECT *` nests whole documents under the
* keyspace name and never yields the key at all (issue #262, decision 5), so
* every generated statement projects it explicitly through the alias.
*/
const COUCHBASE_KEY_PROJECTION = `META(${COUCHBASE_ALIAS}).id AS ${COUCHBASE_DOCUMENT_KEY_COLUMN}`;
/**
* Quote a SQL identifier (table/column) for the target dialect, but ONLY when
* needed. Plain identifiers that round-trip unquoted are left as-is so generated
* SQL stays readable and existing behavior is preserved; mixed-case / special /
* fold-sensitive names get the dialect's quoting.
*
* Dialect is derived from the provider capabilities (same signals the generators
* already branch on), so no provider code needs to change:
* - Oracle (1521): unquoted folds to UPPERCASE → quote unless plain UPPER
* - SQL Server (1433): case-insensitive → bracket-quote only specials
* - MySQL (3306): case-preserving → backtick-quote only specials
* - Couchbase (8091): SQL++ → always backtick-quote
* - Druid (8888): Calcite SQL → always double-quote
* - PostgreSQL (5432) / SQLite / ClickHouse (8123) / default: unquoted folds to
* lowercase (pg) → quote unless plain lower
*
* ClickHouse deliberately has no branch of its own: it never folds case and its
* quote character is the double quote, so the default branch is already exactly
* right — both `SELECT "id" FROM "probe"` and the bare form parse (issue #264).
* Adding a branch would only duplicate it.
*
* `always` quotes a name that would round-trip bare as well. The bare test is about case and
* characters only, so a lowercase reserved word passes it: `when`, `order`, `user` and `group`
* come back bare and the engine reads them as keywords (#1396). Quoting such a name never
* changes which object it means, because a name passes the bare test only in the case the
* engine folds a bare name to. A statement that names columns it is about to create, as an
* import into a new table does, asks for this.
*/
export function quoteIdentifier(
name: string,
capabilities: ProviderCapabilities,
{ always = false }: { always?: boolean } = {},
): string {
// The JSON-language engines don't use SQL identifier quoting: MongoDB, and Redis, LibreDB, Kafka
// and etcd, which declare a JSON dialect of their own (#1088, #1089).
if (capabilities.queryLanguage === "json") return name;
// Cypher writes every name in backticks, a backtick doubled, as its generators do (Neo4j spec 6.5).
if (capabilities.queryLanguage === "cypher") return quoteCypherName(name);
// InfluxQL always double-quotes a name, with the scanner's escapes (InfluxDB spec 6.7, C6).
if (capabilities.queryLanguage === "influxql") return quoteInfluxqlIdentifier(name);
// An explicit declaration wins over the port heuristic below, because the port
// stopped being a faithful proxy for the dialect: Elasticsearch and OpenSearch
// both ship on 9200 and disagree about the quote character. Measured on
// OpenSearch 3.8.0, a double-quoted identifier is a STRING LITERAL - the
// generated query answers 200 with zero rows instead of failing - so the
// fall-through default would produce silently wrong results here, not an error.
// See `ProviderCapabilities.identifierQuoting`.
const declared = capabilities.identifierQuoting;
if (declared !== undefined) return DECLARED_QUOTING[declared](name, always);
if (capabilities.defaultPort === COUCHBASE_PORT) {
// Couchbase (SQL++): quote unconditionally. Reserved words (`bucket`, `scope`,
// ...) are a syntax error unquoted, and a schemaless document may name a field
// anything at all, so there is no safe unquoted subset worth detecting.
return couchbaseQuote(name);
}
if (capabilities.defaultPort === DRUID_PORT) {
// Druid (Calcite SQL): quote unconditionally, same reasoning as Couchbase above.
// A bare reserved word is a SYNTAX error, not a column-not-found: `SELECT count
// FROM libredb_demo` fails with "Received an unexpected token [count FROM]",
// while `SELECT "count" FROM libredb_demo` parses (issue #265). `count` is
// Druid's conventional rollup metric name, so the standard rollup ingestion
// produces a datasource that has one. Calcite's reserved list is large and
// version-dependent, so no safe unquoted subset is worth detecting.
return `"${name.replaceAll('"', '""')}"`;
}
if (capabilities.defaultPort === 1521) {
// Oracle
return !always && /^[A-Z_][A-Z0-9_$#]*$/.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
}
if (capabilities.defaultPort === 1433) {
// SQL Server
return !always && /^[A-Za-z_]\w*$/.test(name) ? name : `[${name.replaceAll("]", "]]")}]`;
}
if (capabilities.defaultPort === 3306) {
// MySQL
return !always && /^[A-Za-z_][\w$]*$/.test(name) ? name : `\`${name.replaceAll("`", "``")}\``;
}
// PostgreSQL / SQLite / default
return !always && /^[a-z_][a-z0-9_$]*$/.test(name) ? name : `"${name.replaceAll('"', '""')}"`;
}
/** A regular identifier in the subset every SQL dialect reads bare: ASCII letters, digits and `_`, no leading digit. */
const PORTABLE_BARE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Write a name the USER TYPED, as opposed to a catalog name read back from the engine, the way
* the SQL editor would read the same text (#1435).
*
* A name in the portable bare subset is written bare, so the engine folds it exactly as it
* folds that text typed in the editor: on Oracle `orders` becomes `ORDERS`, and a later
* `SELECT * FROM orders` finds it. `quoteIdentifier` would quote it there, because it keeps a
* catalog name exact, and the result would be a lowercase table that query cannot see. Any
* other name, `Ürün Tablo` or `a-b`, has no bare spelling at all, so it is quoted in the
* connection's own dialect and kept exactly as typed.
*
* A reserved word passes the bare test and reaches the engine bare, where it is refused with
* the engine's own error, as it would be from the editor. There is no per-engine reserved-word
* list to consult: the one reserved-word list in the repo is PostgreSQL's alone
* (`src/lib/editor/postgres-identifiers.ts`).
*/
export function quoteTypedName(name: string, capabilities: ProviderCapabilities): string {
return PORTABLE_BARE_NAME.test(name) ? name : quoteIdentifier(name, capabilities, { always: true });
}
/** The quote character of each declared quoting, which an engine-spelled segment is read with. */
const DECLARED_QUOTE_CHARACTER: Record<NonNullable<ProviderCapabilities["identifierQuoting"]>, '"' | "`"> = {
double: '"',
"double-always": '"',
backtick: "`",
"backtick-always": "`",
};
const BARE_SPELLED_PART = /^[A-Za-z_][A-Za-z0-9_]*/;
/**
* The parts of an engine-spelled name: bare parts and parts quoted with `quote` (that character doubled inside), joined
* by dots; `undefined` when the text does not read completely as such a spelling, so the caller quotes it as one name.
*/
function readSpelledParts(text: string, quote: string): string[] | undefined {
const parts: string[] = [];
let i = 0;
for (;;) {
if (text[i] === quote) {
let part = "";
let j = i + 1;
while (j < text.length && !(text[j] === quote && text[j + 1] !== quote)) {
part += text[j];
j += text[j] === quote ? 2 : 1;
}
if (j >= text.length || part === "") return undefined;
parts.push(part);
i = j + 1;
} else {
const bare = BARE_SPELLED_PART.exec(text.slice(i));
if (bare === null) return undefined;
parts.push(bare[0]);
i += bare[0].length;
}
if (i === text.length) return parts;
if (text[i] !== ".") return undefined;
i++;
}
}
/** The index of the segment `containerNameSpelling: "engine"` covers: the schema level's, when a segment follows it. */
function spelledSegmentIndex(path: readonly string[], capabilities: ProviderCapabilities): number {
if (capabilities.containerNameSpelling !== "engine") return -1;
const index = declaredLevels(capabilities).findIndex((level) => level.id === "schema");
return index !== -1 && index < path.length - 1 ? index : -1;
}
/** The character an engine-spelled segment is read with: the declared quoting's, which the declaration needs beside it. */
function spelledQuote(capabilities: ProviderCapabilities): '"' | "`" {
const declared = capabilities.identifierQuoting;
if (declared === undefined) {
throw new RangeError('containerNameSpelling "engine" needs a declared identifierQuoting to read the spelling with');
}
return DECLARED_QUOTE_CHARACTER[declared];
}
/** Quote an engine-spelled segment part by part, or as one name where it does not read as a spelling. */
function quoteSpelledSegment(
segment: string,
capabilities: ProviderCapabilities,
options: { always?: boolean },
): string {
const parts = readSpelledParts(segment, spelledQuote(capabilities));
if (parts === undefined) return quoteIdentifier(segment, capabilities, options);
return parts.map((part) => quoteIdentifier(part, capabilities, options)).join(".");
}
/**
* Quote an object ADDRESS: one segment per container level, then the object's own
* segment, each quoted independently and joined with `.`.
*
* Segments in and never a string to split, which is the whole of the rule. A name is
* what LABELS an object and a path is what ADDRESSES it (#789), and the string form
* below could only guess where one segment ends: a ClickHouse table really named
* `.inner_id.fake` in `demo` generated `SELECT * FROM "".inner_id.fake`, which the
* server answers with a syntax error at position 15, because the dots in its NAME were
* read as qualifiers. Reproduced in the browser on ClickHouse 25.8 before this changed.
*
* Quoting stays per segment and stays conditional, so `["employees", "department"]` is
* still `employees.department` and `["public", "Order"]` is still `public."Order"`.
*
* Where the engine declares `containerNameSpelling: "engine"`, the schema-level segment is
* its own spelling of a multi-part name and is quoted part by part; a segment that does not
* read as such a spelling is quoted as one name. That reading applies only when a segment
* follows the schema level, so the last segment is always the object's own name, and a
* container path passed here alone keeps its schema segment as one name: Spark's
* `["lake", "a.b"]` is `` `lake`.`a.b` ``, and a name appended to that text does not address
* the namespace `a`.`b`.
*
* Full qualification is emitted unconditionally, including inside the session default
* container. `demo.orders`, `[libredb_objects].[app].[customers]` and `APP.APP_CUSTOMERS`
* are all valid wherever the bare name is, so nothing here has to know which container a
* connection defaults to - and no capability declares that, which is why the flat spelling
* could not be qualified at the call site.
*
* `options.always` is `quoteIdentifier`'s, applied to every segment.
*/
export function quoteObjectPath(
path: readonly string[],
capabilities: ProviderCapabilities,
options: { always?: boolean } = {},
): string {
if (capabilities.queryLanguage === "json") return path.join(".");
// An InfluxQL measurement is `[database, measurement]`, written `"db".."m"`: the database's default retention
// policy (InfluxDB spec 6.7). No object is the empty string, as on every other dialect: a modal mounted before an
// object is chosen renders with the empty path.
if (capabilities.queryLanguage === "influxql") {
if (path.length === 0) return "";
if (path.length !== 2) {
throw new RangeError(`An InfluxQL source path is [database, measurement]; received ${path.length} segment(s)`);
}
return influxqlSource(path[0], path[1]);
}
const spelled = spelledSegmentIndex(path, capabilities);
return path
.map((segment, index) =>
index === spelled
? quoteSpelledSegment(segment, capabilities, options)
: quoteIdentifier(segment, capabilities, options),
)
.join(".");
}
/**
* How editor completion writes and reads a qualified name on an engine that declares
* `containerNameSpelling: "engine"`: there not every dot separates two segments, because the
* spelled segment holds one name or several. Spark's namespace `a.b` is the names `a` and `b`,
* and its namespace `` `x.y` `` the one name `x.y`.
*/
export interface ContainerSpelling {
/** The character a part is quoted with, doubled inside: the declared quoting's. */
quote: '"' | "`";
/** An object's address as the engine writes it: `quoteObjectPath`. */
quotePath: (path: readonly string[]) => string;
/** The names each segment of an object's address holds: one, or the parts `quoteObjectPath` quotes it by. */
pathNames: (path: readonly string[]) => string[][];
}
/** The engine's `ContainerSpelling`, or `undefined` where it does not declare `containerNameSpelling`. */
export function containerSpellingOf(capabilities: ProviderCapabilities): ContainerSpelling | undefined {
if (capabilities.containerNameSpelling !== "engine") return undefined;
const quote = spelledQuote(capabilities);
return {
quote,
quotePath: (path) => quoteObjectPath(path, capabilities),
pathNames: (path) => {
const spelled = spelledSegmentIndex(path, capabilities);
return path.map((segment, index) =>
index === spelled ? (readSpelledParts(segment, quote) ?? [segment]) : [segment],
);
},
};
}
/**
* The object's own segment, which is the LAST one and is never read by index 0 (standing
* ruling 5g): at container depth 2 the object is `path[2]`, and a positional read there
* addresses a container instead.
*
* An empty path is refused rather than rendered. It is a caller that lost the address, and
* every dialect below would otherwise spell it silently: `FROM ` on SQL, `get ` on LibreDB,
* `"collection": undefined` on MongoDB. Both generators resolve it before they branch, so
* one refusal covers every dialect.
*
* Exported for `useTabManager`, which names the tab it opens after the object and has the
* same two reasons to read the last segment and to refuse an empty address.
*/
export function objectSegment(path: readonly string[]): string {
const segment = path[path.length - 1];
if (segment === undefined) throw new Error("Cannot generate a query: the object address has no segments.");
return segment;
}
/**
* The address a JSON command carries: the collection's own segment, and the database that
* holds it as a key of its own (#843). `db.collection("sample_shop.users")` would name a
* collection literally called that, so the database cannot ride inside `collection`.
*
* The database is the segment the declaration assigns to its `schema` level, never
* `path[0]` (standing ruling 5g), and it is emitted unconditionally, including for the
* connected database, for the reason `quoteObjectPath` qualifies unconditionally. A path
* whose length does not match the declared levels is refused: a collection that lost its
* database would otherwise read the connected database's same-named collection, which is
* the wrong answer #843 was.
*
* The one reader for every statement the product writes for MongoDB: the three generators
* below, the profiler route and the test data generator.
*/
export function jsonCommandAddress(
path: readonly string[],
capabilities: ProviderCapabilities,
): { database?: string; collection: string } {
const levels = declaredLevels(capabilities);
if (path.length !== levels.length + 1) {
const shape = [...levels.map((level) => level.id), "name"].join(", ");
throw new Error(`Cannot generate a query: the object path is [${shape}], received ${JSON.stringify(path)}.`);
}
const collection = objectSegment(path);
if (levels.length === 0) return { collection };
const index = levels.findIndex((level) => level.id === "schema");
if (index < 0) {
throw new Error(
`Cannot generate a query: a JSON command needs a "schema" container level for its database; ` +
`the declaration is [${levels.map((level) => level.id).join(", ")}].`,
);
}
return { database: path[index], collection };
}
/**
* Render a schema-tree node name for a `#` comment line. A node name is a real
* key/collection name taken from the server, and a Redis key is an arbitrary
* byte string — so a name containing a newline used to END the header comment
* and turn its own remainder into the first RUNNABLE line of the cheatsheet,
* which the provider then executed (`a\nDEL user:1 x` ran `DEL user:1`). The
* per-argument defence never engaged, because the injection travelled through
* the comment rather than through a command.
*
* JSON quoting is the fix: it escapes CR, LF and the quote character in one
* lossless step, and for an ordinary name it renders exactly the `"name"` the
* headers already wrote by hand. Any name entering a comment line must go
* through this (#427), a PromQL metric's name included (#1085).
*/
function commentName(name: string): string {
return JSON.stringify(name);
}
/**
* Resolve a key-value schema-tree node name to its command shape. A node is
* either a `:`-prefix group (e.g. `users:*`, whose rows live under the `users:`
* prefix) or a bare single key with no colon. The `*` is stripped so the base is
* the literal prefix used in commands (`users:*` -> `users:`).
*
* Shared by the LibreDB and Redis branches: both build their tree from the same
* `keyGrouping` grouping, so a future change to what a prefix node looks like
* must not be able to make the two dialects disagree (#427).
*/
function prefixGroup(name: string): { isPrefixGroup: boolean; base: string } {
if (name.endsWith(":*")) return { isPrefixGroup: true, base: name.slice(0, -1) };
return { isPrefixGroup: false, base: name };
}
/**
* An etcd key-prefix group's prefix (#1089, section 6.4): the row name without its final `*` where the
* name ends in `/*`, the only name keys.ts `groupLabel` writes (`/app/config/*` reads `/app/config/`).
* Any other segment is the prefix as it stands, so a hand-built path still reads a bounded prefix, and
* never a key with a `*` in it.
*/
function etcdGroupPrefix(name: string): string {
return name.endsWith("/*") ? name.slice(0, -1) : name;
}
/** The rows a generated etcd read asks for: the preview page the other arms read (#1089 6.4). */
const ETCD_READ_LIMIT_FLAG = "--limit=50";
/** The sample key's last segment and the sample value of Generate Command's write forms (#1089 6.4). */
const ETCD_SAMPLE_NAME = "example";
const ETCD_SAMPLE_VALUE = "value";
/**
* Whether a word holds a character Studio's editor does not keep as the etcd command line spells it: a
* carriage return, which has no spelling there, since the editor ends a line at it and the lexer's
* `quoteWord` throws for one, or a line or paragraph separator, U+2028 or U+2029, which Monaco offers to
* remove from the text the moment it lands, as an unusual line terminator, so a form that kept it raw
* between single quotes would name other bytes once the offer is taken. A form that names one is written
* as a txn request instead, whose Go quoting escapes each of them (#1089 6.4).
*/
const holdsUnkeptCharacter = (word: string): boolean => /[\r\u2028\u2029]/.test(word);
/**
* A group's name as an etcd note names it: `commentName`'s JSON quoting, with a line or paragraph separator,
* which JSON leaves raw and the editor does not keep (`holdsUnkeptCharacter`), written as its `\u` escape, which
* JSON reads back as the same character (#1089 6.4).
*/
const etcdCommentName = (name: string): string =>
commentName(name).replace(/[\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16)}`);
/**
* One etcd request: the command, then its arguments through `quote`, the word rule of the place the
* request lands, then the flags; or, where an argument begins with `-`, the flags, `--` and the arguments,
* because etcdctl reads a word that begins with `-` as a flag (#1089 6.4, R11 ETCD-6).
*/
function etcdRequest(
command: string,
args: readonly string[],
flags: readonly string[],
quote: (word: string) => string,
): string {
const words = args.map(quote);
const dashed = args.some((arg) => arg.startsWith("-"));
return [command, ...(dashed ? [...flags, "--", ...words] : [...words, ...flags])].join(" ");
}
/** A word of a txn request: bare when it is safe text, else Go-quoted (lexer.ts `quoteTxnWord`). */
const etcdTxnWord = (word: string): string => quoteTxnWord(encodeKey(word));
/** A piece of a group's range as a get names it: its keys, and `--prefix` for a prefix (#1089 4.7). */
function etcdPieceGet(piece: ObjectReadRange): { readonly keys: readonly string[]; readonly flags: readonly string[] } {
if ("key" in piece) return { keys: [piece.key], flags: [] };
if ("prefix" in piece) return { keys: [piece.prefix], flags: ["--prefix"] };
return { keys: [piece.start, piece.end], flags: [] };
}
/**
* The read of one piece (#1089 6.4): a get on the command line with the preview's `--limit`, or, where a
* key holds a character the editor does not keep as the command line spells it (`holdsUnkeptCharacter`),
* a txn whose success list holds the Go-quoted get with no `--limit`, since the provider sends a txn's
* ranged get with its own page size (#1089 5.1.4). The txn closes its success and failure lists with a
* blank line each, so no line below it is read as one of its requests.
*/
function etcdRead(piece: ObjectReadRange): string {
const { keys, flags } = etcdPieceGet(piece);
if (keys.some(holdsUnkeptCharacter)) {
return ["txn", "", etcdRequest("get", keys, flags, etcdTxnWord), "", ""].join("\n");
}
return etcdRequest("get", keys, [...flags, ETCD_READ_LIMIT_FLAG], quoteWord);
}
/**
* A form written as comment lines (#1089 6.4): every physical line behind `# `, and a blank one as `#`, so a
* form that spans lines, a quoted key holding a newline or a txn, is commented on each of them and never
* leaves a runnable remainder.
*/
function etcdCommented(form: string): string {
return form
.split("\n")
.map((line) => (line === "" ? "#" : `# ${line}`))
.join("\n");
}
/**
* Blocks a blank line apart; a block that ends in a line break, the closed txn of `etcdRead`, already ends
* on a blank line, so it takes one line break more.
*/
function etcdBlocks(blocks: readonly string[]): string {
return blocks.reduce((text, block) => `${text}${text.endsWith("\n") ? "\n" : "\n\n"}${block}`);
}
/**
* What a tree click on an etcd group runs (#1089 6.4, 4.7): the read of the first piece this connection may
* read, the whole prefix where the listing named none, and each further piece as a commented read below it.
* A group whose every readable piece starts or ends at a key that is not UTF-8 text lists no piece
* (keyvalue/etcd/objects.ts `groupReadRanges`), and gets a note and no read.
*/
function etcdReadText(tableName: string, scope: GeneratorScope | undefined): string {
const [first, ...rest] = scope?.readRanges ?? [{ prefix: etcdGroupPrefix(tableName) }];
if (first === undefined) {
return `# No read is written for ${etcdCommentName(tableName)}: each part of it this connection may read starts or ends at a key that is not UTF-8 text.`;
}
return etcdBlocks([etcdRead(first), ...rest.map((piece) => etcdCommented(etcdRead(piece)))]);
}
/**
* The txn template of Generate Command (#1089 6.4): create the key where it does not exist, else read it.
* The compare's key is Go-quoted, as etcdctl's compare reads it, and each request word takes the txn rule.
*/
function etcdTxnTemplate(key: string): string {
return [
"txn",
`create(${quoteGoString(encodeKey(key))}) = "0"`,
"",
etcdRequest("put", [key, ETCD_SAMPLE_VALUE], [], etcdTxnWord),
"",
etcdRequest("get", [key], [], etcdTxnWord),
].join("\n");
}
/**
* Generate Command's other forms (#1089 6.4), each on a sample key under the group's prefix: a put, a
* del, a watch of the prefix and the txn template. A prefix holding a character the editor does not keep
* as the command line spells it has no command-line form, so the txn template, which spells it, is its
* one form. A prefix that begins with `-` gets no watch: on `watch`, a `--` introduces the command etcdctl
* runs for each event, so no watch of such a prefix parses.
*/
function etcdOtherForms(prefix: string): readonly string[] {
const key = `${prefix}${ETCD_SAMPLE_NAME}`;
const template = etcdTxnTemplate(key);
if (holdsUnkeptCharacter(prefix)) return [template];
return [
etcdRequest("put", [key, ETCD_SAMPLE_VALUE], [], quoteWord),
etcdRequest("del", [key], [], quoteWord),
...(prefix.startsWith("-") ? [] : [etcdRequest("watch", [prefix], ["--prefix"], quoteWord)]),
template,
];
}
/**
* Generate Command on an etcd group (#1089 6.4): the click's read, then the other forms as comments, so
* running the whole buffer runs the read and a write needs an edit first. On a read-only connection it is
* the read alone, with its pieces, and no form that writes (E6).
*/
function etcdCommandText(tableName: string, scope: GeneratorScope | undefined): string {
const read = etcdReadText(tableName, scope);
if (scope?.readOnly === true) return read;
return etcdBlocks([read, ...etcdOtherForms(etcdGroupPrefix(tableName)).map(etcdCommented)]);
}
/** A concrete example JSON scalar for a catalog column type (LibreDB column
* types are `string` | `number` | `boolean` | `object`; unknowns read as text). */
function libredbExampleForType(type: string): unknown {
switch (type.toLowerCase()) {
case "number":
return 1;
case "boolean":
return true;
case "object":
return {};
default:
return "example";
}
}
/**
* A concrete, runnable example VALUE for a `put` against a group, shaped by the
* group's columns so the generated command works as-is when selected and run:
* - raw kv (`key`/`value` columns) → a plain string value
* - document collection (`id`/`document`) → a small JSON object
* - relational table → a JSON object built from the declared columns
*/
function libredbExampleValue(columns: readonly ColumnSchema[]): string {
if (columns.length === 2 && columns[0]?.name === "key" && columns[1]?.name === "value") {
return "example";
}
if (columns.length === 2 && columns[1]?.name === "document") {
return `'{"name":"example"}'`;
}
const obj: Record<string, unknown> = {};
for (const c of columns) obj[c.name] = libredbExampleForType(c.type);
return `'${JSON.stringify(obj)}'`;
}
/**
* Redis key types this generator can produce a read/write command for. `TYPE`
* also replies `none`, which falls into the unknown bucket and emits
* `TYPE <key>` rather than guessing a reader (#427). `stream` and the
* `ReJSON-RL` reply a RedisJSON key gives have their own readers (#1454).
*/
type RedisKeyType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "rejson-rl";
/**
* The read and write command each key type gets, with the use-case comment that
* introduces it in the generated cheatsheet. A lookup table rather than a
* `switch` so every arm is one attributable line.
*/
const REDIS_COMMANDS: Record<
RedisKeyType,
{ readComment: string; read: (key: string) => string[]; writeComment: string; write: (key: string) => string[] }
> = {
string: {
readComment: "# Read the value",
read: (key) => ["GET", key],
writeComment: "# Create or update it — this overwrites an existing value",
write: (key) => ["SET", key, "example"],
},
hash: {
readComment: "# Read every field of the hash",
read: (key) => ["HGETALL", key],
writeComment: "# Create or update one field — this overwrites an existing field",
write: (key) => ["HSET", key, "field", "example"],
},
list: {
readComment: "# Read the whole list",
read: (key) => ["LRANGE", key, "0", "-1"],
writeComment: "# Append an element to the list",
write: (key) => ["RPUSH", key, "example"],
},
set: {
readComment: "# Read every member",
read: (key) => ["SMEMBERS", key],
writeComment: "# Add a member to the set",
write: (key) => ["SADD", key, "example"],
},
zset: {
readComment: "# Read every member with its score",
read: (key) => ["ZRANGE", key, "0", "-1", "WITHSCORES"],
writeComment: "# Add a member with a score",
write: (key) => ["ZADD", key, "1", "example"],
},
stream: {
readComment: "# Read the first entries of the stream",
read: (key) => ["XRANGE", key, "-", "+", "COUNT", "100"],
writeComment: "# Append an entry to the stream",
write: (key) => ["XADD", key, "*", "field", "example"],
},
"rejson-rl": {
readComment: "# Read the JSON document",
read: (key) => ["JSON.GET", key],
writeComment: "# Create or update the JSON document",
write: (key) => ["JSON.SET", key, "$", '{"example":true}'],
},
};
/**
* Whether an argument survives a round-trip through the provider's plain-command
* tokenizer. That tokenizer splits on unquoted whitespace and has NO escape
* handling: it toggles quote mode on every `"` or `'` and drops the character.
* So `DEL "say"hi""` reaches the driver as the key `sayhi` — a DIFFERENT key —
* and a quote inside a MATCH pattern swallows the rest of the line. A backslash
* or a newline is treated the same way for safety (#427).
*/
function redisPlainSafe(value: string): boolean {
return !/["'\\\n]/.test(value);
}
/**
* Render one Redis command in the form the provider can actually run it in.
*
* Plain form (`SET key value`, quoting an argument that contains whitespace) is
* the readable default. When any argument cannot round-trip through the plain
* tokenizer, this line — and only this line — is emitted in the lossless JSON
* form the provider also accepts, `{"command":"DEL","args":["say\"hi\""]}`.
* The two forms mix freely inside one cheatsheet: the provider decides per run,
* and every line is run on its own via "Run Selected" (#427).
*/
function renderRedisCommand(parts: string[]): string {
if (parts.every(redisPlainSafe)) {
return parts.map((part) => (/\s/.test(part) ? `"${part}"` : part)).join(" ");
}
return JSON.stringify({ command: parts[0], args: parts.slice(1) });
}
/**
* Escape Redis glob metacharacters. Applied ONLY to the prefix half of a MATCH
* pattern, never to a key argument: a real key `a[b:1` groups to `a[b:*`, and an
* unescaped `[` opens a glob class that matches the wrong set. Escaping a key
* argument would instead corrupt a literal key that genuinely contains `*` (#427).
*/
export function escapeGlob(value: string): string {
return value.replace(/[\\*?[\]^]/g, String.raw`\$&`);
}
/**
* The single Redis key type a schema node's sample resolves to, or `null` for the
* unknown bucket. The source is the `type` column's own `type` field, which
* `redis.ts` `getSchema()` builds as `types.join(", ")` over the DISTINCT `TYPE`
* replies it sampled — it issues `TYPE` for every key of a prefix until it has
* seen 3 distinct types (or the 1000-key scan cap ends the walk), so a uniform
* prefix costs one blocking round-trip per key and still yields one type — so `"string"` resolves, and `""` (every TYPE call threw) or
* `"string, hash"` (a mixed prefix) deliberately do not. The `value` column
* carries the same sample joined with `/` and exists for display only (#427).
*/
function redisKeyType(columns?: readonly ColumnSchema[]): RedisKeyType | null {
const sample = columns?.find((c) => c.name === "type")?.type;
const parts = (sample || "")
.split(",")
.map((part) => part.trim().toLowerCase())
.filter((part) => part !== "");
// Redis TYPE replies are lowercase already; normalising keeps a hand-authored
// schema from reading as a silent unknown.
if (parts.length !== 1) return null;
return parts[0] in REDIS_COMMANDS ? (parts[0] as RedisKeyType) : null;
}
/** The SCAN command listing a prefix group's keys — the only place a glob is interpreted. */
function redisScan(base: string): string {
return renderRedisCommand(["SCAN", "0", "MATCH", `${escapeGlob(base)}*`, "COUNT", "50"]);
}
/**
* The terminator a generated statement ends with: `;` everywhere, and nothing on a
* product whose grammar has none.
*
* Only the two shapes a user reaches by CLICKING are bounded here - the schema tree's
* "Select Top N" and "Generate Query" - because those are the statements this file
* writes on the user's behalf.
*
* `generateTableQuery`'s returns all ask this now. They did not always: Oracle and SQL
* Server had branches of their own to spell their row bound, and #816 removed the bound,
* which left the two branches doing nothing the shared return did not. `generateSelectQuery`
* still keeps a literal `;` in its SQL Server and Couchbase branches, because both accept
* one and neither branch has been touched.
*
* Oracle is the measurement that moved: `SELECT * FROM app_customers FETCH FIRST 50
* ROWS ONLY;` answers ORA-00933, so clicking a table on Oracle had never once worked.
* See `ProviderCapabilities.statementTerminator` for both measurements.
*/
function terminator(capabilities: ProviderCapabilities): string {
return capabilities.statementTerminator === "none" ? "" : ";";
}
/**
* The one refusal both LibreDB generators give for a name they cannot address:
* a `#` note and no command line. A schema-tree node name is a real key name,
* LibreDB keys are arbitrary byte strings, and every LibreDB command is
* line-oriented — `get`/`put`/`delete`/`prefix` interpolate the name raw, so a
* key named `x\ndelete billing:2024` renders its own second half as a runnable
* `delete billing:2024` line. LibreDB has no lossless JSON command form to fall
* back to the way Redis does, so emit no command line at all and say why. Both
* callers go through here so the two branches cannot drift (#427, U11).
*/
function libredbNewlineNote(base: string): string | null {
if (!/[\r\n]/.test(base)) return null;
// No "write it by hand" advice: `firstCommandLine` splits the buffer on LF before tokenizing
// (providers/embedded/libredb.ts), so a key whose name contains a LINE FEED cannot be reached
// from this editor at all, quoted or not. A carriage return survives inside quotes and could be
// typed by hand - but one note covers both characters, and promising an action that fails for
// half of them is worse than promising none.
return "# This key's name contains a line break. LibreDB commands are line-oriented, so no generated line can address it.";
}
/**
* What only the etcd arms of the two generators read (#1089, sections 3.3, 4.7 and 6.4), handed over by
* `useTabManager` from the schema entry and the active connection. Every other arm ignores it, so no other
* engine's text moves with it.
*/
export interface GeneratorScope {
/** The pieces of a group's range this connection may read, `DatabaseObject.readRanges`, where they do not cover it. */
readonly readRanges?: readonly ObjectReadRange[];
/** The active connection's public `readOnly`: Generate Command then writes the read alone (#1089 E6). */
readonly readOnly?: boolean;
/** The object's kind, `DatabaseObject.kind`, handed to `rowAction`, whose statement depends on what the row is. */
readonly kind?: string;
}
/**
* What one query dialect writes for a tree click (`table`, run on the user's behalf) and for "Generate Query"
* (`select`, written into a tab and not run), each from the object's path, and, for a dialect whose provider
* declares `ProviderCapabilities.rowActions`, what each declared row action runs (`rowAction`).
*/
export interface DialectGenerators {
readonly table: (
path: readonly string[],
columns: readonly ColumnSchema[] | undefined,
scope: GeneratorScope | undefined,
) => string;
readonly select: (
path: readonly string[],
columns: readonly ColumnSchema[],
scope: GeneratorScope | undefined,
) => string;
/**
* The statement one declared row action runs on one object, by the action's id, with the object's kind
* on `scope.kind`. Absent on every dialect whose provider declares no row action.
*
* The statement reads and never writes, and honours `scope.readOnly`: it runs on click with no role
* asked and no editing step. Every segment of `path` is an object name taken from a listing, which a
* user of the database can choose, so it is quoted by the dialect's own quoting rule and never
* concatenated.
*/
readonly rowAction?: (path: readonly string[], actionId: string, scope: GeneratorScope) => string;
/**
* The statement Generate Count Query writes for one object, on a dialect whose `QUERY_DIALECTS` record offers it.
* Absent on every dialect whose record does not; `generateCountQuery` refuses a record that offers a count and
* writes none, rather than falling through to the JSON arm's MongoDB count document.
*/
readonly count?: (path: readonly string[]) => string;
}
/**
* Every query dialect's generators, read by `generateTableQuery` and `generateSelectQuery` BEFORE their JSON arm:
* each dialect declares `queryLanguage: "json"` too, and without its record a tree click auto-executes a MongoDB
* `find` its provider refuses (#427, #1088, #1089). A `Record` over `QueryDialect`, so a dialect added to the
* union does not compile until it has its generators. Module-internal: the two generators, `generateCountQuery`
* and `generateRowActionQuery` are its only readers, and this module's export list is pinned
* (`tests/unit/lib/query-generators.test.ts`).
*/
/**
* Every query dialect's generators, read by `generateTableQuery` and `generateSelectQuery` BEFORE their JSON arm:
* each dialect declares `queryLanguage: "json"` too, and without its record a tree click auto-executes a MongoDB
* `find` its provider refuses (#427, #1088, #1089). A `Record` over `QueryDialect`, so a dialect added to the
* union does not compile until it has its generators. Module-internal: the two generators, `generateCountQuery`
* and `generateRowActionQuery` are its only readers, and this module's export list is pinned
* (`tests/unit/lib/query-generators.test.ts`).
*/
/** The object a tree click landed on: the last segment, which is the numbered name itself. */
function snmpObjectName(path: readonly string[]): string {
return path[path.length - 1] ?? "";
}
const DIALECT_GENERATORS: Readonly<Record<QueryDialect, DialectGenerators>> = Object.freeze({
// LibreDB speaks its own command grammar (get/put/delete/prefix/range), not SQL and not MongoDB JSON. "Scan"
// lists everything under the group's prefix, and "Scan Keys" AUTO-EXECUTES, so a newline in the name, which
// used to leave a second, plausible command one "Run Selected" away, gets the shared refusal (U11). "Generate
// Command" writes an explanatory cheatsheet, a use-case comment above each command and every command line a
// concrete, directly runnable example; the provider skips `#` comment and blank lines, so running the whole
// buffer runs its first real command.
libredb: {
table: (path) => {
const { isPrefixGroup, base } = prefixGroup(objectSegment(path));
const note = libredbNewlineNote(base);
if (note !== null) return note;
return isPrefixGroup ? `prefix ${base}` : `get ${base}`;
},
select: (path, columns) => libredbCheatsheet(objectSegment(path), columns),
},
// Redis speaks its own command grammar, and it silently got MongoDB documents its driver answered with HTTP 400
// before it had a record (#427). A prefix group is not addressable (`tablesAreDerivedGroupings`), so it always
// SCANs; a bare key gets the reader its sampled type calls for, or `TYPE` when unknown.
redis: {
table: (path, columns) => {
const { isPrefixGroup, base } = prefixGroup(objectSegment(path));
if (isPrefixGroup) return redisScan(base);
const keyType = redisKeyType(columns);
return renderRedisCommand(keyType ? REDIS_COMMANDS[keyType].read(base) : ["TYPE", base]);
},
select: (path, columns) => redisCheatsheet(objectSegment(path), columns),
},
// Kafka reads a topic through a JSON read request (#1088 3.3), and the name goes through JSON.stringify with the
// rest, so no topic name leaves its string. "Generate Query" writes ONE read request, because the tab's whole
// buffer is sent as one request (`handleGenerateSelect` in use-tab-manager.ts) and JSON has no comments to hold
// the other forms: it names a partition and reads it from its earliest offset, which exists on any retention,
// and the result's `offset` column shows the offsets the `{"offset": n}` form takes. The offset and timestamp
// forms are documented in docs/providers/kafka.md. The columns are the fields each message comes back with, not
// keys of the request, so none is written.
kafka: {
table: (path) => JSON.stringify({ topic: objectSegment(path), from: "latest", limit: 50 }, null, 2),
select: (path) =>
JSON.stringify({ topic: objectSegment(path), partition: 0, from: "earliest", limit: 50 }, null, 2),
},
// etcd reads a group through an etcdctl command (#1089, section 6.4). A row is one group, so the click reads its
// prefix, or the first piece of it this connection may read, bounded by the command's own `--limit`; "Generate
// Command" writes that read on the first line and the other forms as comments below it. A group's columns are
// the fixed shape of a get row, so none reaches the text.
etcd: {
table: (path, _columns, scope) => etcdReadText(objectSegment(path), scope),
select: (path, _columns, scope) => etcdCommandText(objectSegment(path), scope),
},
// Milvus reads a collection through its own console request (vector-family spec 5.7), written by the provider's
// browser-safe generators.ts: the tree click is an entities/query naming the clicked database and collection, and
// "Generate Command" a runnable search over the first dense vector field. Neither reads the scope.
milvus: {
table: (path) => milvusTableQuery(path),
select: (path, columns) => milvusSelectQuery(path, columns),
},
// Qdrant reads a collection through its own console request (vector-family spec 6.7), written by the provider's
// browser-safe generators.ts: the tree click is a 100-point scroll of the clicked collection, without vectors, and
// "Generate Command" a runnable query over the first dense vector. Neither reads the scope.
qdrant: {
table: (path) => qdrantTableQuery(path),
select: (path, columns) => qdrantSelectQuery(path, columns),
},
// Oxia reads a key through an oxia client command (SB2-4.5), written by the provider's browser-safe generators.ts:
// the click is `get <key>`, and "Generate Command" that get with the `list --prefix` and `range-scan --prefix` forms
// as comments. No shipped click reaches them in v1 (a key row opens its Source tab, SB2-12 D2). Neither reads the
// columns or the scope.
oxia: {
table: (path) => oxiaTableQuery(path),
select: (path) => oxiaSelectQuery(path),
},
// S3 reads through its console's commands, written by the provider's browser-safe generators.ts: a bucket's
// listing, or an object's preview, and Generate Command adds reads as comments. The path is the provider's own,
// [bucket] or [<bucket>/<key>]. No shipped click reaches them in v1 (a bucket or object row opens its Source tab).
// Every text is a command the console's parser accepts, or a note.
// MQTT has no query console at all: its surface is the Keys panel, and a declared key-browser kind opens a
// topic's value in its Source tab rather than a generated read, so these are reached only by a path that
// asks for text where there is none. They answer a sentence saying so, never a statement, because a
// statement would be a command this provider cannot send.
mqtt: {
table: (path) =>
`// MQTT has no query console. ${objectSegment(path)} opens in its Source tab from the Keys panel.`,
select: (path) =>
`// MQTT has no query console. ${objectSegment(path)} opens in its Source tab from the Keys panel.`,
},
// Firestore reads a collection through one statement of its console's read-only Admin SDK subset, written by the
// provider's browser-safe console/generators.ts: a tree click and Generate Query are both the 100-document preview of
// the clicked collection, addressed by the object's own segment, the collection id the tree lists. Generate Count
// Query writes the count() aggregation of the same collection.
firestore: {
table: (path) => previewStatement([objectSegment(path)]),
select: (path) => previewStatement([objectSegment(path)]),
count: (path) => countStatement([objectSegment(path)]),
},
// RabbitMQ reads a stream through its own JSON read request, written by the provider's browser-safe generators.ts:
// the click reads the last chunk ("Read Last Chunk") and Generate Read Request the first message, 50 rows each.
// Neither reads the columns or the scope, and neither throws.
rabbitmq: {
table: (path) => rabbitmqTableQuery(path),
select: (path) => rabbitmqSelectQuery(path),
},
s3: {
table: (path) => s3TableQuery(path),
select: (path) => s3SelectQuery(path),
},
// Kubernetes reads an object through a kubectl statement, written by the provider's browser-safe generators.ts: the
// click and Generate Command are one get of the object (a built-in row opens its YAML instead, so the click reaches
// this only from a custom resource type's row), and a declared row action writes Describe, Logs or List. None reads
// the columns or the scope.
kubectl: {
table: (path) => kubectlGetQuery(path),
select: (path) => kubectlGetQuery(path),
rowAction: (path, actionId) => kubectlRowActionQuery(path, actionId),
},
/**
* OPC UA generates nothing, and the two members below are unreachable rather than useful.
*
* Its dialect declares `offersCodeGeneration: false`, which is what `row-actions.ts` and
* `TableItem.tsx` read: the button is never drawn, so neither function is called. They exist
* because the record is exhaustive over dialects and `DialectGenerators` requires both.
*
* WHY NOTHING CAN BE GENERATED. A generator is handed the object's PATH, and a path's last segment
* is a BrowseName. A read request names a NODE ID, which a BrowseName is not: measured on the
* pinned fixture, 1,299 of its 6,769 node ids cannot be built from their own browse name, and 41
* nodes have more than one path. So a statement built from a path would name a node the reader did not
* choose, or none. A click on a key opens the node's own value in a Source tab instead, which is
* `readObjectSource`, and that reaches the node id by browsing down the path.
*/
opcua: Object.freeze({
table: () => UNREACHABLE_OPCUA_GENERATOR,
select: () => UNREACHABLE_OPCUA_GENERATOR,
}),
/**
* MTConnect writes one request line, the route MEASURED to answer the clicked row.
*
* A path is `[device]` for a device and `[device, dataItemId]` for an item under it, and the route
* that answers either is the device's `current`: one row per observation of that device, the
* clicked item's among them. The item is named in a comment above the line rather than written
* into a `path=` filter, because the filter would be an XPath predicate on `@id` and THAT WAS NOT
* MEASURED: `@type` and `@category` predicates were run against the pinned agent and answered 200,
* an `@id` one was not, and a generator is not the place to find out. The comment marker is the
* dialect's own `#`, which the provider's guard drops before it reads the route.
*
* `offersCodeGeneration: false`, so `select` is never drawn; it answers the same line as `table`
* rather than a sentence, because the line is correct wherever it lands.
*/
mtconnect: Object.freeze({