@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.
- package/README.md +6 -0
- package/docs/query-lambda-round-trip.md +707 -0
- package/lib/__lib__/QueryBuilderEvent.d.ts +18 -1
- package/lib/__lib__/QueryBuilderEvent.d.ts.map +1 -1
- package/lib/__lib__/QueryBuilderEvent.js +26 -1
- package/lib/__lib__/QueryBuilderEvent.js.map +1 -1
- package/lib/__lib__/QueryBuilderTelemetryHelper.d.ts +42 -0
- package/lib/__lib__/QueryBuilderTelemetryHelper.d.ts.map +1 -1
- package/lib/__lib__/QueryBuilderTelemetryHelper.js +56 -0
- package/lib/__lib__/QueryBuilderTelemetryHelper.js.map +1 -1
- package/lib/components/QueryBuilder.d.ts.map +1 -1
- package/lib/components/QueryBuilder.js +74 -9
- package/lib/components/QueryBuilder.js.map +1 -1
- package/lib/data-access-overview.css +1 -1
- package/lib/index.css +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js.map +1 -1
- package/lib/package.json +1 -1
- package/lib/stores/QueryBuilderState.d.ts +8 -0
- package/lib/stores/QueryBuilderState.d.ts.map +1 -1
- package/lib/stores/QueryBuilderState.js +10 -1
- package/lib/stores/QueryBuilderState.js.map +1 -1
- package/package.json +10 -10
- package/src/__lib__/QueryBuilderEvent.ts +28 -1
- package/src/__lib__/QueryBuilderTelemetryHelper.ts +158 -0
- package/src/components/QueryBuilder.tsx +126 -15
- package/src/index.ts +1 -0
- package/src/stores/QueryBuilderState.ts +21 -0
|
@@ -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.
|