@finos/legend-query-builder 4.19.13 → 4.19.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,707 @@
1
+ # Query Builder — The Lambda Round Trip
2
+
3
+ > Technical reference for `@finos/legend-query-builder`.
4
+ >
5
+ > Audience: engineers working on the query builder, the graph manager
6
+ > (`@finos/legend-graph`), or any application that embeds `QueryBuilderState`.
7
+
8
+ A query in Legend has no single representation. It exists as five different
9
+ things at different points in its life, and the query builder's central job is
10
+ converting between them without losing information:
11
+
12
+ ```
13
+ raw JSON ⇄ V1_RawLambda ⇄ ValueSpecification / LambdaFunction ⇄ QueryBuilderState
14
+ │ │
15
+ └──────────────────── PURE grammar text ───────────────────────────────┘
16
+ (via the engine)
17
+ ```
18
+
19
+ This document traces that cycle in both directions, explains why each hop
20
+ exists, and covers where the engine sits in it.
21
+
22
+ **Contents**
23
+
24
+ 1. [The representations](#1-the-representations)
25
+ 2. [Inbound: JSON → UI state](#2-inbound-json--ui-state)
26
+ 3. [Outbound: UI state → JSON](#3-outbound-ui-state--json)
27
+ 4. [How the lambda reaches the engine](#4-how-the-lambda-reaches-the-engine)
28
+ 5. [Grammar (text) mode](#5-grammar-text-mode)
29
+ 6. [When the round trip fails](#6-when-the-round-trip-fails)
30
+ 7. [Change detection](#7-change-detection)
31
+
32
+ ---
33
+
34
+ ## 1. The representations
35
+
36
+ ### 1.1 `RawLambda` — the transport form
37
+
38
+ [`RawLambda`](../../legend-graph/src/graph/metamodel/pure/rawValueSpecification/RawLambda.ts)
39
+ is deliberately dumb:
40
+
41
+ ```ts
42
+ export class RawLambda extends RawValueSpecification implements Hashable {
43
+ body?: object | undefined;
44
+ parameters?: object | undefined;
45
+ }
46
+ ```
47
+
48
+ `body` and `parameters` are **untyped JSON**. Nothing is resolved, nothing is
49
+ validated. Its protocol twin `V1_RawLambda` serializes them with serializr's
50
+ `raw()`, which passes the payload through verbatim:
51
+
52
+ ```ts
53
+ export const V1_rawLambdaModelSchema = createModelSchema(V1_RawLambda, {
54
+ _type: usingConstantValueSchema(V1_RawValueSpecificationType.LAMBDA),
55
+ body: raw(),
56
+ parameters: raw(),
57
+ });
58
+ ```
59
+
60
+ That opacity is the point. A `RawLambda` can hold a query using functions this
61
+ version of Studio has never heard of, round-trip it to the engine, and store it
62
+ again — byte-for-byte. It is what gets **persisted** (`Query.content`, service
63
+ executions) and what goes **over the wire**.
64
+
65
+ ### 1.2 `ValueSpecification` / `LambdaFunction` — the metamodel form
66
+
67
+ [`LambdaFunction`](../../legend-graph/src/graph/metamodel/pure/valueSpecification/LambdaFunction.ts)
68
+ is the resolved tree:
69
+
70
+ ```ts
71
+ export class LambdaFunction implements Hashable {
72
+ functionType: FunctionType; // parameters + return type
73
+ openVariables: Map<string, VariableExpression>;
74
+ expressionSequence: ValueSpecification[]; // the actual expressions
75
+ }
76
+ ```
77
+
78
+ Every node is a real `ValueSpecification` — `SimpleFunctionExpression`,
79
+ `AbstractPropertyExpression`, `VariableExpression`, `InstanceValue`, … — and
80
+ every reference into the model is a resolved `PackageableElementReference`
81
+ pointing at a live `Class`, `Property`, or `Enum` in the `PureModel` graph.
82
+
83
+ This is the form the query builder can actually reason about: you cannot ask a
84
+ blob of JSON "what class does this property belong to", but you can ask a
85
+ resolved `AbstractPropertyExpression`.
86
+
87
+ `LambdaFunction` is wrapped in a `LambdaFunctionInstanceValue` whenever it needs
88
+ to be treated as a `ValueSpecification` (which is how it crosses the
89
+ serialization boundary — see below).
90
+
91
+ ### 1.3 `QueryBuilderState` — the UI form
92
+
93
+ [`QueryBuilderState`](../src/stores/QueryBuilderState.ts) is not a tree at all.
94
+ It is a set of MobX-observable, purpose-built sub-states:
95
+
96
+ | Sub-state | Holds |
97
+ | ----------------------- | ------------------------------------------------------- |
98
+ | `explorerState` | the class explorer tree + mapping model coverage |
99
+ | `filterState` | the filter tree |
100
+ | `fetchStructureState` | projection / graph-fetch columns, aggregations, sorting |
101
+ | `parametersState` | `LambdaParameterState[]` — parameters and their values |
102
+ | `constantState` | `let` constants |
103
+ | `milestoningState` | business/processing temporal parameters |
104
+ | `watermarkState` | watermark expression |
105
+ | `executionContextState` | mapping + runtime |
106
+ | `resultState` | execution results, grid config |
107
+ | `unsupportedQueryState` | the escape hatch (§6) |
108
+
109
+ The mapping from one flat expression tree to a dozen independent observable
110
+ states is lossy in one direction and reconstructive in the other. That
111
+ asymmetry is the source of most of the complexity below.
112
+
113
+ ### 1.4 Why not fewer representations?
114
+
115
+ - **Raw ⇄ metamodel** exists because persistence must survive functions the
116
+ client cannot interpret, while the UI needs resolved references.
117
+ - **Metamodel ⇄ UI state** exists because a query builder is not a tree editor.
118
+ Users think in "filters", "columns", "sorts" — not in nested
119
+ `SimpleFunctionExpression` parameters.
120
+ - **Grammar text** exists because the engine is the only thing that can parse
121
+ and print PURE.
122
+
123
+ ---
124
+
125
+ ## 2. Inbound: JSON → UI state
126
+
127
+ Entry point:
128
+ [`QueryBuilderState.initializeWithQuery(query: RawLambda, …)`](../src/stores/QueryBuilderState.ts),
129
+ which delegates to `rebuildWithQuery` and then snapshots for change detection:
130
+
131
+ ```ts
132
+ initializeWithQuery(query, defaultParameterValues?, gridConfig?): void {
133
+ this.rebuildWithQuery(query, { defaultParameterValues });
134
+ this.resetQueryResult({ gridConfig });
135
+ this.changeDetectionState.initialize(query);
136
+ this.changeHistoryState.initialize(query);
137
+ }
138
+ ```
139
+
140
+ ### 2.1 `RawLambda` → `ValueSpecification`
141
+
142
+ Inside `rebuildWithQuery`, the conversion is two graph-manager calls composed:
143
+
144
+ ```ts
145
+ const valueSpec = observe_ValueSpecification(
146
+ this.graphManagerState.graphManager.buildValueSpecification(
147
+ this.graphManagerState.graphManager.serializeRawValueSpecification(query),
148
+ this.graphManagerState.graph,
149
+ ),
150
+ this.observerContext,
151
+ );
152
+ ```
153
+
154
+ Read it inside-out — **JSON is the pivot**:
155
+
156
+ | Step | Call | Transformation |
157
+ | ---- | ------------------------------------------- | ------------------------------------------------------- |
158
+ | 1 | `serializeRawValueSpecification(rawLambda)` | `RawLambda` → `V1_RawLambda` → `PlainObject` (raw JSON) |
159
+ | 2 | `buildValueSpecification(json, graph)` | JSON → `V1_ValueSpecification` → `ValueSpecification` |
160
+ | 3 | `observe_ValueSpecification(...)` | makes the tree MobX-observable |
161
+
162
+ Step 2 is where the real work happens
163
+ ([`V1_PureGraphManager.buildValueSpecification`](../../legend-graph/src/graph-manager/protocol/pure/v1/V1_PureGraphManager.ts)):
164
+
165
+ ```ts
166
+ buildValueSpecification(json: PlainObject, graph: PureModel): ValueSpecification {
167
+ return V1_buildValueSpecification(
168
+ V1_deserializeValueSpecification(json, plugins),
169
+ new V1_GraphBuilderContextBuilder(graph, graph, extensions, logService).build(),
170
+ );
171
+ }
172
+ ```
173
+
174
+ - `V1_deserializeValueSpecification` dispatches on the `_type` discriminator —
175
+ `'func'`, `'property'`, `'var'`, `'lambda'`, `'collection'`, `'string'`,
176
+ `'packageableElementPtr'`, `'classInstance'`, … (the full set is
177
+ `V1_ValueSpecificationType` in
178
+ [`V1_ValueSpecificationSerializer.ts`](../../legend-graph/src/graph-manager/protocol/pure/v1/transformation/pureProtocol/serializationHelpers/V1_ValueSpecificationSerializer.ts)).
179
+ Output: a `V1_*` protocol tree — still just data, but typed.
180
+ - `V1_buildValueSpecification` walks that tree with a **graph builder context**
181
+ and resolves every path against the `PureModel`. `"model::Person"` stops being
182
+ a string and becomes a reference to the actual `Class`.
183
+
184
+ Note the `V1_Lambda` case specifically — the protocol `V1_Lambda` becomes a
185
+ `LambdaFunctionInstanceValue` wrapping a built `LambdaFunction`, with a **cloned
186
+ processing context** so the lambda's own variables don't leak into the enclosing
187
+ scope:
188
+
189
+ ```ts
190
+ visit_Lambda(valueSpecification: V1_Lambda): ValueSpecification {
191
+ const instanceValue = new LambdaFunctionInstanceValue();
192
+ instanceValue.values = [
193
+ V1_buildLambdaBody(
194
+ valueSpecification.body,
195
+ valueSpecification.parameters,
196
+ this.context,
197
+ this.processingContext.clone(), // clone the context for lambda
198
+ ),
199
+ ];
200
+ return instanceValue;
201
+ }
202
+ ```
203
+
204
+ This is why the top-level result is unwrapped with a `guaranteeType` before
205
+ processing:
206
+
207
+ ```ts
208
+ const compiledValueSpecification = guaranteeType(
209
+ valueSpec,
210
+ LambdaFunctionInstanceValue,
211
+ `Can't build query state: query builder only support lambda`,
212
+ );
213
+ processQueryLambdaFunction(
214
+ guaranteeNonNullable(compiledValueSpecification.values[0]), // the LambdaFunction
215
+ this,
216
+ { parameterValues: previousStateParameterValues },
217
+ );
218
+ ```
219
+
220
+ ### 2.2 `LambdaFunction` → `QueryBuilderState`
221
+
222
+ [`processQueryLambdaFunction`](../src/stores/QueryBuilderStateBuilder.ts) is
223
+ short, and its brevity hides the dispatch machinery underneath:
224
+
225
+ ```ts
226
+ export const processQueryLambdaFunction = (
227
+ lambdaFunction,
228
+ queryBuilderState,
229
+ parameterOptions?,
230
+ ) => {
231
+ if (lambdaFunction.functionType.parameters.length) {
232
+ processParameters(
233
+ lambdaFunction.functionType.parameters,
234
+ queryBuilderState,
235
+ parameterOptions,
236
+ );
237
+ }
238
+ lambdaFunction.expressionSequence.map((expression) =>
239
+ QueryBuilderValueSpecificationProcessor.process(
240
+ expression,
241
+ lambdaFunction,
242
+ queryBuilderState,
243
+ ),
244
+ );
245
+ };
246
+ ```
247
+
248
+ `QueryBuilderValueSpecificationProcessor` is a `ValueSpecificationVisitor<void>`
249
+ that walks the tree and **mutates the appropriate sub-state** as it recognises
250
+ each function.
251
+
252
+ **The traversal is inverted relative to how you read the query.** The source
253
+ comment on the processor explains this better than a paraphrase can, so it is
254
+ worth reading in full — but the essential point:
255
+
256
+ ```
257
+ Person.all()->filter(x|$x.age > 0)->project([x|$x.name], ['Name'])->sort([desc('Name')])
258
+ ```
259
+
260
+ is, structurally:
261
+
262
+ ```
263
+ sort(project(filter(all(Person), …), …), …)
264
+ ```
265
+
266
+ So the tree root is `sort`, and `getAll` is the _deepest_ node. Reading order is
267
+ `all → filter → project → sort`; traversal order is the reverse. The processor
268
+ therefore carries two pieces of context:
269
+
270
+ - `parentExpression` — the enclosing `SimpleFunctionExpression`, used to
271
+ validate usage (e.g. `desc()` is only legal inside `sort()`).
272
+ - `parentLambda` — the enclosing `LambdaFunction`, needed to resolve variables.
273
+
274
+ `processChild(...)` is how the processor recurses while supplying that context.
275
+
276
+ Recognised functions are matched against `QUERY_BUILDER_SUPPORTED_FUNCTIONS` and
277
+ routed to the owning sub-state — `filter()` populates `filterState`, `project()`
278
+ and `groupBy()` populate `fetchStructureState`, `let` statements populate
279
+ `constantState`, and so on. Anything unrecognised takes the path in §6.
280
+
281
+ Execution-context wrappers are recognised too, so a lambda saved with its
282
+ context embedded (§3.1, step 5) can be reopened in form mode: `from()` is
283
+ consumed into `executionContextState`, and `with(body, dataProduct)` — emitted
284
+ for Data Product-backed contexts — is processed by stepping straight into its
285
+ first parameter, the query body.
286
+
287
+ Finally `fetchStructureState.initializeWithQuery()` runs, letting the
288
+ fetch-structure implementation do its own post-processing once the whole tree
289
+ has been consumed.
290
+
291
+ ---
292
+
293
+ ## 3. Outbound: UI state → JSON
294
+
295
+ Entry point: [`QueryBuilderState.buildQuery()`](../src/stores/QueryBuilderState.ts).
296
+
297
+ ```ts
298
+ buildQuery(options?: { keepSourceInformation: boolean }): RawLambda {
299
+ if (!this.isQuerySupported) { /* … see §6 … */ }
300
+ return buildRawLambdaFromLambdaFunction(
301
+ buildLambdaFunction(this, {
302
+ keepSourceInformation: Boolean(options?.keepSourceInformation),
303
+ useTypedRelationFunctions: this.isFetchStructureTyped,
304
+ }),
305
+ this.graphManagerState,
306
+ );
307
+ }
308
+ ```
309
+
310
+ ### 3.1 `QueryBuilderState` → `LambdaFunction`
311
+
312
+ [`buildLambdaFunction`](../src/stores/QueryBuilderValueSpecificationBuilder.ts)
313
+ assembles the expression sequence **in a fixed order**, because the order of
314
+ composition _is_ the semantics of the query:
315
+
316
+ 1. **Source.** A `Class` source produces `getAll()` /
317
+ `getAllVersions()` / `getAllVersionsInRange()` depending on
318
+ `queryBuilderState.getAllFunction` and the class's milestoning stereotype
319
+ (milestoned classes get their temporal parameters injected here by
320
+ `milestoningState`). A non-class source produces an `AccessorInstanceValue`.
321
+ 2. **Watermark** — `buildWatermarkExpression(watermarkState, lambdaFunction)`.
322
+ 3. **Filter** — `buildFilterExpression(filterState, lambdaFunction)`.
323
+ 4. **Fetch structure** — `buildFetchStructure(...)`; projection columns,
324
+ aggregations, sorting, graph-fetch trees.
325
+ 5. **Execution context** — `buildExecutionContextExpression(...)`. Whether
326
+ anything is emitted depends on the kind of `executionContextState`:
327
+
328
+ - `QueryBuilderExternalExecutionContextState` (the default) — **nothing**
329
+ is added. Mapping and runtime travel as separate fields on the execute
330
+ input instead (§4.1).
331
+ - `QueryBuilderEmbeddedFromExecutionContextState` — the body is wrapped in
332
+ `->from(mapping, runtime)` by `buildExecutionContextState`.
333
+
334
+ `reconcileExecutionContextState()` swaps between the two according to
335
+ `requiresEmbeddedExecutionContext`, which is true for typed/relation fetch
336
+ structures, accessor sources, data products, and data spaces whose execution
337
+ context has a Data Product mapping provider. Those last two override
338
+ `buildExecutionContextExpression` again to emit
339
+ `->with(dataProduct)->from(runtime)` (`attachWithFromQuery`), because their
340
+ mapping comes from the Data Product rather than the project.
341
+
342
+ Skippable via `skipExecutionContext` (see §3.3).
343
+
344
+ 6. **Constants** — `let` expressions are **prepended** to the expression
345
+ sequence.
346
+ 7. **Parameters** — assigned to `lambdaFunction.functionType.parameters`.
347
+
348
+ Each step takes the `lambdaFunction` built so far and wraps it, so the tree
349
+ grows outward from `getAll()`.
350
+
351
+ ### 3.2 `LambdaFunction` → `RawLambda`
352
+
353
+ [`buildRawLambdaFromLambdaFunction`](../../legend-graph/src/graph-manager/helpers/ValueSpecificationGraphManagerHelper.ts):
354
+
355
+ ```ts
356
+ export const buildRawLambdaFromLambdaFunction = (
357
+ lambdaFunction,
358
+ graphManagerState,
359
+ ): RawLambda => {
360
+ const lambdaFunctionInstanceValue = new LambdaFunctionInstanceValue();
361
+ lambdaFunctionInstanceValue.values = [lambdaFunction];
362
+ return guaranteeType(
363
+ graphManagerState.graphManager.transformValueSpecToRawValueSpec(
364
+ lambdaFunctionInstanceValue,
365
+ graphManagerState.graph,
366
+ ),
367
+ RawLambda,
368
+ );
369
+ };
370
+ ```
371
+
372
+ And `transformValueSpecToRawValueSpec` is, again, **JSON as the pivot**:
373
+
374
+ ```ts
375
+ transformValueSpecToRawValueSpec(valueSpecification, graph): RawValueSpecification {
376
+ const json = this.serializeValueSpecification(valueSpecification); // metamodel → V1 → JSON
377
+ return this.buildRawValueSpecification(json, graph); // JSON → V1_RawLambda → RawLambda
378
+ }
379
+ ```
380
+
381
+ - `serializeValueSpecification` runs `V1_transformRootValueSpecification`
382
+ (metamodel → `V1_*` protocol, dropping resolved references back down to
383
+ paths) then `V1_serializeValueSpecification` (protocol → JSON).
384
+ - `buildRawValueSpecification` deserializes that same JSON as a
385
+ `V1_RawValueSpecification` and builds a `RawLambda` — whose `body` and
386
+ `parameters` are the JSON sub-objects, untouched.
387
+
388
+ So the outbound path is the exact mirror of §2.1. In both directions the two
389
+ worlds only ever meet as plain JSON; there is no direct `LambdaFunction ⇄
390
+ RawLambda` converter, by design.
391
+
392
+ ### 3.3 The three build variants
393
+
394
+ | Method | Used for | Difference |
395
+ | --------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
396
+ | `buildQuery()` | general use, text mode, compile | the full lambda |
397
+ | `buildQueryForPersistence()` | saving to the query store | delegates to `buildQuery()` by default; overridden by `DataSpaceQueryBuilderState`, `DataProductQueryBuilderState`, and `AccessorQueryBuilderState` to build without the execution context (`skipExecutionContext`) — the context is persisted separately on `Query.executionContext`, so embedding it in the lambda too would duplicate it |
398
+ | `resultState.buildExecutionRawLambda()` | running the query | passes `isBuildingExecutionQuery: true`, which routes parameters through `buildExecutionQueryFromLambdaFunction` |
399
+
400
+ That last one matters. For execution, parameters whose values are **function
401
+ calls** cannot stay as lambda parameters — the engine would have nothing to bind
402
+ them to. `buildExecutionQueryFromLambdaFunction`
403
+ ([`LambdaParameterState.ts`](../src/stores/shared/LambdaParameterState.ts))
404
+ handles this by:
405
+
406
+ 1. removing those parameters from `functionType.parameters`, and
407
+ 2. prepending `let` statements that assign their values.
408
+
409
+ Plain-valued parameters are left as parameters and supplied separately as
410
+ `parameterValues` on the execution input.
411
+
412
+ ---
413
+
414
+ ## 4. How the lambda reaches the engine
415
+
416
+ Everything the client sends the engine carries a **`V1_RawLambda`**, never a
417
+ `LambdaFunction`. The engine does its own parsing and compilation; shipping the
418
+ resolved metamodel would be both redundant and lossy.
419
+
420
+ ### 4.1 Execution
421
+
422
+ [`V1_ExecuteInput`](../../legend-graph/src/graph-manager/protocol/pure/v1/engine/execution/V1_ExecuteInput.ts)
423
+ is the payload:
424
+
425
+ ```ts
426
+ export class V1_ExecuteInput {
427
+ clientVersion: string | undefined;
428
+ /**
429
+ * Studio does not process value specification, they are left in raw JSON form
430
+ * @discrepancy model
431
+ */
432
+ function!: V1_RawLambda;
433
+ mapping: string | undefined;
434
+ model!: V1_PureModelContext;
435
+ runtime: V1_Runtime | undefined;
436
+ context!: V1_RawExecutionContext;
437
+ parameterValues: V1_ParameterValue[] = [];
438
+ }
439
+ ```
440
+
441
+ Assembled by `createExecutionInputWithPureModelContext` in
442
+ [`V1_PureGraphManager.ts`](../../legend-graph/src/graph-manager/protocol/pure/v1/V1_PureGraphManager.ts):
443
+
444
+ - `V1_transformRawLambda(lambda, …)` — `RawLambda` → `V1_RawLambda`.
445
+ - `model` — the `V1_PureModelContext`, chosen in `createExecutionInput`:
446
+
447
+ ```ts
448
+ let context: V1_PureModelContext = graph.origin
449
+ ? this.buildPureModelSDLCPointer(graph.origin, undefined) // GAV pointer
450
+ : this.getFullGraphModelData(graph); // the whole model inline
451
+ ```
452
+
453
+ When the graph carries an `origin` — which is exactly what the minimal-graph
454
+ build stamps on it (`{ origin: new LegendSDLC(groupId, artifactId, version) }`)
455
+ — only a **pointer** is sent and the engine resolves the model itself. This is
456
+ what makes the minimal graph viable end-to-end: the client never held the
457
+ whole model, and never needs to send it.
458
+
459
+ Elements that exist only client-side (`options.floatingExecutionElements` —
460
+ e.g. the adhoc Lakehouse runtimes registered as `_internal_`) are appended as
461
+ a `V1_PureModelContextData` and combined via `V1_PureModelContextCombination`,
462
+ so a pointer-based context can still carry them.
463
+
464
+ - `mapping` / `runtime` — sent as separate fields…
465
+
466
+ …**unless** `options.forceFromExpression` is set. In that case the manager
467
+ rewrites the lambda body to wrap it in a `from()` expression and clears the
468
+ separate fields:
469
+
470
+ ```ts
471
+ __fromExpression = (
472
+ mapping: string | undefined,
473
+ runtime: string,
474
+ body: object,
475
+ ): object => ({
476
+ _type: 'func',
477
+ function: 'from',
478
+ parameters: [
479
+ body,
480
+ mapping ? { _type: 'packageableElementPtr', fullPath: mapping } : undefined,
481
+ { _type: 'packageableElementPtr', fullPath: runtime },
482
+ ].filter(isNonNullable),
483
+ });
484
+ ```
485
+
486
+ Note this is hand-built **raw JSON**, not metamodel construction — legitimate
487
+ precisely because `V1_RawLambda.body` is opaque. Multi-expression bodies (i.e.
488
+ lambdas with `let` statements) get only their **last** expression wrapped, since
489
+ that is the one producing the result.
490
+
491
+ ### 4.2 Compilation / type-checking
492
+
493
+ `compileQuery()` does not use a separate compile endpoint — it asks the engine
494
+ for the lambda's return type and treats success as "compiles":
495
+
496
+ ```ts
497
+ yield this.graphManagerState.graphManager.getLambdaReturnType(
498
+ this.buildQuery({ keepSourceInformation: true }),
499
+ this.graphManagerState.graph,
500
+ { keepSourceInformation: true },
501
+ );
502
+ ```
503
+
504
+ `keepSourceInformation: true` is deliberate. Source information maps expressions
505
+ back to positions, which is what lets a compilation error be **pinned to the
506
+ right element in form mode** rather than reported as an opaque failure. It is
507
+ pruned everywhere it isn't needed (`pruneSourceInformation`), since it
508
+ substantially inflates the payload.
509
+
510
+ `getLambdaRelationType` is the equivalent for relation-typed fetch structures.
511
+
512
+ ### 4.3 Grammar transformation
513
+
514
+ | Direction | Graph manager API | Engine |
515
+ | ------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
516
+ | text → lambda | `pureCodeToLambda(code, lambdaId, options)` | `transformCodeToLambda`; result becomes `new RawLambda(result.parameters, result.body)` |
517
+ | lambda → text | `lambdaToPureCode(lambda, pretty)` / `lambdasToPureCode(map, pretty)` | `transformLambdaToCode(s)` |
518
+ | value specs → text | `valueSpecificationsToPureCode(map, pretty)` | serializes each via `serializeValueSpecification` first |
519
+
520
+ The client never parses or prints PURE itself.
521
+
522
+ ---
523
+
524
+ ## 5. Grammar (text) mode
525
+
526
+ [`QueryBuilderTextEditorState`](../src/stores/QueryBuilderTextEditorState.ts)
527
+ implements the "edit as text" escape hatch, and it is a complete lap of the
528
+ cycle.
529
+
530
+ **Opening the modal** (`openModal`):
531
+
532
+ ```ts
533
+ const rawLambda = this.queryBuilderState.buildQuery(); // §3
534
+ if (mode === TEXT) {
535
+ this.setQueryRawLambdaState(new QueryBuilderRawLambdaState(rawLambda));
536
+ }
537
+ if (mode === JSON) {
538
+ this.setLambdaJson(
539
+ JSON.stringify(
540
+ pruneSourceInformation(
541
+ graphManager.serializeRawValueSpecification(rawLambda),
542
+ ),
543
+ null,
544
+ DEFAULT_TAB_SIZE,
545
+ ),
546
+ );
547
+ }
548
+ ```
549
+
550
+ JSON mode is **read-only** — it displays the exact protocol payload, source
551
+ information stripped.
552
+
553
+ **While open**, `convertLambdaObjectToGrammarString` calls `lambdasToPureCode`
554
+ to render the text; `convertLambdaGrammarStringToObject` calls `pureCodeToLambda`
555
+ to parse edits back, capturing `ParserError` for inline display.
556
+
557
+ **Closing the modal** (`closeModal`) — the round trip completes:
558
+
559
+ ```ts
560
+ yield flowResult(this.convertLambdaGrammarStringToObject()); // text → RawLambda
561
+ if (this.parserError) {
562
+ notifyError(`Can't parse query. Please fix error before closing: …`);
563
+ } else {
564
+ this.queryBuilderState.rebuildWithQuery(this.rawLambdaState.lambda, {
565
+ // → §2
566
+ preserveParameterValues: true,
567
+ preserveResult: true,
568
+ });
569
+ }
570
+ ```
571
+
572
+ A parse error **blocks closing**; the user cannot escape text mode with a query
573
+ the client cannot represent. But a query that parses and yet isn't
574
+ _recognisable_ to the processor will close fine and land in the unsupported
575
+ state below — those are different failures.
576
+
577
+ ---
578
+
579
+ ## 6. When the round trip fails
580
+
581
+ `rebuildWithQuery` wraps the entire inbound conversion in a `try`/`catch`. On
582
+ any failure — deserialization, unresolvable reference, unrecognised function
583
+ shape — it degrades rather than throwing:
584
+
585
+ ```ts
586
+ } catch (error) {
587
+ logService.error(LogEvent.create(QUERY_BUILDER_EVENT.UNSUPPORTED_QUERY_LAUNCH), error);
588
+ this.resetQueryResult({ preserveResult: options?.preserveResult });
589
+ this.resetQueryContent();
590
+ this.unsupportedQueryState.setLambdaError(error);
591
+ this.unsupportedQueryState.setRawLambda(query); // keep the original, verbatim
592
+ this.setSourceElement(undefined);
593
+ const parameters = buildLambdaVariableExpressions(query, this.graphManagerState)
594
+ .map((param) => observe_ValueSpecification(param, this.observerContext))
595
+ .filter(filterByType(VariableExpression));
596
+ processParameters(parameters, this, { parameterValues: previousStateParameterValues });
597
+ }
598
+ ```
599
+
600
+ Two things survive the failure:
601
+
602
+ - **The original `RawLambda`**, stored on `unsupportedQueryState`. The user gets
603
+ a text view instead of the form, and can still run and save the query — it is
604
+ never rewritten or lost.
605
+ - **The parameters**, recovered independently via
606
+ `buildLambdaVariableExpressions`, which converts only
607
+ `rawLambda.parameters[]` (not the body). Parameters are structurally simple
608
+ enough to build even when the body is not, so the parameter panel keeps
609
+ working.
610
+
611
+ `buildQuery()` mirrors this on the way out — when `!isQuerySupported`, it
612
+ returns the stored raw lambda with freshly serialized parameters spliced in,
613
+ rather than trying to rebuild from state that was never populated:
614
+
615
+ ```ts
616
+ if (!this.isQuerySupported) {
617
+ const parameters = this.parametersState.parameterStates.map((e) =>
618
+ this.graphManagerState.graphManager.serializeValueSpecification(
619
+ e.parameter,
620
+ ),
621
+ );
622
+ this.unsupportedQueryState.setRawLambda(
623
+ new RawLambda(parameters, this.unsupportedQueryState.rawLambda?.body), // body untouched
624
+ );
625
+ return guaranteeNonNullable(this.unsupportedQueryState.rawLambda);
626
+ }
627
+ ```
628
+
629
+ There is also a node-level escape hatch: `INTERNAL__UnknownValueSpecification`
630
+ holds an unrecognised sub-tree as `readonly content: PlainObject`, letting a
631
+ _mostly_ supported query keep one opaque fragment (e.g. a derivation expression
632
+ inside `project()`) without failing wholesale.
633
+
634
+ **Design principle:** the query builder will refuse to _edit_ a query it does
635
+ not understand, but it will never silently _alter_ one.
636
+
637
+ ---
638
+
639
+ ## 7. Change detection
640
+
641
+ [`QueryBuilderChangeDetectionState`](../src/stores/QueryBuilderChangeDetectionState.ts)
642
+ answers "has the user modified the query?" — and notably does **not** do it by
643
+ rebuilding and comparing lambdas:
644
+
645
+ ```ts
646
+ initialize(initialQuery: RawLambda): void {
647
+ this.hashCodeSnapshot = this.querybuilderState.hashCode; // hash of the UI STATE
648
+ this.querySnapshot = initialQuery; // the raw lambda, for diffing
649
+ }
650
+
651
+ get hasChanged(): boolean {
652
+ return this.querybuilderState.hashCode !== this.hashCodeSnapshot;
653
+ }
654
+ ```
655
+
656
+ The comparison is on `QueryBuilderState.hashCode` — a hash over the observable
657
+ sub-states (see `QueryBuilderStateHashUtils.ts`), not over the serialized
658
+ lambda. This is a deliberate performance trade: checking for changes on every
659
+ keystroke by running the full §3 pipeline (build tree → transform → serialize)
660
+ would be far too expensive, whereas the state hash is cheap and MobX-memoized.
661
+
662
+ `querySnapshot` retains the original `RawLambda` separately, so the diff view
663
+ (`QueryBuilderDiffViewState`) can show a real before/after in grammar form.
664
+
665
+ One consequence worth knowing: because the hash covers UI state rather than the
666
+ emitted lambda, two states that serialize to equivalent lambdas but differ in
667
+ some non-semantic way can register as "changed". This errs toward prompting the
668
+ user to save — the safe direction.
669
+
670
+ ---
671
+
672
+ ## 8. Summary
673
+
674
+ ```
675
+ PERSISTENCE (Query.content) ◄──────────┐
676
+ │ │
677
+ ▼ │
678
+ ┌────────────────── RawLambda ─────────────────────┐ │
679
+ │ (opaque JSON) │ │
680
+ │ │ │ │
681
+ │ serializeRawValueSpecification │ buildRawValueSpecification
682
+ │ ▼ │ │
683
+ │ raw JSON ────────────────────► │ │
684
+ │ │ ▲ │ │
685
+ │ buildValueSpecification │ serializeValueSpecification
686
+ │ (resolves against PureModel) │ │ │
687
+ │ ▼ │ │ │
688
+ │ LambdaFunction ────────┘ │ │
689
+ │ │ ▲ │ │
690
+ │ processQueryLambdaFunction │ buildLambdaFunction
691
+ │ (visitor → sub-states) │ (ordered assembly)
692
+ │ ▼ │ │ │
693
+ │ QueryBuilderState ──┘ │ │
694
+ └────────────────────────────────────────────────────┘ │
695
+ │ │
696
+ └──────► V1_ExecuteInput.function (V1_RawLambda) ──► ENGINE
697
+ ```
698
+
699
+ Three invariants hold throughout:
700
+
701
+ 1. **JSON is the only bridge** between the raw and metamodel worlds, in both
702
+ directions. There is no direct converter, and adding one would bypass the
703
+ protocol layer that makes version tolerance work.
704
+ 2. **The engine only ever sees `V1_RawLambda`.** The resolved metamodel is a
705
+ client-side convenience; it never crosses the wire.
706
+ 3. **An unparseable query is preserved, not repaired.** Every failure path keeps
707
+ the user's original bytes.