@objectstack/service-analytics 16.1.0 → 17.0.0-rc.1
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/CHANGELOG.md +2656 -0
- package/dist/index.cjs +1192 -166
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +386 -24
- package/dist/index.d.ts +386 -24
- package/dist/index.js +1185 -161
- package/dist/index.js.map +1 -1
- package/package.json +8 -5
package/dist/index.d.ts
CHANGED
|
@@ -119,6 +119,7 @@ declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolve
|
|
|
119
119
|
* — reading an object's field map and fetching id→label pairs — are injected via
|
|
120
120
|
* {@link DimensionLabelDeps} so this module stays free of any engine dependency.
|
|
121
121
|
*/
|
|
122
|
+
|
|
122
123
|
/** The minimal field shape this resolver needs. */
|
|
123
124
|
interface FieldMetaLite {
|
|
124
125
|
type?: string;
|
|
@@ -138,9 +139,90 @@ interface DimensionLabelDeps {
|
|
|
138
139
|
* Fetch a map of `id → display label` for the given ids of a target object.
|
|
139
140
|
* The implementation chooses the target's display field. Returning an empty
|
|
140
141
|
* map (e.g. no display field, no data access) leaves the ids unresolved.
|
|
142
|
+
*
|
|
143
|
+
* `scope` (ADR-0021 D-C, #3602) is the TARGET object's own read scope — the
|
|
144
|
+
* RLS/tenant `FilterCondition` the implementation must AND into the label
|
|
145
|
+
* lookup so this never reveals a related record the target object's RLS would
|
|
146
|
+
* hide. The label lookup is a per-record read (`group by id`) dressed as an
|
|
147
|
+
* aggregate; without the scope it leaks display names whenever the referenced
|
|
148
|
+
* object is more restricted than the base object whose rows carry the id.
|
|
149
|
+
* `undefined` means "no scope for this object" (global table / unrestricted
|
|
150
|
+
* caller) — the same contract as the read-scope provider.
|
|
151
|
+
*
|
|
152
|
+
* `context` is the request's ExecutionContext — the SECOND belt on the same
|
|
153
|
+
* read (#3602). `scope` is the analytics layer's own predicate; forwarding the
|
|
154
|
+
* context lets the ENGINE's middleware chain scope this per-record read
|
|
155
|
+
* itself, so it stays scoped even if a caller ever reaches this hook without
|
|
156
|
+
* a resolved `scope`. Implementations bridging to an ObjectQL engine MUST
|
|
157
|
+
* forward it; a bridge with nowhere to put it may ignore it.
|
|
141
158
|
*/
|
|
142
|
-
fetchRecordLabels(targetObject: string, ids: unknown[]): Promise<Map<unknown, string>>;
|
|
159
|
+
fetchRecordLabels(targetObject: string, ids: unknown[], scope?: Record<string, unknown>, context?: ExecutionContext): Promise<Map<unknown, string>>;
|
|
143
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the TARGET object's read scope for a label lookup (#3602). Returns the
|
|
163
|
+
* object's RLS/tenant `FilterCondition`, `null`/`undefined` when the object is
|
|
164
|
+
* unscoped, or a rejected promise when the scope cannot be resolved — in which
|
|
165
|
+
* case the resolver fails CLOSED (skips that dimension's labels) rather than
|
|
166
|
+
* fetching unscoped names.
|
|
167
|
+
*/
|
|
168
|
+
type LabelScopeResolver = (targetObject: string) => Promise<Record<string, unknown> | null | undefined> | Record<string, unknown> | null | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Sort-key label resolution for `DatasetSelection.order` (#3680).
|
|
171
|
+
*
|
|
172
|
+
* The executor sorts the assembled grid BEFORE `queryDataset` rewrites stored
|
|
173
|
+
* dimension values into display labels, so an order key naming a `select` or
|
|
174
|
+
* `lookup`/`master_detail` dimension used to sort by the stored value / FK id —
|
|
175
|
+
* an order that presents as arbitrary once the labels render. This hook hands
|
|
176
|
+
* the executor JUST the value→label mapping for such a dimension so it can sort
|
|
177
|
+
* by what the user will actually read, while the rows keep their raw values
|
|
178
|
+
* (drill metadata depends on them) and ordering + windowing stay one adjacent
|
|
179
|
+
* step. The executor stays engine-free: it sees this interface, never the
|
|
180
|
+
* engine behind it.
|
|
181
|
+
*/
|
|
182
|
+
interface OrderLabelResolver {
|
|
183
|
+
/**
|
|
184
|
+
* Whether the dimension's stored value differs from the label it renders as
|
|
185
|
+
* (`select` options, `lookup`/`master_detail` FK ids). Synchronous — the
|
|
186
|
+
* executor consults it when deciding whether the window may be pushed into
|
|
187
|
+
* SQL, before any query runs.
|
|
188
|
+
*/
|
|
189
|
+
isLabelBearing(dimension: string): boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Map the given raw stored values of one dimension to display labels.
|
|
192
|
+
* Values missing from the map sort by their raw form — the same thing the
|
|
193
|
+
* user will see rendered for them.
|
|
194
|
+
*/
|
|
195
|
+
resolveLabels(dimension: string, values: unknown[]): Promise<Map<unknown, string> | undefined>;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build the executor's {@link OrderLabelResolver} from the dataset's dimension
|
|
199
|
+
* list and the injected label capabilities. Mirrors the classification in
|
|
200
|
+
* {@link resolveDimensionLabels}: a dimension is label-bearing when its field
|
|
201
|
+
* carries select `options` or is a lookup/master_detail with a `reference`.
|
|
202
|
+
*
|
|
203
|
+
* - `select` resolves from field metadata — no query at all.
|
|
204
|
+
* - `lookup`/`master_detail` costs ONE batched id→name read over the distinct
|
|
205
|
+
* grouped values, scoped to the REFERENCED object's own RLS (#3602). Fail
|
|
206
|
+
* closed: an unresolvable scope degrades to sorting by the stored id rather
|
|
207
|
+
* than fetching unscoped — consistent with the display pass, which renders
|
|
208
|
+
* the raw id in that case too.
|
|
209
|
+
*/
|
|
210
|
+
declare function createOrderLabelResolver(baseObject: string, dims: Array<{
|
|
211
|
+
name: string;
|
|
212
|
+
field: string;
|
|
213
|
+
}>, deps: DimensionLabelDeps, resolveScope?: LabelScopeResolver, context?: ExecutionContext): OrderLabelResolver;
|
|
214
|
+
/**
|
|
215
|
+
* Wrap a {@link DimensionLabelDeps} so repeated `fetchRecordLabels` calls
|
|
216
|
+
* within ONE request fetch each id at most once. A selection that sorts by a
|
|
217
|
+
* lookup dimension resolves labels twice — once PRE-window for the sort keys
|
|
218
|
+
* (#3680, over the full grid's ids), once post-window for display (a subset of
|
|
219
|
+
* the same ids) — so with this cache the display pass costs no extra query.
|
|
220
|
+
*
|
|
221
|
+
* Per-request only: entries are keyed by target object alone, which is safe
|
|
222
|
+
* because an object's read scope is constant within one request. Never share
|
|
223
|
+
* an instance across requests.
|
|
224
|
+
*/
|
|
225
|
+
declare function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDeps;
|
|
144
226
|
/** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
|
|
145
227
|
type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
146
228
|
/**
|
|
@@ -151,13 +233,22 @@ type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
|
151
233
|
* (row key = `name`)
|
|
152
234
|
* @param rows - result rows, mutated in place
|
|
153
235
|
* @param deps - injected runtime capabilities
|
|
236
|
+
* @param resolveScope - (ADR-0021 D-C, #3602) resolves the referenced object's
|
|
237
|
+
* own read scope for a lookup/master_detail dimension's label fetch. When it
|
|
238
|
+
* throws, that dimension's labels are SKIPPED (fail-closed — the raw id renders
|
|
239
|
+
* instead) rather than fetched unscoped. Omit when no read-scope provider is
|
|
240
|
+
* configured (labels then fetch unscoped, as before — no security in play).
|
|
241
|
+
* @param context - the request's ExecutionContext, forwarded to
|
|
242
|
+
* {@link DimensionLabelDeps.fetchRecordLabels} so the engine's own middleware
|
|
243
|
+
* scopes the per-record label read too — the second belt beside `resolveScope`
|
|
244
|
+
* (#3602)
|
|
154
245
|
*/
|
|
155
246
|
declare function resolveDimensionLabels(baseObject: string, dims: Array<{
|
|
156
247
|
name: string;
|
|
157
248
|
field: string;
|
|
158
249
|
type?: string;
|
|
159
250
|
dateGranularity?: DateGranularity | string;
|
|
160
|
-
}>, rows: Record<string, unknown>[], deps: DimensionLabelDeps): Promise<void>;
|
|
251
|
+
}>, rows: Record<string, unknown>[], deps: DimensionLabelDeps, resolveScope?: LabelScopeResolver, context?: ExecutionContext): Promise<void>;
|
|
161
252
|
/**
|
|
162
253
|
* Pick the display field for an object from its field map, by convention:
|
|
163
254
|
* an explicit `name`/`title`/`label` field, else the first text-like field.
|
|
@@ -195,6 +286,15 @@ interface AnalyticsServiceConfig {
|
|
|
195
286
|
alias: string;
|
|
196
287
|
}>;
|
|
197
288
|
filter?: Record<string, unknown>;
|
|
289
|
+
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
290
|
+
timezone?: string;
|
|
291
|
+
/**
|
|
292
|
+
* ADR-0021 D-C (#3602) — the request's ExecutionContext. Bridges MUST
|
|
293
|
+
* forward it to `engine.aggregate` so engine-side RLS applies; see
|
|
294
|
+
* `StrategyContext.executeAggregate` for why this is a second belt rather
|
|
295
|
+
* than a replacement for `getReadScope`.
|
|
296
|
+
*/
|
|
297
|
+
context?: ExecutionContext;
|
|
198
298
|
}) => Promise<Record<string, unknown>[]>;
|
|
199
299
|
/**
|
|
200
300
|
* Fallback IAnalyticsService (e.g. MemoryAnalyticsService).
|
|
@@ -236,6 +336,14 @@ interface AnalyticsServiceConfig {
|
|
|
236
336
|
* `StrategyContext.coerceTemporalFilterValue` for the full rationale.
|
|
237
337
|
*/
|
|
238
338
|
coerceTemporalFilterValue?: (objectName: string, fieldName: string, value: unknown) => unknown;
|
|
339
|
+
/**
|
|
340
|
+
* Normalise the COLUMN side of the same comparison to that storage form — the
|
|
341
|
+
* other half of the fix, needed because a SQLite `Field.datetime` holds both an
|
|
342
|
+
* INTEGER epoch (a `Date` write) and ISO TEXT (a REST/JSON write, a `NOW()`
|
|
343
|
+
* default) at once, so coercing only the comparand matches one of them and
|
|
344
|
+
* misses the other (#3912). See `StrategyContext.coerceTemporalFilterColumn`.
|
|
345
|
+
*/
|
|
346
|
+
coerceTemporalFilterColumn?: (objectName: string, fieldName: string, columnSql: string) => string;
|
|
239
347
|
/**
|
|
240
348
|
* ADR-0062 D6 — report whether an object is federated (external datasource).
|
|
241
349
|
* Threaded into the StrategyContext so `NativeSQLStrategy` declines external
|
|
@@ -244,6 +352,24 @@ interface AnalyticsServiceConfig {
|
|
|
244
352
|
* `StrategyContext.isExternalObject`.
|
|
245
353
|
*/
|
|
246
354
|
isExternalObject?: (objectName: string) => boolean;
|
|
355
|
+
/**
|
|
356
|
+
* [#3867] Is `name` a registered object in this kernel's schema registry?
|
|
357
|
+
*
|
|
358
|
+
* Consulted by {@link AnalyticsService.ensureCube} on the auto-inference
|
|
359
|
+
* path only. When no Cube is registered under the queried name, the service
|
|
360
|
+
* infers a minimal one whose `sql` IS that name — the intended "metric over
|
|
361
|
+
* an object" path (an `object-metric` KPI widget queries `crm_account`
|
|
362
|
+
* without anyone authoring a Cube). Without this hook that inference accepts
|
|
363
|
+
* ANY string, so an arbitrary physical table name reached the driver: the
|
|
364
|
+
* analytics-side twin of the data-path gap closed in #3770.
|
|
365
|
+
*
|
|
366
|
+
* Optional, and absence means "skip the check" — same tiering as #3770's
|
|
367
|
+
* `assertObjectRegistered`: with no registry to consult the question cannot
|
|
368
|
+
* be answered, and failing closed would break every embedding that runs
|
|
369
|
+
* analytics without a data engine. The production bridge in `plugin.ts`
|
|
370
|
+
* always wires it.
|
|
371
|
+
*/
|
|
372
|
+
isRegisteredObject?: (name: string) => boolean;
|
|
247
373
|
/**
|
|
248
374
|
* ADR-0021 — optional object-graph resolver used when compiling datasets:
|
|
249
375
|
* `(baseObject, relationshipName) => relatedObjectName | undefined`. When
|
|
@@ -315,6 +441,10 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
315
441
|
private readonly labelResolver?;
|
|
316
442
|
/** ADR-0037 P3: pending-seed row resolver for draft data preview. */
|
|
317
443
|
private readonly draftRowsResolver?;
|
|
444
|
+
/** [#3867] Schema-registry probe gating cube auto-inference. */
|
|
445
|
+
private readonly isRegisteredObject?;
|
|
446
|
+
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
447
|
+
private warnedNoObjectRegistry;
|
|
318
448
|
readonly cubeRegistry: CubeRegistry;
|
|
319
449
|
private readonly logger;
|
|
320
450
|
constructor(config?: AnalyticsServiceConfig);
|
|
@@ -389,6 +519,20 @@ declare class AnalyticsService implements IAnalyticsService {
|
|
|
389
519
|
* strategies pick the right aggregation function and field.
|
|
390
520
|
*/
|
|
391
521
|
private ensureCube;
|
|
522
|
+
/**
|
|
523
|
+
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
524
|
+
* Cube may only be inferred into one if it is a registered object.
|
|
525
|
+
*
|
|
526
|
+
* Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
|
|
527
|
+
* answers "no such cube" instead of letting the name reach the driver as a
|
|
528
|
+
* table and surfacing whatever the driver says about it. The message names
|
|
529
|
+
* both ways the request could be made valid, because from here the two are
|
|
530
|
+
* genuinely indistinguishable: register a Cube, or register the object.
|
|
531
|
+
*
|
|
532
|
+
* Skips when `isRegisteredObject` was not supplied — see the config field's
|
|
533
|
+
* doc for why that tier is a deliberate stand-down and not a hole.
|
|
534
|
+
*/
|
|
535
|
+
private assertInferableCube;
|
|
392
536
|
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
393
537
|
private inferCubeFromQuery;
|
|
394
538
|
/**
|
|
@@ -427,6 +571,12 @@ interface AnalyticsServicePluginOptions {
|
|
|
427
571
|
filter?: Record<string, unknown>;
|
|
428
572
|
/** Reference timezone (IANA) for date bucketing — ADR-0053 Phase 2. */
|
|
429
573
|
timezone?: string;
|
|
574
|
+
/**
|
|
575
|
+
* ADR-0021 D-C (#3602) — the request's ExecutionContext. A custom bridge
|
|
576
|
+
* MUST forward it to its engine so engine-side RLS applies; dropping it is
|
|
577
|
+
* what made the built-in bridge fall open in #3597.
|
|
578
|
+
*/
|
|
579
|
+
context?: ExecutionContext;
|
|
430
580
|
}) => Promise<Record<string, unknown>[]>;
|
|
431
581
|
/**
|
|
432
582
|
* ADR-0021 D-C — context-aware per-object read scope (tenant + RLS). The
|
|
@@ -475,6 +625,11 @@ interface AnalyticsServicePluginOptions {
|
|
|
475
625
|
*/
|
|
476
626
|
declare class AnalyticsServicePlugin implements Plugin {
|
|
477
627
|
name: string;
|
|
628
|
+
/**
|
|
629
|
+
* Services init() registers on every path (ADR-0116, #4131) — lets the
|
|
630
|
+
* kernel name this plugin when a consumer requires one before it inits.
|
|
631
|
+
*/
|
|
632
|
+
providesServices: string[];
|
|
478
633
|
version: string;
|
|
479
634
|
type: "standard";
|
|
480
635
|
dependencies: string[];
|
|
@@ -488,25 +643,6 @@ declare class AnalyticsServicePlugin implements Plugin {
|
|
|
488
643
|
|
|
489
644
|
/** @deprecated use DatasetCompareTo from @objectstack/spec/contracts */
|
|
490
645
|
type CompareTo = DatasetCompareTo;
|
|
491
|
-
/**
|
|
492
|
-
* Dataset executor (ADR-0021 WS2).
|
|
493
|
-
*
|
|
494
|
-
* Turns a compiled dataset + a presentation's selection (dimensions, measures,
|
|
495
|
-
* runtime filter, compareTo) into one or more `AnalyticsQuery`s against the Cube
|
|
496
|
-
* runtime, then post-processes the results:
|
|
497
|
-
* - resolves the base measures a selection needs (including derived deps),
|
|
498
|
-
* - applies measure-scoped filters via supplementary grouped queries,
|
|
499
|
-
* - evaluates derived measures (ratio/sum/difference/product) row-by-row (Q1),
|
|
500
|
-
* - shifts the query for `compareTo` (previousPeriod / previousYear) and
|
|
501
|
-
* attaches `<measure>__compare` columns,
|
|
502
|
-
* - computes server-side totals (`selection.totals.groupings`, #1753) by
|
|
503
|
-
* re-running the selection per dimension subset, so matrix subtotals and
|
|
504
|
-
* the grand total use each measure's true aggregate.
|
|
505
|
-
*
|
|
506
|
-
* RLS/tenant scoping is NOT handled here — it is enforced inside the strategy
|
|
507
|
-
* via the StrategyContext read-scope hook (D-C). This layer is pure query
|
|
508
|
-
* shaping + arithmetic.
|
|
509
|
-
*/
|
|
510
646
|
/** AND two optional FilterConditions into one (MongoDB-style). */
|
|
511
647
|
declare function combineFilters(a?: FilterCondition, b?: FilterCondition): FilterCondition | undefined;
|
|
512
648
|
/**
|
|
@@ -518,7 +654,16 @@ declare function evaluateDerivedMeasures(rows: Record<string, unknown>[], derive
|
|
|
518
654
|
declare function shiftRange(range: [string, string], kind: CompareTo['kind']): [string, string];
|
|
519
655
|
declare class DatasetExecutor {
|
|
520
656
|
private readonly service;
|
|
521
|
-
|
|
657
|
+
private readonly orderLabels?;
|
|
658
|
+
/**
|
|
659
|
+
* @param service - The analytics service the executor issues its queries to.
|
|
660
|
+
* @param orderLabels - Optional sort-key label hook (#3680). When provided,
|
|
661
|
+
* an order key naming a label-bearing (`select`/`lookup`) dimension sorts
|
|
662
|
+
* by its display label instead of the stored value. Omit to sort by stored
|
|
663
|
+
* values everywhere (e.g. the draft-preview path, whose seed rows already
|
|
664
|
+
* carry display names).
|
|
665
|
+
*/
|
|
666
|
+
constructor(service: IAnalyticsService, orderLabels?: OrderLabelResolver | undefined);
|
|
522
667
|
/**
|
|
523
668
|
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
524
669
|
*
|
|
@@ -526,8 +671,19 @@ declare class DatasetExecutor {
|
|
|
526
671
|
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
527
672
|
* applied per request (ADR-0021 D-C).
|
|
528
673
|
*/
|
|
529
|
-
execute(
|
|
674
|
+
execute(compiledInput: CompiledDataset, selectionInput: DatasetSelection, context?: ExecutionContext): Promise<AnalyticsResult>;
|
|
530
675
|
private executeSelection;
|
|
676
|
+
/**
|
|
677
|
+
* The selected dimensions the compiled cube types as `time`, in selection
|
|
678
|
+
* order (#3916) — the axis {@link resolveOrdering} defaults to ascending.
|
|
679
|
+
*
|
|
680
|
+
* Membership is decided by the DIMENSION's declared type, not by whether the
|
|
681
|
+
* selection happens to bucket it: a `date` dimension left ungranulated groups
|
|
682
|
+
* raw timestamps, and those want chronological order every bit as much as
|
|
683
|
+
* month buckets do. (Both sort correctly — `compareValues` compares Dates and
|
|
684
|
+
* ISO strings chronologically, and bucket keys are minted sort-stable.)
|
|
685
|
+
*/
|
|
686
|
+
private timeDimensionsOf;
|
|
531
687
|
private buildQuery;
|
|
532
688
|
private runCompare;
|
|
533
689
|
}
|
|
@@ -549,6 +705,12 @@ declare function compileScopedFilterToSql(filter: FilterCondition, alias: string
|
|
|
549
705
|
* Pushes the analytics query down to the database as a native SQL statement.
|
|
550
706
|
* This is the most efficient path and is preferred whenever the backing driver
|
|
551
707
|
* supports raw SQL execution (e.g. Postgres, MySQL, SQLite).
|
|
708
|
+
*
|
|
709
|
+
* `resolveMeasureSql` used to answer `COUNT(*)` to three different questions it
|
|
710
|
+
* could not otherwise answer — an undeclared measure, a custom-SQL-expression
|
|
711
|
+
* metric type, and an unrecognised type. All three returned a plausible number
|
|
712
|
+
* for a query that asked for something else. They now emit the expression or
|
|
713
|
+
* throw; see that method. #4157.
|
|
552
714
|
*/
|
|
553
715
|
declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
554
716
|
readonly name = "NativeSQLStrategy";
|
|
@@ -635,6 +797,34 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
|
|
|
635
797
|
* correctly.
|
|
636
798
|
*/
|
|
637
799
|
private coerceTemporal;
|
|
800
|
+
/**
|
|
801
|
+
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
802
|
+
* reads in the storage form the comparand was coerced into.
|
|
803
|
+
*
|
|
804
|
+
* A SQLite `Field.datetime` column carries an INTEGER epoch (a `Date` write)
|
|
805
|
+
* and ISO TEXT (a REST/JSON write, a `NOW()` default — including the platform's
|
|
806
|
+
* own `created_at`) at the SAME time, so coercing the value alone fixes one half
|
|
807
|
+
* and empties the other. That is #3912: a `dateRange: last_30_days` on
|
|
808
|
+
* `created_date` read 0 with 29 rows in range. Every other column and dialect
|
|
809
|
+
* gets its reference back verbatim.
|
|
810
|
+
*/
|
|
811
|
+
private temporalColumn;
|
|
812
|
+
/**
|
|
813
|
+
* Compile a normalized filter node into a boolean SQL expression, recursing
|
|
814
|
+
* through the combinators. `null` = no constraint.
|
|
815
|
+
*
|
|
816
|
+
* Leaves go through {@link buildFilterClause} exactly as they did when this
|
|
817
|
+
* was a flat loop, so the storage-form coercion and the calendar-day
|
|
818
|
+
* upper-bound rule (#3777) apply at every depth — including inside an `$or`,
|
|
819
|
+
* where a second, combinator-aware implementation would have been free to
|
|
820
|
+
* drift from the first.
|
|
821
|
+
*
|
|
822
|
+
* Parenthesisation is explicit rather than left to SQL's precedence: `AND`
|
|
823
|
+
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
824
|
+
* being right by construction is what keeps a future edit from making it
|
|
825
|
+
* wrong.
|
|
826
|
+
*/
|
|
827
|
+
private compileFilterNode;
|
|
638
828
|
private buildFilterClause;
|
|
639
829
|
private extractObjectName;
|
|
640
830
|
private buildFieldMeta;
|
|
@@ -652,10 +842,83 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
652
842
|
readonly priority = 20;
|
|
653
843
|
canHandle(query: AnalyticsQuery, ctx: StrategyContext): boolean;
|
|
654
844
|
execute(query: AnalyticsQuery, ctx: StrategyContext): Promise<AnalyticsResult>;
|
|
845
|
+
/**
|
|
846
|
+
* Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
|
|
847
|
+
*
|
|
848
|
+
* This path executes through `engine.aggregate()`, not raw SQL, so the string
|
|
849
|
+
* is documentation rather than the literal statement — but it must be an
|
|
850
|
+
* honest account of what the query does, because dataset responses echo it
|
|
851
|
+
* and authors read it to verify their widget options landed (#3588). It
|
|
852
|
+
* therefore renders date bucketing (`date_trunc`), the WHERE predicate,
|
|
853
|
+
* ordering, and the row window.
|
|
854
|
+
*
|
|
855
|
+
* Filter VALUES are rendered as `$n` placeholders and returned in `params`,
|
|
856
|
+
* never inlined: the echoed statement travels to the browser, and a filter
|
|
857
|
+
* comparand can carry tenant data.
|
|
858
|
+
*/
|
|
655
859
|
generateSql(query: AnalyticsQuery, ctx: StrategyContext): Promise<{
|
|
656
860
|
sql: string;
|
|
657
861
|
params: unknown[];
|
|
658
862
|
}>;
|
|
863
|
+
/**
|
|
864
|
+
* ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
|
|
865
|
+
* filter handed to `engine.aggregate`.
|
|
866
|
+
*
|
|
867
|
+
* This path used to drop the scope entirely, and the engine could not make up
|
|
868
|
+
* for it: the aggregate bridge passes no `ExecutionContext`, so the security
|
|
869
|
+
* middleware's principal-less fall-open skipped its own RLS injection. Both
|
|
870
|
+
* belts were off at once — an authenticated caller received aggregates
|
|
871
|
+
* computed over EVERY tenant's rows.
|
|
872
|
+
*
|
|
873
|
+
* Composed with `$and`, never by key merge: the query's own filter and the
|
|
874
|
+
* scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
|
|
875
|
+
* and a spread would let caller input silently overwrite the security
|
|
876
|
+
* predicate. `$and` makes that structurally impossible.
|
|
877
|
+
*/
|
|
878
|
+
private withReadScope;
|
|
879
|
+
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
880
|
+
private isCrossObjectField;
|
|
881
|
+
/**
|
|
882
|
+
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
883
|
+
*
|
|
884
|
+
* `engine.aggregate()` cannot join. A cross-object DIMENSION within a
|
|
885
|
+
* supported envelope is served by an FK-expand (`executeCrossObject`): group
|
|
886
|
+
* the base aggregate on the lookup FK, resolve the FK to the related attribute
|
|
887
|
+
* with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
|
|
888
|
+
* query (direct path), a plan for an in-envelope cross-object query.
|
|
889
|
+
*
|
|
890
|
+
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
891
|
+
* (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
|
|
892
|
+
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
893
|
+
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
894
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
895
|
+
*
|
|
896
|
+
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
897
|
+
* flattens to a real column is treated as base, not cross-object.
|
|
898
|
+
*/
|
|
899
|
+
private planCrossObject;
|
|
900
|
+
/**
|
|
901
|
+
* Serve a cross-object-dimension query by FK-expand (#3654). The pure
|
|
902
|
+
* re-bucketing step lives in `cross-object-rebucket.ts`.
|
|
903
|
+
*/
|
|
904
|
+
private executeCrossObject;
|
|
905
|
+
/**
|
|
906
|
+
* Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
|
|
907
|
+
* referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
|
|
908
|
+
* bridge — `group by (id, attr)` is one row per record. Ids the scope hides
|
|
909
|
+
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
910
|
+
*/
|
|
911
|
+
private resolveFkAttr;
|
|
912
|
+
/**
|
|
913
|
+
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
914
|
+
*
|
|
915
|
+
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
916
|
+
* two previews read alike, but binds through `coerceFilterValueForObjectQL`:
|
|
917
|
+
* the comparand shown is the one THIS path actually hands the engine (a real
|
|
918
|
+
* boolean, not SQL's 1/0). Returns null for an operator/value combination
|
|
919
|
+
* that carries no predicate, matching `execute()`, which drops it too.
|
|
920
|
+
*/
|
|
921
|
+
private buildFilterClauseSql;
|
|
659
922
|
/**
|
|
660
923
|
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
661
924
|
*
|
|
@@ -671,9 +934,108 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
|
|
|
671
934
|
private lookupMember;
|
|
672
935
|
private resolveFieldName;
|
|
673
936
|
private resolveMeasureAggregation;
|
|
937
|
+
/**
|
|
938
|
+
* AND one more operand onto `filter[field]`, merging operator objects rather
|
|
939
|
+
* than overwriting them. Returns a standalone conjunct when the two cannot
|
|
940
|
+
* share one entry, or `null` when the merge absorbed the operand.
|
|
941
|
+
*
|
|
942
|
+
* Every predicate this strategy contributes goes through here — the caller's
|
|
943
|
+
* `where` and the time-dimension `dateRange` alike. Two operands on one field
|
|
944
|
+
* are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
|
|
945
|
+
* window on `close_date`), and a plain assignment would keep only the last:
|
|
946
|
+
* that is how a range used to lose a bound.
|
|
947
|
+
*
|
|
948
|
+
* Spreading is sound only while the operands name DIFFERENT operators. Where
|
|
949
|
+
* they collide — two `$gte` bounds on one field, which a window makes routine
|
|
950
|
+
* and which a `where` can already produce on its own through `$and` — the
|
|
951
|
+
* spread keeps whichever came last and WIDENS the query. Same for a bare
|
|
952
|
+
* equality meeting an operator object: neither can absorb the other. Those
|
|
953
|
+
* are handed back for the caller to AND in separately, so the engine
|
|
954
|
+
* intersects them instead of the strategy picking a winner.
|
|
955
|
+
*/
|
|
956
|
+
/**
|
|
957
|
+
* Fold a normalized filter node into the engine filter being built.
|
|
958
|
+
*
|
|
959
|
+
* AND-ed LEAVES merge per field through {@link mergeFilterOperand}, exactly
|
|
960
|
+
* as the flat loop this replaced did — so a query without combinators still
|
|
961
|
+
* produces byte-identical engine input. Anything structural (`$or`, `$not`,
|
|
962
|
+
* a nested `$and` that cannot merge) becomes its own conjunct, which the
|
|
963
|
+
* caller ANDs in. The engine speaks these combinators natively
|
|
964
|
+
* (`FilterCondition` declares them and every driver compiles them), so this
|
|
965
|
+
* path hands them over rather than lowering them.
|
|
966
|
+
*/
|
|
967
|
+
private applyFilterNode;
|
|
968
|
+
/** A node as a standalone `FilterCondition` the engine can consume. */
|
|
969
|
+
private filterNodeToCondition;
|
|
970
|
+
/**
|
|
971
|
+
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
972
|
+
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
973
|
+
* browser, so a comparand is never inlined.
|
|
974
|
+
*/
|
|
975
|
+
private renderFilterNodeSql;
|
|
976
|
+
private mergeFilterOperand;
|
|
977
|
+
/**
|
|
978
|
+
* Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
|
|
979
|
+
*
|
|
980
|
+
* `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
|
|
981
|
+
* never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
|
|
982
|
+
* this path used to drop the window on the floor — no error, just every row
|
|
983
|
+
* ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
|
|
984
|
+
* declines any query carrying a `granularity`, so a date-bucketed trend lands
|
|
985
|
+
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
986
|
+
* carries a range ("last 12 months", "this quarter").
|
|
987
|
+
*
|
|
988
|
+
* Bounds are inclusive on both ends — logically "from day X through day Y".
|
|
989
|
+
* The `$lte` end is left as the bare calendar day on purpose: the driver's
|
|
990
|
+
* filter compiler owns the calendar-day → instant translation, compiling a
|
|
991
|
+
* bare-day `$lte` on a `datetime` column into the half-open `< nextDay`
|
|
992
|
+
* (#3777) while a `date` column keeps the plain `<=`. `NativeSQLStrategy`
|
|
993
|
+
* performs the same half-open translation itself because it binds into raw
|
|
994
|
+
* SQL, so one dashboard reads the same on every driver.
|
|
995
|
+
*
|
|
996
|
+
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
997
|
+
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
998
|
+
* STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
|
|
999
|
+
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1000
|
+
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1001
|
+
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
1002
|
+
* the very coercion that already makes a `where` bound on that same column
|
|
1003
|
+
* work today.
|
|
1004
|
+
*
|
|
1005
|
+
* A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
|
|
1006
|
+
* `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
|
|
1007
|
+
* neither SQL path resolves them, and inventing a second interpretation on the
|
|
1008
|
+
* driver-independent path is how the two would drift apart again.
|
|
1009
|
+
*
|
|
1010
|
+
* An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
|
|
1011
|
+
* takes its first two entries, a one-entry array degenerating to a point.
|
|
1012
|
+
* `NativeSQLStrategy` drops such a window entirely — but "drop the window"
|
|
1013
|
+
* means "plot all of history", which is the very failure this fixes, so the
|
|
1014
|
+
* fallback here errs toward the narrower query instead.
|
|
1015
|
+
*/
|
|
1016
|
+
private dateRangeBounds;
|
|
674
1017
|
private convertFilter;
|
|
675
1018
|
private extractObjectName;
|
|
1019
|
+
/**
|
|
1020
|
+
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
1021
|
+
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
1022
|
+
* is not already one of them.
|
|
1023
|
+
*
|
|
1024
|
+
* `timeDimensions` is not merely a filter carrier. An entry with a
|
|
1025
|
+
* `granularity` is GROUPED BY — see the `td.granularity` sites that build
|
|
1026
|
+
* groupBy here, in `generateSql` and in the cross-object path — so its
|
|
1027
|
+
* bucket is a COLUMN of the result; an entry without one only contributes a
|
|
1028
|
+
* `dateRange` predicate and must NOT be projected.
|
|
1029
|
+
*
|
|
1030
|
+
* Grouping, row mapping and {@link buildFieldMeta} have to agree on exactly
|
|
1031
|
+
* that set. When they did not, a bucketed query returned rows carrying only
|
|
1032
|
+
* the measures and a `fields` list that never mentioned the bucket — a trend
|
|
1033
|
+
* chart got N values and no x-axis (#4033) — even though the SQL had
|
|
1034
|
+
* selected `date_trunc(…) AS "<dim>"` all along. One definition, every
|
|
1035
|
+
* consumer.
|
|
1036
|
+
*/
|
|
1037
|
+
private projectedDimensions;
|
|
676
1038
|
private buildFieldMeta;
|
|
677
1039
|
}
|
|
678
1040
|
|
|
679
|
-
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
|
|
1041
|
+
export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type OrderLabelResolver, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, createOrderLabelResolver, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange, withLabelFetchCache };
|