@objectstack/service-analytics 17.0.0-rc.6 → 17.0.0
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 +4574 -1
- package/dist/index.cjs +239 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +234 -7
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
package/dist/index.cjs
CHANGED
|
@@ -41,7 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
module.exports = __toCommonJS(index_exports);
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
|
-
var
|
|
44
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
45
45
|
var import_ui2 = require("@objectstack/spec/ui");
|
|
46
46
|
var import_core5 = require("@objectstack/core");
|
|
47
47
|
var import_types = require("@objectstack/types");
|
|
@@ -178,11 +178,46 @@ function isRenderableTextComparand(value) {
|
|
|
178
178
|
if (kind === "string" || kind === "number" || kind === "bigint" || kind === "boolean") return true;
|
|
179
179
|
return value instanceof Date;
|
|
180
180
|
}
|
|
181
|
+
function isFieldReference(value) {
|
|
182
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
183
|
+
return typeof value.$field === "string";
|
|
184
|
+
}
|
|
185
|
+
var CROSS_FIELD_COMPARISON_OPERATORS = /* @__PURE__ */ new Set([
|
|
186
|
+
"$eq",
|
|
187
|
+
"$ne",
|
|
188
|
+
"$gt",
|
|
189
|
+
"$gte",
|
|
190
|
+
"$lt",
|
|
191
|
+
"$lte"
|
|
192
|
+
]);
|
|
193
|
+
function findCrossFieldComparand(filter) {
|
|
194
|
+
return findIn(filter, "");
|
|
195
|
+
}
|
|
196
|
+
function findIn(node, field) {
|
|
197
|
+
if (!node || typeof node !== "object") return null;
|
|
198
|
+
if (Array.isArray(node)) {
|
|
199
|
+
for (const child of node) {
|
|
200
|
+
const hit = findIn(child, field);
|
|
201
|
+
if (hit) return hit;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (node instanceof Date || ArrayBuffer.isView(node)) return null;
|
|
206
|
+
for (const [key, value] of Object.entries(node)) {
|
|
207
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(key) && isFieldReference(value)) {
|
|
208
|
+
return { op: key, field, ref: value.$field };
|
|
209
|
+
}
|
|
210
|
+
const hit = findIn(value, key.startsWith("$") ? field : key);
|
|
211
|
+
if (hit) return hit;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
181
215
|
var TEXT_PATTERN_OPERATORS = /* @__PURE__ */ new Set([
|
|
182
216
|
"$contains",
|
|
183
217
|
"$notContains",
|
|
184
218
|
"$startsWith",
|
|
185
|
-
"$endsWith"
|
|
219
|
+
"$endsWith",
|
|
220
|
+
"$icontains"
|
|
186
221
|
]);
|
|
187
222
|
function shapePreview(value) {
|
|
188
223
|
try {
|
|
@@ -196,6 +231,12 @@ function shapePreview(value) {
|
|
|
196
231
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
197
232
|
return `"${op}" on "${field}" matches against the TEXT of a pattern, but its comparand is ${Array.isArray(value) ? "an array" : "an object"} (${shapePreview(value)}). filter.zod.ts declares it a string (StringOperatorSchema); a string, number, boolean, null or Date is accepted. Refusing rather than stringifying it: String({}) is "[object Object]", so the pattern that ran would be one nobody wrote \u2014 and a row storing that literal text matches it.`;
|
|
198
233
|
}
|
|
234
|
+
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
235
|
+
return `"${op}" on "${field}"${position ? ` (${position})` : ""} compares against the field reference { "$field": "${ref}" }, which this compiler does not lower into a column-to-column comparison. Refusing rather than binding it: the reference object used to become the BOUND VALUE of the comparison, so the emitted predicate compared "${field}" against the reference itself \u2014 a value no row can hold \u2014 and a read scope built from it answered the wrong row set with nothing to read. \u26A0\uFE0F This is NOT the platform declining the rule. @objectstack/spec declares this shape (FieldReferenceSchema), @objectstack/formula resolves it per record in memory, driver-sql / driver-sqlite-wasm compile it to a same-table column comparison for the six scalar operators since #5222, and since the 2026-08-12 ruling on #7598 the analytics native-SQL strategy DECLINES such a query so it routes to the ObjectQL engine path and runs there \u2014 the driver enforcing declared-only enumeration, the tenant-isolation ban and the comparison class with metadata it owns. What refuses here is this SQL lowering, whose only remaining caller is the /analytics/sql display echo; it has no faithful rendering of the predicate the engine path actually runs, and half-rendering one would describe a query that returns different rows. Run the query itself (/analytics/query) to get its rows (#7598).`;
|
|
236
|
+
}
|
|
237
|
+
function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
238
|
+
return `"${op}" on "${field}" has the field reference { "$field": "${ref}" } at index ${index} of its [min, max] bounds. A range BOUND may not be a field reference on any backend: driver-sql and driver-sqlite-wasm refuse both endpoints (#5222), @objectstack/formula does not resolve a reference inside a list either \u2014 it orders the bounds against the raw reference object, which no value compares meaningfully to \u2014 and @objectstack/spec no longer declares the position at all (#7596 removed FieldReferenceSchema from the $between endpoint union, ADR-0049 declared = enforced). Refusing rather than lowering it: this compiler splits $between into its two bounds, so the reference would arrive at the driver under a "$gte" / "$lte" the author never wrote \u2014 a position the SQL drivers DO compile \u2014 and the range would quietly succeed here while the identical filter is refused everywhere else. Use a literal bound, or spell the comparison you meant as a scalar one ({ "${field}": { "$gte": { "$field": "${ref}" } } }), which IS served \u2014 on the ObjectQL engine path, where the driver enforces the #5222 rulings (#7598).`;
|
|
239
|
+
}
|
|
199
240
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
200
241
|
return `"${op}" on "${field}" has a value at index ${index} of its list that cannot be bound as a SQL parameter: ${shapePreview(value)}. Every member of an $in/$nin/$between list is a comparand in its own right \u2014 use a string, number, boolean, null, Date or binary value. Refusing rather than binding it: the member can equal no stored value, so the list silently loses that entry (and a $nin loses the exclusion the caller wrote).`;
|
|
201
242
|
}
|
|
@@ -261,6 +302,15 @@ function assertCompilableComparand(opKey, field, value) {
|
|
|
261
302
|
});
|
|
262
303
|
}
|
|
263
304
|
}
|
|
305
|
+
function assertNoFieldReferenceComparand(opKey, field, value) {
|
|
306
|
+
if (opKey !== "$between" || !Array.isArray(value)) return;
|
|
307
|
+
value.forEach((member, index) => {
|
|
308
|
+
if (!isFieldReference(member)) return;
|
|
309
|
+
throw invalidFilterError(
|
|
310
|
+
`[analytics] ${fieldReferenceBetweenBoundMessage(opKey, field, member.$field, index)}`
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
264
314
|
function undefinedComparandError(field, path) {
|
|
265
315
|
return invalidFilterError(
|
|
266
316
|
`[analytics] comparand at ${path} is undefined \u2014 refusing to compile this filter. @objectstack/spec FieldOperatorsSchema declares no undefined comparand, and in JavaScript a key whose value is undefined cannot be told apart from an ABSENT key \u2014 yet the two mean OPPOSITE things (a predicate versus no constraint at all), so there is no reading of it that is not a guess. It used to compile, two ways: in a FIELD position the key was dropped outright, so a single-key where ran with no filter at all and the chart was drawn over every row (#3650's widening, which this module refuses everywhere else); in an OPERATOR or list position it became a comparison against null, which is UNKNOWN for every row and charts nothing. Write null if the null predicate was meant ({ "${field}": null } or { "${field}": { "$null": true } }), or omit the key entirely when the value is genuinely absent \u2014 an omitted key is the same "no constraint" without the ambiguity. The producer to fix is whoever BUILT this where: undefined cannot cross JSON, so it is in-process code spreading a possibly-absent value into a filter object (#6050 ruling B, pushed down to this door by #6386).`
|
|
@@ -330,6 +380,7 @@ function fieldLeaves(key, raw) {
|
|
|
330
380
|
`[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
|
|
331
381
|
);
|
|
332
382
|
}
|
|
383
|
+
assertNoFieldReferenceComparand(opKey, key, v2);
|
|
333
384
|
leaf("gte", [comparand(v2[0])]);
|
|
334
385
|
leaf("lte", [comparand(v2[1])]);
|
|
335
386
|
continue;
|
|
@@ -457,6 +508,7 @@ function nullValueSatisfiesOperator(op, value) {
|
|
|
457
508
|
}
|
|
458
509
|
}
|
|
459
510
|
function operatorIsNullTotal(op, value) {
|
|
511
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(value)) return true;
|
|
460
512
|
switch (op) {
|
|
461
513
|
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
462
514
|
// construction, on every strategy that compiles this tree.
|
|
@@ -677,6 +729,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
677
729
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
678
730
|
assertDefinedComparands2(field, value);
|
|
679
731
|
assertBooleanFlagComparands(field, value);
|
|
732
|
+
assertNoFieldReferenceComparand2(field, value);
|
|
680
733
|
if (value === null) return `${col} IS NULL`;
|
|
681
734
|
if (typeof value !== "object" || value instanceof Date) {
|
|
682
735
|
params.push(value);
|
|
@@ -749,6 +802,23 @@ function assertBooleanFlagComparands(field, spec) {
|
|
|
749
802
|
throw nonBooleanFlagComparandError(op, field, `"${field}".${op}`);
|
|
750
803
|
}
|
|
751
804
|
}
|
|
805
|
+
function assertNoFieldReferenceComparand2(field, spec) {
|
|
806
|
+
if (!isFilterNode(spec)) return;
|
|
807
|
+
for (const [op, opValue] of Object.entries(spec)) {
|
|
808
|
+
if (CROSS_FIELD_COMPARISON_OPERATORS.has(op) && isFieldReference(opValue)) {
|
|
809
|
+
throw readScopeCompileError(
|
|
810
|
+
`[read-scope-sql] ${fieldReferenceComparandMessage(op, field, opValue.$field)}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (op !== "$between" || !Array.isArray(opValue)) continue;
|
|
814
|
+
opValue.forEach((member, index) => {
|
|
815
|
+
if (!isFieldReference(member)) return;
|
|
816
|
+
throw readScopeCompileError(
|
|
817
|
+
`[read-scope-sql] ${fieldReferenceBetweenBoundMessage(op, field, member.$field, index)}`
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
752
822
|
function compileOperator(col, op, val, field, params) {
|
|
753
823
|
switch (op) {
|
|
754
824
|
case "$eq":
|
|
@@ -980,9 +1050,82 @@ var NativeSQLStrategy = class {
|
|
|
980
1050
|
}
|
|
981
1051
|
}
|
|
982
1052
|
}
|
|
1053
|
+
if (this.carriesCrossFieldComparison(query, ctx)) return false;
|
|
983
1054
|
const caps = ctx.queryCapabilities(query.cube);
|
|
984
1055
|
return caps.nativeSql && typeof ctx.executeRawSql === "function";
|
|
985
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* [#7598] Does serving this query require the cross-field capability this
|
|
1059
|
+
* strategy declines? See the ruling recorded at {@link canHandle}.
|
|
1060
|
+
*
|
|
1061
|
+
* ⚠️ This and {@link assertNoCrossFieldComparison} read the SAME inputs
|
|
1062
|
+
* through the SAME detector, which is what makes the decline and the
|
|
1063
|
+
* fail-closed backstop unable to drift: a shape one of them recognises is a
|
|
1064
|
+
* shape the other recognises.
|
|
1065
|
+
*/
|
|
1066
|
+
carriesCrossFieldComparison(query, ctx) {
|
|
1067
|
+
return this.crossFieldComparisonIn(query, ctx) !== null;
|
|
1068
|
+
}
|
|
1069
|
+
crossFieldComparisonIn(query, ctx) {
|
|
1070
|
+
let where = null;
|
|
1071
|
+
try {
|
|
1072
|
+
where = lowerAnalyticsWhere(query);
|
|
1073
|
+
} catch {
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
const inWhere = findCrossFieldComparand(where);
|
|
1077
|
+
if (inWhere) return { source: "the query's `where`", ...inWhere };
|
|
1078
|
+
if (typeof ctx.getReadScope !== "function") return null;
|
|
1079
|
+
const cube = query.cube ? ctx.getCube(query.cube) : void 0;
|
|
1080
|
+
if (!cube) return null;
|
|
1081
|
+
const objects = [this.extractObjectName(cube)];
|
|
1082
|
+
for (const alias of Object.keys(cube.joins ?? {})) {
|
|
1083
|
+
objects.push(cube.joins?.[alias]?.name ?? alias);
|
|
1084
|
+
}
|
|
1085
|
+
for (const objectName of objects) {
|
|
1086
|
+
const scope = ctx.getReadScope(objectName);
|
|
1087
|
+
if (scope === void 0 || scope === null) continue;
|
|
1088
|
+
const inScope = findCrossFieldComparand(scope);
|
|
1089
|
+
if (inScope) return { source: `the read scope of "${objectName}"`, ...inScope };
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* [#7598] The fail-closed backstop at the door that BINDS.
|
|
1095
|
+
*
|
|
1096
|
+
* ⚠️ **Unreachable by construction, and kept deliberately** — saying so
|
|
1097
|
+
* because #7598's brief asks that a refusal arm which has become unreachable
|
|
1098
|
+
* be named rather than left to be re-discovered. {@link canHandle} declines
|
|
1099
|
+
* every query this would fire on, and it declines using
|
|
1100
|
+
* {@link crossFieldComparisonIn} — the same walk over the same two inputs —
|
|
1101
|
+
* so `resolveStrategy` cannot hand this strategy a query carrying one.
|
|
1102
|
+
*
|
|
1103
|
+
* It is kept because of what the failure mode is if that ever stops being
|
|
1104
|
+
* true. The defect #7598 measured was not a missing error: it was a SILENT
|
|
1105
|
+
* BIND — `toSqlBindValue` JSON-stringifies the reference object, so the
|
|
1106
|
+
* statement compiled perfectly and compared a column against the text
|
|
1107
|
+
* `{"$field":"budget"}`, a value no row can hold. A routing gate that misses
|
|
1108
|
+
* a shape therefore degrades to a wrong ANSWER rather than to an error, and
|
|
1109
|
+
* that is the one class this package refuses to leave to a single guard
|
|
1110
|
+
* (Prime Directive #12 — refuse at the door, do not tolerate at the
|
|
1111
|
+
* consumer). One line, no measurable cost, and it turns a routing regression
|
|
1112
|
+
* into a loud refusal instead of an empty chart.
|
|
1113
|
+
*
|
|
1114
|
+
* Deliberately BARE — an undeclared 500, not `INVALID_FILTER` / 400 — for the
|
|
1115
|
+
* reason `buildFilterClauseSql`'s #5333 exit in `objectql-strategy.ts` gives
|
|
1116
|
+
* for the same class: the caller's filter is legal and is served on the
|
|
1117
|
+
* engine path, so an arrival here is drift between our own routing gate and
|
|
1118
|
+
* our own emitter. Billing the caller 400 for that would hide a platform bug
|
|
1119
|
+
* from 5xx alerting and tell a dashboard user to fix a filter that is fine.
|
|
1120
|
+
* Same tier as `resolveMeasureSql`'s unrecognised-`Metric.type` throw below.
|
|
1121
|
+
*/
|
|
1122
|
+
assertNoCrossFieldComparison(query, ctx) {
|
|
1123
|
+
const hit = this.crossFieldComparisonIn(query, ctx);
|
|
1124
|
+
if (!hit) return;
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
`[native-sql-strategy] ${hit.source} carries a field reference { "$field": "${hit.ref}" } under "${hit.op}" on "${hit.field}", which this strategy does not compile into a column-to-column comparison \u2014 it would BIND the reference object as the comparison's value and answer a wrong row set silently (#7598). \`canHandle\` declines such a query so it routes to the ObjectQL/engine path, whose driver compiles it and enforces the #5222 rulings with metadata it owns; reaching this throw means the decline and this emitter stopped agreeing, which is our bug and must never degrade to a silent answer.`
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
986
1129
|
async execute(query, ctx) {
|
|
987
1130
|
const { sql, params } = await this.generateSql(query, ctx);
|
|
988
1131
|
const cube = ctx.getCube(query.cube);
|
|
@@ -996,6 +1139,7 @@ var NativeSQLStrategy = class {
|
|
|
996
1139
|
if (!cube) {
|
|
997
1140
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
998
1141
|
}
|
|
1142
|
+
this.assertNoCrossFieldComparison(query, ctx);
|
|
999
1143
|
const params = [];
|
|
1000
1144
|
const selectClauses = [];
|
|
1001
1145
|
const groupByClauses = [];
|
|
@@ -1432,6 +1576,7 @@ var NativeSQLStrategy = class {
|
|
|
1432
1576
|
};
|
|
1433
1577
|
|
|
1434
1578
|
// src/strategies/objectql-strategy.ts
|
|
1579
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
1435
1580
|
var import_core2 = require("@objectstack/core");
|
|
1436
1581
|
|
|
1437
1582
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1618,6 +1763,12 @@ var ObjectQLStrategy = class {
|
|
|
1618
1763
|
if (!cube) {
|
|
1619
1764
|
throw new Error(`Cube not found: ${query.cube}`);
|
|
1620
1765
|
}
|
|
1766
|
+
const crossField = findCrossFieldComparand(this.loweredWhere(query));
|
|
1767
|
+
if (crossField) {
|
|
1768
|
+
throw invalidFilterError(
|
|
1769
|
+
`[analytics] cannot render display SQL for the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}". The query itself is SERVED \u2014 \`NativeSQLStrategy.canHandle\` declines a cross-field comparison so it routes to the ObjectQL engine path, where driver-sql compiles it into a column-to-column predicate written TOTAL across NULLs and enforces the #5222 rulings (#7598, maintainer ruling 2026-08-12). This renderer has no faithful rendering of that predicate: what it can emit is a comparison against the reference object as a bound VALUE, which reproduces none of the rows the query returns. Refusing rather than half-rendering \u2014 an echo that contradicts execution is worse than no echo (#3601 / #3602 / #3650). Run the query itself (/analytics/query) to get its rows.`
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1621
1772
|
const selectParts = [];
|
|
1622
1773
|
const groupByParts = [];
|
|
1623
1774
|
const params = [];
|
|
@@ -1722,11 +1873,11 @@ var ObjectQLStrategy = class {
|
|
|
1722
1873
|
* predicate. `$and` makes that structurally impossible.
|
|
1723
1874
|
*/
|
|
1724
1875
|
withReadScope(objectName, filter, ctx) {
|
|
1725
|
-
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
1876
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data2.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
1726
1877
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
1727
1878
|
const scope = ctx.getReadScope(objectName);
|
|
1728
1879
|
if (scope === void 0 || scope === null) return userFilter;
|
|
1729
|
-
const scopeFilter = scope;
|
|
1880
|
+
const scopeFilter = (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
|
|
1730
1881
|
if (!userFilter) return scopeFilter;
|
|
1731
1882
|
return { $and: [userFilter, scopeFilter] };
|
|
1732
1883
|
}
|
|
@@ -1900,6 +2051,7 @@ var ObjectQLStrategy = class {
|
|
|
1900
2051
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1901
2052
|
const idFilter = { id: { $in: fkValues } };
|
|
1902
2053
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2054
|
+
if (scope != null) (0, import_data2.markFilterSubtreeProvenance)(scope, "policy");
|
|
1903
2055
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1904
2056
|
const rows = await ctx.executeAggregate(refObject, {
|
|
1905
2057
|
groupBy: ["id", attr],
|
|
@@ -2252,8 +2404,27 @@ var ObjectQLStrategy = class {
|
|
|
2252
2404
|
const v0 = values[0];
|
|
2253
2405
|
const all = [...values];
|
|
2254
2406
|
switch (operator) {
|
|
2407
|
+
// [#7598] IMPLICIT equality for a literal, EXPLICIT `$eq` for a field
|
|
2408
|
+
// reference — the branch is on the COMPARAND, not on the operator, which
|
|
2409
|
+
// is the same fix and the same reasoning #7597 applied to
|
|
2410
|
+
// `parseFilterAST`, the spec's own lowering sink.
|
|
2411
|
+
//
|
|
2412
|
+
// `{ amount: 5 }` is implicit equality and every backend reads it that
|
|
2413
|
+
// way. `{ amount: { $field: 'budget' } }` is NOT: it is a field-spec
|
|
2414
|
+
// object whose only key is `$field`, which no backend reads as an
|
|
2415
|
+
// equality — `driver-sql` sees an unrecognised operator key and the
|
|
2416
|
+
// memory evaluator sees a comparand it never resolves. So the bare return
|
|
2417
|
+
// was correct for four years' worth of literals and silently wrong for
|
|
2418
|
+
// the one comparand the 2026-08-12 ruling routes HERE on purpose: with it,
|
|
2419
|
+
// `{ amount: { $eq: { $field: 'budget' } } }` — the shape
|
|
2420
|
+
// `compileCelToFilter` emits for a field-to-field CEL rule, and the shape
|
|
2421
|
+
// `canHandle` now declines native SQL for — would arrive at the driver as
|
|
2422
|
+
// something the driver cannot read, so the capability B exists to serve
|
|
2423
|
+
// would fail on its single most important spelling. Its five siblings
|
|
2424
|
+
// (`$ne`/`$gt`/`$gte`/`$lt`/`$lte`) were never affected: they emit their
|
|
2425
|
+
// operator explicitly two lines down.
|
|
2255
2426
|
case "equals":
|
|
2256
|
-
return v0;
|
|
2427
|
+
return isFieldReference(v0) ? { $eq: v0 } : v0;
|
|
2257
2428
|
case "notEquals":
|
|
2258
2429
|
return { $ne: v0 };
|
|
2259
2430
|
case "gt":
|
|
@@ -2313,6 +2484,24 @@ var ObjectQLStrategy = class {
|
|
|
2313
2484
|
extractObjectName(cube) {
|
|
2314
2485
|
return cube.sql.trim();
|
|
2315
2486
|
}
|
|
2487
|
+
/**
|
|
2488
|
+
* [#7598] The query's `where`, lowered — the same input
|
|
2489
|
+
* `NativeSQLStrategy.canHandle` scans, so the strategy that DECLINED and the
|
|
2490
|
+
* echo that refuses read one shape rather than two.
|
|
2491
|
+
*
|
|
2492
|
+
* A throw from the lowering is swallowed for the same reason it is there: the
|
|
2493
|
+
* `where` is malformed either way and `normalizeAnalyticsFilterTree` below
|
|
2494
|
+
* refuses it with the message and envelope it has always had. This helper's
|
|
2495
|
+
* only job is finding a reference, and there is none to find in a filter that
|
|
2496
|
+
* does not lower.
|
|
2497
|
+
*/
|
|
2498
|
+
loweredWhere(query) {
|
|
2499
|
+
try {
|
|
2500
|
+
return lowerAnalyticsWhere(query);
|
|
2501
|
+
} catch {
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2316
2505
|
/**
|
|
2317
2506
|
* The dimensions this query PROJECTS, in the order the result carries them:
|
|
2318
2507
|
* every `dimensions` entry, then every granular `timeDimensions` entry that
|
|
@@ -2354,10 +2543,10 @@ var ObjectQLStrategy = class {
|
|
|
2354
2543
|
};
|
|
2355
2544
|
|
|
2356
2545
|
// src/dataset-compiler.ts
|
|
2357
|
-
var
|
|
2546
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
2358
2547
|
var import_ui = require("@objectstack/spec/ui");
|
|
2359
2548
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2360
|
-
var SUPPORTED_AGGREGATES =
|
|
2549
|
+
var SUPPORTED_AGGREGATES = import_data3.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2361
2550
|
function aggregateToMetricType(m) {
|
|
2362
2551
|
if (!m.aggregate) {
|
|
2363
2552
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -2519,7 +2708,7 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2519
2708
|
}
|
|
2520
2709
|
|
|
2521
2710
|
// src/dataset-executor.ts
|
|
2522
|
-
var
|
|
2711
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
2523
2712
|
var import_core3 = require("@objectstack/core");
|
|
2524
2713
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2525
2714
|
const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
@@ -2559,7 +2748,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
2559
2748
|
}
|
|
2560
2749
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
2561
2750
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
2562
|
-
const empty = (0,
|
|
2751
|
+
const empty = (0, import_data4.emptyGroupValueFor)(aggregate2);
|
|
2563
2752
|
if (empty === void 0) continue;
|
|
2564
2753
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
2565
2754
|
}
|
|
@@ -3494,6 +3683,7 @@ var AnalyticsService = class {
|
|
|
3494
3683
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3495
3684
|
this.getObjectDatasource = config.getObjectDatasource;
|
|
3496
3685
|
this.isExternalObject = config.isExternalObject;
|
|
3686
|
+
this.debugSql = config.debugSql ?? (0, import_core5.getEnv)("NODE_ENV") === "development";
|
|
3497
3687
|
if (config.datasets) {
|
|
3498
3688
|
for (const ds of config.datasets) {
|
|
3499
3689
|
try {
|
|
@@ -3611,7 +3801,7 @@ var AnalyticsService = class {
|
|
|
3611
3801
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
3612
3802
|
this.logger.debug(`[Analytics] Query on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
3613
3803
|
try {
|
|
3614
|
-
return await strategy.execute(query, ctx);
|
|
3804
|
+
return this.applySqlEchoPolicy(await strategy.execute(query, ctx));
|
|
3615
3805
|
} catch (e) {
|
|
3616
3806
|
if (e?.code === "RAW_SQL_UNSUPPORTED") {
|
|
3617
3807
|
this.logger.warn(
|
|
@@ -3624,6 +3814,30 @@ var AnalyticsService = class {
|
|
|
3624
3814
|
}
|
|
3625
3815
|
}
|
|
3626
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* [#8286] Withhold the executed statement unless this host enabled the echo.
|
|
3819
|
+
*
|
|
3820
|
+
* Applied at {@link query}, which is the response-assembly seam for BOTH
|
|
3821
|
+
* faces that serve callers: `/api/v1/analytics/query` calls it directly, and
|
|
3822
|
+
* `queryDataset` reaches it through `DatasetExecutor`, so a dataset response
|
|
3823
|
+
* inherits the same verdict without a second gate to keep in step.
|
|
3824
|
+
* `generateSql` — the dedicated `/api/v1/analytics/sql` dry-run route — is
|
|
3825
|
+
* deliberately NOT gated: asking for the statement is that route's entire
|
|
3826
|
+
* purpose, and it is the surface a debugging author is meant to use.
|
|
3827
|
+
*
|
|
3828
|
+
* What the echo disclosed, and why "it is only a table name" understates it:
|
|
3829
|
+
* the statement carries the compiled read scope, i.e. the SHAPE of the
|
|
3830
|
+
* isolation predicate (`"sys_user"."id" IN ($2, $3, …)` rather than an
|
|
3831
|
+
* `organization_id` comparison) plus its bound-parameter arity, which counts
|
|
3832
|
+
* the caller's own org membership. No wall was breached by it — the echo is
|
|
3833
|
+
* information disclosure, and this is the disclosure closing.
|
|
3834
|
+
*/
|
|
3835
|
+
applySqlEchoPolicy(result) {
|
|
3836
|
+
if (this.debugSql || result?.sql === void 0) return result;
|
|
3837
|
+
const withheld = { ...result };
|
|
3838
|
+
delete withheld.sql;
|
|
3839
|
+
return withheld;
|
|
3840
|
+
}
|
|
3627
3841
|
/**
|
|
3628
3842
|
* Compile a `dataset` (ADR-0021) and register its Cube + join allowlist so it
|
|
3629
3843
|
* can be queried by name. Idempotent (re-registering overwrites). Returns the
|
|
@@ -3789,7 +4003,7 @@ var AnalyticsService = class {
|
|
|
3789
4003
|
}
|
|
3790
4004
|
}
|
|
3791
4005
|
if (f.percentScale == null) {
|
|
3792
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4006
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data5.percentScaleOf)(meta);
|
|
3793
4007
|
}
|
|
3794
4008
|
}
|
|
3795
4009
|
}
|
|
@@ -4288,11 +4502,19 @@ var AnalyticsService = class {
|
|
|
4288
4502
|
return strategy;
|
|
4289
4503
|
}
|
|
4290
4504
|
}
|
|
4505
|
+
const crossField = findCrossFieldComparand(lowerAnalyticsWhereQuietly(query));
|
|
4291
4506
|
throw new Error(
|
|
4292
|
-
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. Ensure a compatible driver is configured or a fallback service is registered
|
|
4507
|
+
`[Analytics] No strategy can handle query for cube "${query.cube}". Checked: ${this.strategies.map((s) => s.name).join(", ")}${skip?.size ? ` (skipped at runtime: ${[...skip].map((s) => s.name).join(", ")})` : ""}. ` + (crossField ? `This query's filter compares against the field reference { "$field": "${crossField.ref}" } under "${crossField.op}" on "${crossField.field}", and NativeSQLStrategy DECLINES a cross-field comparison so that it routes to the ObjectQL engine path \u2014 whose driver compiles it and enforces the #5222 rulings with metadata it owns (#7598). No such path is configured here, so the capability is unavailable on this deployment: supply an \`executeAggregate\` bridge (the plugin auto-wires one from the engine), or compare against a literal value. Every other query on this cube is unaffected. ` : "") + "Ensure a compatible driver is configured or a fallback service is registered."
|
|
4293
4508
|
);
|
|
4294
4509
|
}
|
|
4295
4510
|
};
|
|
4511
|
+
function lowerAnalyticsWhereQuietly(query) {
|
|
4512
|
+
try {
|
|
4513
|
+
return lowerAnalyticsWhere(query);
|
|
4514
|
+
} catch {
|
|
4515
|
+
return null;
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4296
4518
|
function mintableMeasureKey(member, cubeName) {
|
|
4297
4519
|
const dot = member.indexOf(".");
|
|
4298
4520
|
if (dot < 0) return member;
|
|
@@ -4588,6 +4810,11 @@ var AnalyticsServicePlugin = class {
|
|
|
4588
4810
|
coerceTemporalFilterColumn,
|
|
4589
4811
|
relationshipResolver,
|
|
4590
4812
|
labelResolver,
|
|
4813
|
+
// [#8286] Passed through as authored — `undefined` is "this host did not
|
|
4814
|
+
// choose", which the service resolves to development-only. Defaulting it
|
|
4815
|
+
// here would be a second copy of that decision, drifting the moment one
|
|
4816
|
+
// of the two moves.
|
|
4817
|
+
debugSql: this.options.debugSql,
|
|
4591
4818
|
// Source-field metadata behind the display chains on result columns:
|
|
4592
4819
|
// ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
|
|
4593
4820
|
// (`max`, which is what marks whole-percent storage — objectui#3136).
|