@objectstack/service-analytics 17.2.0 → 17.4.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 +899 -0
- package/dist/index.cjs +746 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +275 -34
- package/dist/index.d.ts +275 -34
- package/dist/index.js +739 -115
- package/dist/index.js.map +1 -1
- package/package.json +16 -10
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_data8 = require("@objectstack/spec/data");
|
|
45
45
|
var import_ui2 = require("@objectstack/spec/ui");
|
|
46
46
|
var import_core6 = require("@objectstack/core");
|
|
47
47
|
var import_types = require("@objectstack/types");
|
|
@@ -86,14 +86,36 @@ var CubeRegistry = class {
|
|
|
86
86
|
this.cubes.clear();
|
|
87
87
|
}
|
|
88
88
|
/**
|
|
89
|
-
* Auto-generate a cube definition from an object
|
|
89
|
+
* Auto-generate a cube definition from an object's FIELD SCHEMA, and register
|
|
90
|
+
* it under `objectName`.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ Nothing in this repository calls this — the only in-tree caller is a unit
|
|
93
|
+
* test, and every cube the platform registers itself comes from one of the
|
|
94
|
+
* three sources named on the class above (#15019). That is not the same thing
|
|
95
|
+
* as unreachable: `CubeRegistry` is exported from the package entry and
|
|
96
|
+
* `AnalyticsService.cubeRegistry` is public, so a consumer of
|
|
97
|
+
* `@objectstack/service-analytics` can call it, and what it mints does reach
|
|
98
|
+
* the wire — `getMeta()` serves the labels below as `CubeMeta` titles. Whether
|
|
99
|
+
* this published method is removed or wired up as a real cube source is #15019.
|
|
100
|
+
*
|
|
101
|
+
* Heuristic rules, measured by driving the built package (the list this
|
|
102
|
+
* replaces claimed three behaviours the code does not have — `min`/`max`
|
|
103
|
+
* measures, a `count` measure for booleans, and a computed-field exclusion):
|
|
104
|
+
* - `number` / `currency` / `percent` fields → one `sum` and one `avg` measure
|
|
105
|
+
* each, labelled with the field's label plus ` (Sum)` / ` (Avg)`. No `min`
|
|
106
|
+
* or `max` measure is minted.
|
|
107
|
+
* - EVERY field becomes a dimension; there is no computed-field exclusion (the
|
|
108
|
+
* `fields` parameter carries no flag one could exclude on).
|
|
109
|
+
* - `boolean` fields become a `boolean` DIMENSION and nothing else — no count
|
|
110
|
+
* measure is minted for them.
|
|
111
|
+
* - `date` / `datetime` fields → `time` dimensions granulated
|
|
112
|
+
* day/week/month/quarter/year.
|
|
113
|
+
* - A default `count` measure labelled `Count` is always added.
|
|
90
114
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* -
|
|
95
|
-
* - `date`/`datetime` fields → time dimensions with standard granularities
|
|
96
|
-
* - A default `count` measure is always added
|
|
115
|
+
* Those three defaults (`Count`, and the two composites) are English literals
|
|
116
|
+
* with no i18n hook; #14492's ruling listed the `Count` one as a site to carry
|
|
117
|
+
* the `builtinAggregate` discriminator, and it was left alone because no
|
|
118
|
+
* in-repo path reaches it.
|
|
97
119
|
*
|
|
98
120
|
* @param objectName - The snake_case object name (used as table/cube name)
|
|
99
121
|
* @param fields - Array of field descriptors `{ name, type, label? }`
|
|
@@ -161,19 +183,47 @@ var CubeRegistry = class {
|
|
|
161
183
|
}
|
|
162
184
|
};
|
|
163
185
|
|
|
186
|
+
// src/measure-result-type.ts
|
|
187
|
+
var import_data = require("@objectstack/spec/data");
|
|
188
|
+
var MEASURE_RESULT_TYPE_TEMPORAL = "time";
|
|
189
|
+
var MEASURE_RESULT_TYPE_STRING = "string";
|
|
190
|
+
var TEMPORAL_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
191
|
+
"date",
|
|
192
|
+
"datetime",
|
|
193
|
+
"time"
|
|
194
|
+
]);
|
|
195
|
+
var STRING_SOURCE_FIELD_TYPES = /* @__PURE__ */ new Set([
|
|
196
|
+
// Plain strings: text/textarea/email/url/phone/password/secret, the rich
|
|
197
|
+
// bodies (markdown/html/richtext/code), and color/signature/qrcode.
|
|
198
|
+
...import_data.STRING_VALUE_TYPES,
|
|
199
|
+
// One declared option code — select/radio.
|
|
200
|
+
...import_data.SINGLE_OPTION_TYPES,
|
|
201
|
+
// The referenced row's id — lookup/master_detail/tree/user.
|
|
202
|
+
...import_data.REFERENCE_VALUE_TYPES,
|
|
203
|
+
// The rendered record number, zero-padded under the default `{0000}`.
|
|
204
|
+
"autonumber"
|
|
205
|
+
]);
|
|
206
|
+
function measureResultType(aggregate2, sourceFieldType) {
|
|
207
|
+
if (aggregate2 !== "min" && aggregate2 !== "max") return void 0;
|
|
208
|
+
if (sourceFieldType === void 0) return void 0;
|
|
209
|
+
if (TEMPORAL_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_TEMPORAL;
|
|
210
|
+
if (STRING_SOURCE_FIELD_TYPES.has(sourceFieldType)) return MEASURE_RESULT_TYPE_STRING;
|
|
211
|
+
return void 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
164
214
|
// src/strategies/filter-normalizer.ts
|
|
165
|
-
var
|
|
215
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
166
216
|
var import_api = require("@objectstack/spec/api");
|
|
167
217
|
|
|
168
218
|
// src/comparand-shape.ts
|
|
169
219
|
var import_core = require("@objectstack/core");
|
|
170
|
-
var
|
|
220
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
171
221
|
function isBindableComparand(value) {
|
|
172
222
|
if (value === void 0) return true;
|
|
173
|
-
return (0,
|
|
223
|
+
return (0, import_data2.isAcceptedFilterComparand)(value) || ArrayBuffer.isView(value);
|
|
174
224
|
}
|
|
175
225
|
function isRenderableTextComparand(value) {
|
|
176
|
-
return value === void 0 || (0,
|
|
226
|
+
return value === void 0 || (0, import_data2.isAcceptedFilterComparand)(value);
|
|
177
227
|
}
|
|
178
228
|
function isFieldReference(value) {
|
|
179
229
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -271,7 +321,7 @@ function shapePreview(value) {
|
|
|
271
321
|
}
|
|
272
322
|
}
|
|
273
323
|
function unrenderableTextComparandMessage(op, field, value) {
|
|
274
|
-
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); ${
|
|
324
|
+
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); ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} 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.`;
|
|
275
325
|
}
|
|
276
326
|
function fieldReferenceComparandMessage(op, field, ref, position) {
|
|
277
327
|
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).`;
|
|
@@ -280,7 +330,7 @@ function fieldReferenceBetweenBoundMessage(op, field, ref, index) {
|
|
|
280
330
|
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).`;
|
|
281
331
|
}
|
|
282
332
|
function unbindableListMemberMessage(op, field, value, index) {
|
|
283
|
-
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 ${
|
|
333
|
+
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 ${import_data2.ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE} (or a 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).`;
|
|
284
334
|
}
|
|
285
335
|
|
|
286
336
|
// src/strategies/filter-normalizer.ts
|
|
@@ -628,7 +678,7 @@ function nullSafeNegationOperand(node) {
|
|
|
628
678
|
}
|
|
629
679
|
function filterArrayNotLowerableError(where) {
|
|
630
680
|
return invalidFilterError(
|
|
631
|
-
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...
|
|
681
|
+
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...import_data3.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
|
|
632
682
|
);
|
|
633
683
|
}
|
|
634
684
|
function lowerAnalyticsWhere(query) {
|
|
@@ -637,8 +687,8 @@ function lowerAnalyticsWhere(query) {
|
|
|
637
687
|
if (!where || typeof where !== "object") return null;
|
|
638
688
|
if (Array.isArray(where)) {
|
|
639
689
|
if (where.length === 0) return null;
|
|
640
|
-
if (!(0,
|
|
641
|
-
const condition = (0,
|
|
690
|
+
if (!(0, import_data3.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
|
|
691
|
+
const condition = (0, import_data3.parseFilterAST)(where);
|
|
642
692
|
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
643
693
|
throw invalidFilterError(
|
|
644
694
|
`[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
|
|
@@ -699,6 +749,86 @@ function asciiLowerSqlExpr(expr) {
|
|
|
699
749
|
return `translate(${expr}, '${ASCII_UPPER_LETTERS}', '${ASCII_LOWER_LETTERS}')`;
|
|
700
750
|
}
|
|
701
751
|
|
|
752
|
+
// src/text-match-sql.ts
|
|
753
|
+
var KNOWN_DIALECTS = /* @__PURE__ */ new Set(["sqlite", "postgres", "mysql"]);
|
|
754
|
+
function normalizeSqlDialect(name) {
|
|
755
|
+
return typeof name === "string" && KNOWN_DIALECTS.has(name) ? name : "unknown";
|
|
756
|
+
}
|
|
757
|
+
function sqlDialectFor(ctx, objectName) {
|
|
758
|
+
const hook = ctx.sqlDialect;
|
|
759
|
+
if (typeof hook !== "function") return "unknown";
|
|
760
|
+
return normalizeSqlDialect(hook.call(ctx, objectName));
|
|
761
|
+
}
|
|
762
|
+
function escapeGlobPattern(value) {
|
|
763
|
+
return String(value).replace(/[*?[]/g, "[$&]");
|
|
764
|
+
}
|
|
765
|
+
function wrapShape(escaped, shape, wildcard) {
|
|
766
|
+
if (shape === "starts") return `${escaped}${wildcard}`;
|
|
767
|
+
if (shape === "ends") return `${wildcard}${escaped}`;
|
|
768
|
+
return `${wildcard}${escaped}${wildcard}`;
|
|
769
|
+
}
|
|
770
|
+
function globPattern(shape, value) {
|
|
771
|
+
return wrapShape(escapeGlobPattern(value), shape, "*");
|
|
772
|
+
}
|
|
773
|
+
function mysqlAsciiLowerBinarySql(expr) {
|
|
774
|
+
return asciiLowerReplaceSql(`CAST(${expr} AS BINARY)`);
|
|
775
|
+
}
|
|
776
|
+
function asciiLowerReplaceSql(expr) {
|
|
777
|
+
let out = expr;
|
|
778
|
+
for (let i = 0; i < ASCII_UPPER_LETTERS.length; i++) {
|
|
779
|
+
out = `REPLACE(${out}, '${ASCII_UPPER_LETTERS[i]}', '${ASCII_LOWER_LETTERS[i]}')`;
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
function textMatchPredicateSql(req) {
|
|
784
|
+
const { dialect, column, shape, value, bind: bind2 } = req;
|
|
785
|
+
const negate = req.negate === true;
|
|
786
|
+
const fold = req.fold === true;
|
|
787
|
+
if (dialect === "sqlite") {
|
|
788
|
+
const lower = (expr) => fold ? `lower(${expr})` : expr;
|
|
789
|
+
return `${lower(column)} ${negate ? "NOT GLOB" : "GLOB"} ${lower(bind2(globPattern(shape, value)))}`;
|
|
790
|
+
}
|
|
791
|
+
const keyword = negate ? "NOT LIKE" : "LIKE";
|
|
792
|
+
if (dialect === "mysql") {
|
|
793
|
+
const binary = (expr) => fold ? mysqlAsciiLowerBinarySql(expr) : `CAST(${expr} AS BINARY)`;
|
|
794
|
+
return `${binary(column)} ${keyword} ${binary(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
|
|
795
|
+
}
|
|
796
|
+
const asciiLower = dialect === "postgres" ? asciiLowerSqlExpr : asciiLowerReplaceSql;
|
|
797
|
+
const folded = (expr) => fold ? asciiLower(expr) : expr;
|
|
798
|
+
return `${folded(column)} ${keyword} ${folded(bind2(likePattern(shape, value)))} ESCAPE ${bind2(LIKE_ESCAPE_CHAR)}`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/non-text-column.ts
|
|
802
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
803
|
+
function textOperatorPolarity(op) {
|
|
804
|
+
switch (op) {
|
|
805
|
+
case "$contains":
|
|
806
|
+
case "$startsWith":
|
|
807
|
+
case "$endsWith":
|
|
808
|
+
case "$icontains":
|
|
809
|
+
case "$like":
|
|
810
|
+
case "$ilike":
|
|
811
|
+
case "contains":
|
|
812
|
+
case "startsWith":
|
|
813
|
+
case "endsWith":
|
|
814
|
+
case "icontains":
|
|
815
|
+
return "positive";
|
|
816
|
+
case "$notContains":
|
|
817
|
+
case "notContains":
|
|
818
|
+
return "negative";
|
|
819
|
+
default:
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function isNonTextDeclaredType(type) {
|
|
824
|
+
return typeof type === "string" && import_data4.NON_TEXT_STORED_VALUE_TYPES.has(type);
|
|
825
|
+
}
|
|
826
|
+
function nonTextColumnResolver(ctx, objectName) {
|
|
827
|
+
const declared = ctx.declaredFieldType;
|
|
828
|
+
if (typeof declared !== "function") return void 0;
|
|
829
|
+
return (field) => isNonTextDeclaredType(declared.call(ctx, objectName, field));
|
|
830
|
+
}
|
|
831
|
+
|
|
702
832
|
// src/read-scope-sql.ts
|
|
703
833
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
704
834
|
var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
|
|
@@ -709,6 +839,7 @@ function readScopeCompileError(message) {
|
|
|
709
839
|
return err;
|
|
710
840
|
}
|
|
711
841
|
var FALSE_CLAUSE = "1 = 0";
|
|
842
|
+
var TRUE_CLAUSE = "1 = 1";
|
|
712
843
|
function isFilterNode(v) {
|
|
713
844
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
714
845
|
}
|
|
@@ -718,18 +849,65 @@ function quoteIdent(name, kind) {
|
|
|
718
849
|
}
|
|
719
850
|
return `"${name}"`;
|
|
720
851
|
}
|
|
721
|
-
function compileScopedFilterToSql(filter, alias) {
|
|
852
|
+
function compileScopedFilterToSql(filter, alias, options = {}) {
|
|
722
853
|
const quotedAlias = quoteIdent(alias, "alias");
|
|
723
854
|
const params = [];
|
|
724
|
-
const sql = compileNode(filter, quotedAlias, params);
|
|
855
|
+
const sql = compileNode(filter, quotedAlias, params, options);
|
|
725
856
|
return { sql, params };
|
|
726
857
|
}
|
|
727
|
-
function
|
|
858
|
+
function emptyMembershipFinding(spec, negated, path) {
|
|
859
|
+
if (Array.isArray(spec)) {
|
|
860
|
+
return spec.length === 0 && negated ? { path: `${path}: []`, kind: "negatedIn" } : null;
|
|
861
|
+
}
|
|
862
|
+
if (spec === null || typeof spec !== "object") return null;
|
|
863
|
+
const rec = spec;
|
|
864
|
+
if (Array.isArray(rec.$nin) && rec.$nin.length === 0) return { path: `${path}.$nin`, kind: "nin" };
|
|
865
|
+
if (Array.isArray(rec.$in) && rec.$in.length === 0 && negated) {
|
|
866
|
+
return { path: `${path}.$in`, kind: "negatedIn" };
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
function findEmptyMembership(node, negated, path) {
|
|
871
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) return null;
|
|
872
|
+
const rec = node;
|
|
873
|
+
const bare = emptyMembershipFinding(rec, negated, path.length > 0 ? path : "<root>");
|
|
874
|
+
if (bare) return bare;
|
|
875
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
876
|
+
const here = path.length > 0 ? `${path}.${key}` : key;
|
|
877
|
+
if (key === "$not") {
|
|
878
|
+
const found = findEmptyMembership(value, !negated, here);
|
|
879
|
+
if (found) return found;
|
|
880
|
+
} else if (key === "$and" || key === "$or") {
|
|
881
|
+
if (!Array.isArray(value)) continue;
|
|
882
|
+
for (let i = 0; i < value.length; i++) {
|
|
883
|
+
const found = findEmptyMembership(value[i], negated, `${here}[${i}]`);
|
|
884
|
+
if (found) return found;
|
|
885
|
+
}
|
|
886
|
+
} else if (!key.startsWith("$")) {
|
|
887
|
+
const found = emptyMembershipFinding(value, negated, here);
|
|
888
|
+
if (found) return found;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
function assertReadScopeCannotVacate(scope, objectName) {
|
|
894
|
+
const found = findEmptyMembership(scope, false, "");
|
|
895
|
+
if (found === null) return;
|
|
896
|
+
if (found.kind === "nin") {
|
|
897
|
+
throw readScopeCompileError(
|
|
898
|
+
`[read-scope-sql] read scope for "${objectName}" has an empty $nin at ${found.path} \u2014 an empty exclusion excludes nothing, so the engine lowers that clause to constant TRUE and the scope does not bind as written. Refused at every polarity, matching this module's own $nin arm (fail-closed).`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
throw readScopeCompileError(
|
|
902
|
+
`[read-scope-sql] read scope for "${objectName}" has an empty $in under negation at ${found.path} \u2014 an empty membership matches nothing, so its negation matches every row and the read scope admits the whole table (fail-closed).`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
function compileSub(node, qAlias, opts) {
|
|
728
906
|
const params = [];
|
|
729
|
-
const sql = compileNode(node, qAlias, params);
|
|
907
|
+
const sql = compileNode(node, qAlias, params, opts);
|
|
730
908
|
return { sql, params };
|
|
731
909
|
}
|
|
732
|
-
function compileNode(node, qAlias, params) {
|
|
910
|
+
function compileNode(node, qAlias, params, opts) {
|
|
733
911
|
if (!isFilterNode(node)) {
|
|
734
912
|
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
735
913
|
}
|
|
@@ -743,7 +921,7 @@ function compileNode(node, qAlias, params) {
|
|
|
743
921
|
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
744
922
|
continue;
|
|
745
923
|
}
|
|
746
|
-
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
924
|
+
const compiled = value.map((child) => compileSub(child, qAlias, opts));
|
|
747
925
|
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
748
926
|
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
749
927
|
if (kept.length === 0) continue;
|
|
@@ -752,7 +930,7 @@ function compileNode(node, qAlias, params) {
|
|
|
752
930
|
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
753
931
|
} else if (key === "$not") {
|
|
754
932
|
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
755
|
-
const inner = compileSub(operand, qAlias);
|
|
933
|
+
const inner = compileSub(operand, qAlias, opts);
|
|
756
934
|
if (inner.sql.length === 0) {
|
|
757
935
|
clauses.push(FALSE_CLAUSE);
|
|
758
936
|
} else {
|
|
@@ -762,12 +940,12 @@ function compileNode(node, qAlias, params) {
|
|
|
762
940
|
} else if (key.startsWith("$")) {
|
|
763
941
|
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
764
942
|
} else {
|
|
765
|
-
clauses.push(compileField(key, value, qAlias, params));
|
|
943
|
+
clauses.push(compileField(key, value, qAlias, params, opts));
|
|
766
944
|
}
|
|
767
945
|
}
|
|
768
946
|
return clauses.join(" AND ");
|
|
769
947
|
}
|
|
770
|
-
function compileField(field, value, qAlias, params) {
|
|
948
|
+
function compileField(field, value, qAlias, params, opts) {
|
|
771
949
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
772
950
|
assertDefinedComparands2(field, value);
|
|
773
951
|
assertBooleanFlagComparands(field, value);
|
|
@@ -787,7 +965,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
787
965
|
}
|
|
788
966
|
const parts = [];
|
|
789
967
|
for (const op of keys) {
|
|
790
|
-
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
968
|
+
parts.push(compileOperator(col, op, ops[op], field, params, opts));
|
|
791
969
|
}
|
|
792
970
|
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
793
971
|
}
|
|
@@ -795,8 +973,20 @@ function bind(params, v) {
|
|
|
795
973
|
params.push(v);
|
|
796
974
|
return "?";
|
|
797
975
|
}
|
|
798
|
-
function
|
|
799
|
-
return
|
|
976
|
+
function textMatch(col, shape, val, negate, params, opts, fold = false) {
|
|
977
|
+
return textMatchPredicateSql({
|
|
978
|
+
dialect: normalizeSqlDialect(opts.dialect),
|
|
979
|
+
column: col,
|
|
980
|
+
shape,
|
|
981
|
+
value: val,
|
|
982
|
+
negate,
|
|
983
|
+
fold,
|
|
984
|
+
bind: (v) => bind(params, v)
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
function textOverNonTextColumn(op, field, opts) {
|
|
988
|
+
if (!opts.nonTextColumn || !opts.nonTextColumn(field)) return null;
|
|
989
|
+
return textOperatorPolarity(op) === "negative" ? TRUE_CLAUSE : FALSE_CLAUSE;
|
|
800
990
|
}
|
|
801
991
|
function nullSafeNegative(col, test) {
|
|
802
992
|
return `(${col} IS NULL OR ${test})`;
|
|
@@ -861,7 +1051,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
|
|
|
861
1051
|
});
|
|
862
1052
|
}
|
|
863
1053
|
}
|
|
864
|
-
function compileOperator(col, op, val, field, params) {
|
|
1054
|
+
function compileOperator(col, op, val, field, params, opts) {
|
|
865
1055
|
switch (op) {
|
|
866
1056
|
case "$eq":
|
|
867
1057
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
@@ -885,7 +1075,7 @@ function compileOperator(col, op, val, field, params) {
|
|
|
885
1075
|
}
|
|
886
1076
|
case "$nin": {
|
|
887
1077
|
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
888
|
-
if (val.length === 0)
|
|
1078
|
+
if (val.length === 0) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" is empty \u2014 an empty exclusion excludes nothing and would compile the read scope to constant TRUE (fail-closed).`);
|
|
889
1079
|
assertCompilableMembers(op, field, val);
|
|
890
1080
|
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
891
1081
|
}
|
|
@@ -895,12 +1085,19 @@ function compileOperator(col, op, val, field, params) {
|
|
|
895
1085
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
896
1086
|
}
|
|
897
1087
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
898
|
-
// character is bound with it. See {@link
|
|
1088
|
+
// character is bound with it. See {@link textMatch}.
|
|
899
1089
|
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
900
|
-
// BEFORE
|
|
1090
|
+
// BEFORE a pattern is built from it — see {@link assertRenderableText}.
|
|
1091
|
+
// [#14079] Every text arm asks {@link textOverNonTextColumn} AFTER its
|
|
1092
|
+
// comparand gate and BEFORE it binds: a comparand the contract refuses is
|
|
1093
|
+
// still refused, and a column whose stored value is never text gets the
|
|
1094
|
+
// contract's constant instead of a match over a number.
|
|
1095
|
+
// [#15684] …and the four case-EXACT arms take their construct from the
|
|
1096
|
+
// DIALECT ({@link textMatch}): a plain `LIKE` folds ASCII case on SQLite,
|
|
1097
|
+
// so this scope ADMITTED rows the policy excludes — over-reach (#3948).
|
|
901
1098
|
case "$contains":
|
|
902
1099
|
assertRenderableText(op, field, val);
|
|
903
|
-
return
|
|
1100
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
|
|
904
1101
|
/**
|
|
905
1102
|
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
906
1103
|
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
@@ -916,23 +1113,39 @@ function compileOperator(col, op, val, field, params) {
|
|
|
916
1113
|
* the rows already lower-case — and on a read scope that is a row set the
|
|
917
1114
|
* policy author never wrote, in the narrowing direction here but in the
|
|
918
1115
|
* WIDENING direction under a `$not`.
|
|
1116
|
+
*
|
|
1117
|
+
* [#15780] …and WHICH fold is the DIALECT's answer, exactly as the keyword
|
|
1118
|
+
* is for the case-exact arms. This line used to spell its own binds and
|
|
1119
|
+
* emit `translate()` unconditionally, on the reasoning that a
|
|
1120
|
+
* case-INSENSITIVE operator never wants the per-dialect case-EXACT
|
|
1121
|
+
* construct. The first half of that was right and the second half hid the
|
|
1122
|
+
* defect: `translate()` is Postgres/Oracle, so on a SQLite datasource this
|
|
1123
|
+
* read scope compiled to a statement the engine could not PARSE — an RLS
|
|
1124
|
+
* policy that cannot be evaluated at all. It goes through
|
|
1125
|
+
* {@link textMatch} now with `fold` set, which keeps `translate()` on
|
|
1126
|
+
* Postgres, emits `lower(col) GLOB lower(?)` on SQLite, the nested-
|
|
1127
|
+
* `REPLACE` binary fold on MySQL and — [#16028] — the same `REPLACE` chain
|
|
1128
|
+
* without the cast on the `unknown` residue, because a datasource whose
|
|
1129
|
+
* dialect nothing answered can BE SQLite and `translate()` failed to parse
|
|
1130
|
+
* there just as loudly through this compiler as through the other two. The
|
|
1131
|
+
* `ESCAPE`
|
|
1132
|
+
* binding is still never folded — the construct table owns that, and the
|
|
1133
|
+
* SQLite arm has no `ESCAPE` clause to bind at all.
|
|
919
1134
|
*/
|
|
920
|
-
case "$icontains":
|
|
1135
|
+
case "$icontains":
|
|
921
1136
|
assertRenderableText(op, field, val);
|
|
922
|
-
|
|
923
|
-
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
924
|
-
}
|
|
1137
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
|
|
925
1138
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
926
1139
|
// contain" is true of a value that is not there.
|
|
927
1140
|
case "$notContains":
|
|
928
1141
|
assertRenderableText(op, field, val);
|
|
929
|
-
return
|
|
1142
|
+
return textOverNonTextColumn(op, field, opts) ?? nullSafeNegative(col, textMatch(col, "contains", val, true, params, opts));
|
|
930
1143
|
case "$startsWith":
|
|
931
1144
|
assertRenderableText(op, field, val);
|
|
932
|
-
return
|
|
1145
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
|
|
933
1146
|
case "$endsWith":
|
|
934
1147
|
assertRenderableText(op, field, val);
|
|
935
|
-
return
|
|
1148
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
|
|
936
1149
|
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
937
1150
|
// refused anything else at {@link compileField}, before this emitter runs.
|
|
938
1151
|
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
@@ -1363,7 +1576,11 @@ var NativeSQLStrategy = class {
|
|
|
1363
1576
|
if (typeof ctx.getReadScope !== "function") return;
|
|
1364
1577
|
const filter = ctx.getReadScope(objectName);
|
|
1365
1578
|
if (filter === void 0 || filter === null) return;
|
|
1366
|
-
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias
|
|
1579
|
+
const { sql, params: scopeParams } = compileScopedFilterToSql(filter, alias, {
|
|
1580
|
+
nonTextColumn: nonTextColumnResolver(ctx, objectName),
|
|
1581
|
+
dialect: sqlDialectFor(ctx, objectName)
|
|
1582
|
+
});
|
|
1583
|
+
assertReadScopeCannotVacate(filter, objectName);
|
|
1367
1584
|
if (!sql) return;
|
|
1368
1585
|
let i = 0;
|
|
1369
1586
|
const rendered = sql.replace(/\?/g, () => {
|
|
@@ -1640,12 +1857,16 @@ var NativeSQLStrategy = class {
|
|
|
1640
1857
|
gte: ">=",
|
|
1641
1858
|
lt: "<",
|
|
1642
1859
|
lte: "<=",
|
|
1860
|
+
// [#15684 / #15780] For every text operator these entries are the
|
|
1861
|
+
// OPERATOR GATE, not the emitted keyword: `text-match-sql.ts` picks
|
|
1862
|
+
// `LIKE` or `GLOB` per dialect below. ⛔ Reading these five as the
|
|
1863
|
+
// emitted SQL is exactly the mistake #15780 was — `$icontains` was the
|
|
1864
|
+
// last row still emitting the keyword written here, together with a
|
|
1865
|
+
// `translate()` fold SQLite cannot parse.
|
|
1643
1866
|
contains: "LIKE",
|
|
1644
1867
|
notContains: "NOT LIKE",
|
|
1645
1868
|
startsWith: "LIKE",
|
|
1646
1869
|
endsWith: "LIKE",
|
|
1647
|
-
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1648
|
-
// the ASCII fold applied below, not the keyword.
|
|
1649
1870
|
icontains: "LIKE"
|
|
1650
1871
|
};
|
|
1651
1872
|
const likeShape = {
|
|
@@ -1671,13 +1892,22 @@ var NativeSQLStrategy = class {
|
|
|
1671
1892
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
1672
1893
|
const shape = likeShape[operator];
|
|
1673
1894
|
if (shape) {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
if (operator === "icontains") {
|
|
1678
|
-
return `${asciiLowerSqlExpr(rawCol)} ${sqlOp} ${asciiLowerSqlExpr(patternRef)} ESCAPE $${params.length}`;
|
|
1895
|
+
const polarity = textOperatorPolarity(operator);
|
|
1896
|
+
if (polarity && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
|
|
1897
|
+
return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1679
1898
|
}
|
|
1680
|
-
return
|
|
1899
|
+
return textMatchPredicateSql({
|
|
1900
|
+
dialect: sqlDialectFor(ctx, target.object),
|
|
1901
|
+
column: rawCol,
|
|
1902
|
+
shape,
|
|
1903
|
+
value: values[0],
|
|
1904
|
+
negate: operator === "notContains",
|
|
1905
|
+
fold: operator === "icontains",
|
|
1906
|
+
bind: (v) => {
|
|
1907
|
+
params.push(v);
|
|
1908
|
+
return `$${params.length}`;
|
|
1909
|
+
}
|
|
1910
|
+
});
|
|
1681
1911
|
}
|
|
1682
1912
|
if (operator === "lte") {
|
|
1683
1913
|
const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
|
|
@@ -1710,7 +1940,7 @@ var NativeSQLStrategy = class {
|
|
|
1710
1940
|
};
|
|
1711
1941
|
|
|
1712
1942
|
// src/strategies/objectql-strategy.ts
|
|
1713
|
-
var
|
|
1943
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
1714
1944
|
var import_core3 = require("@objectstack/core");
|
|
1715
1945
|
|
|
1716
1946
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1778,15 +2008,25 @@ var SCALAR_SQL_OPS = {
|
|
|
1778
2008
|
lte: "<="
|
|
1779
2009
|
};
|
|
1780
2010
|
var LIKE_SQL_OPS = {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1787
|
-
//
|
|
1788
|
-
//
|
|
1789
|
-
|
|
2011
|
+
// [#15684 / #15780] This table carries NO keyword, deliberately. Every row's
|
|
2012
|
+
// construct comes from the DIALECT (`text-match-sql.ts`): `GLOB` on SQLite,
|
|
2013
|
+
// `LIKE` over `CAST(… AS BINARY)` on MySQL, plain `LIKE` on Postgres, since a
|
|
2014
|
+
// plain `LIKE` is case-exact on Postgres alone. #15684 kept a `sql` field
|
|
2015
|
+
// here for the FOLDING row, which was the last row still emitting a keyword
|
|
2016
|
+
// written locally — together with the `translate()` fold that could not parse
|
|
2017
|
+
// on SQLite. #15780 moved that row onto the table too, so the field had no
|
|
2018
|
+
// reader left, and a dead field named `sql` sitting beside a compiler is an
|
|
2019
|
+
// invitation to read it as the emitted keyword.
|
|
2020
|
+
//
|
|
2021
|
+
// What survives here is only what the construct table cannot derive from the
|
|
2022
|
+
// operator name: which POLARITY the row is (`negate`) and whether it FOLDS
|
|
2023
|
+
// (`fold`), each spelled once. `fold` is on `icontains` ALONE — the four
|
|
2024
|
+
// above it are case-SENSITIVE by ruling (#4706 Q2 = A).
|
|
2025
|
+
contains: { shape: "contains" },
|
|
2026
|
+
notContains: { shape: "contains", negate: true },
|
|
2027
|
+
startsWith: { shape: "starts" },
|
|
2028
|
+
endsWith: { shape: "ends" },
|
|
2029
|
+
icontains: { shape: "contains", fold: true }
|
|
1790
2030
|
};
|
|
1791
2031
|
var ObjectQLStrategy = class {
|
|
1792
2032
|
constructor() {
|
|
@@ -1817,11 +2057,16 @@ var ObjectQLStrategy = class {
|
|
|
1817
2057
|
for (const [dim, gran] of granByDim) {
|
|
1818
2058
|
groupBy.push({ field: this.resolveFieldName(cube, dim, "dimension"), dateGranularity: gran });
|
|
1819
2059
|
}
|
|
2060
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1820
2061
|
const aggregations = [];
|
|
1821
2062
|
if (query.measures && query.measures.length > 0) {
|
|
1822
2063
|
for (const measure of query.measures) {
|
|
1823
2064
|
const { field, method } = this.resolveMeasureAggregation(cube, measure);
|
|
1824
|
-
|
|
2065
|
+
const measureFilter = datasetScope?.measureFilters?.[measure];
|
|
2066
|
+
const filterCondition = measureFilter ? this.filterNodeToCondition(normalizeAnalyticsFilterTree({ where: measureFilter }), cube) : null;
|
|
2067
|
+
aggregations.push(
|
|
2068
|
+
filterCondition ? { field, method, alias: measure, filter: filterCondition } : { field, method, alias: measure }
|
|
2069
|
+
);
|
|
1825
2070
|
}
|
|
1826
2071
|
}
|
|
1827
2072
|
const filter = {};
|
|
@@ -1831,7 +2076,6 @@ var ObjectQLStrategy = class {
|
|
|
1831
2076
|
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
1832
2077
|
if (extra) conjuncts.push(extra);
|
|
1833
2078
|
}
|
|
1834
|
-
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1835
2079
|
if (datasetScope?.filter) {
|
|
1836
2080
|
const scopeCondition = this.filterNodeToCondition(
|
|
1837
2081
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
@@ -1920,6 +2164,7 @@ var ObjectQLStrategy = class {
|
|
|
1920
2164
|
}
|
|
1921
2165
|
const tableName = this.extractObjectName(cube);
|
|
1922
2166
|
const plan = this.planCrossObject(cube, query, this.filterMemberView(cube, query, ctx));
|
|
2167
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
1923
2168
|
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
1924
2169
|
const joinClauses = [];
|
|
1925
2170
|
const dimExpr = (dim) => {
|
|
@@ -1950,7 +2195,9 @@ var ObjectQLStrategy = class {
|
|
|
1950
2195
|
if (query.measures) {
|
|
1951
2196
|
for (const m of query.measures) {
|
|
1952
2197
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
1953
|
-
const
|
|
2198
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2199
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
|
|
2200
|
+
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
1954
2201
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
1955
2202
|
}
|
|
1956
2203
|
}
|
|
@@ -1958,15 +2205,16 @@ var ObjectQLStrategy = class {
|
|
|
1958
2205
|
const filterClause = this.renderFilterNodeSql(
|
|
1959
2206
|
normalizeAnalyticsFilterTree(query),
|
|
1960
2207
|
cube,
|
|
1961
|
-
params
|
|
2208
|
+
params,
|
|
2209
|
+
ctx
|
|
1962
2210
|
);
|
|
1963
2211
|
if (filterClause) whereParts.push(filterClause);
|
|
1964
|
-
|
|
1965
|
-
if (echoedDatasetFilter) {
|
|
2212
|
+
if (datasetScope?.filter) {
|
|
1966
2213
|
const scopeSql = this.renderFilterNodeSql(
|
|
1967
|
-
normalizeAnalyticsFilterTree({ where:
|
|
2214
|
+
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
1968
2215
|
cube,
|
|
1969
|
-
params
|
|
2216
|
+
params,
|
|
2217
|
+
ctx
|
|
1970
2218
|
);
|
|
1971
2219
|
if (scopeSql) whereParts.push(scopeSql);
|
|
1972
2220
|
}
|
|
@@ -1979,7 +2227,11 @@ var ObjectQLStrategy = class {
|
|
|
1979
2227
|
}
|
|
1980
2228
|
const scope = ctx.getReadScope?.(tableName);
|
|
1981
2229
|
if (scope != null) {
|
|
1982
|
-
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName
|
|
2230
|
+
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName, {
|
|
2231
|
+
nonTextColumn: nonTextColumnResolver(ctx, tableName),
|
|
2232
|
+
dialect: sqlDialectFor(ctx, tableName)
|
|
2233
|
+
});
|
|
2234
|
+
assertReadScopeCannotVacate(scope, tableName);
|
|
1983
2235
|
if (scopeSql) {
|
|
1984
2236
|
let i = 0;
|
|
1985
2237
|
const rendered = scopeSql.replace(/\?/g, () => {
|
|
@@ -2022,11 +2274,12 @@ var ObjectQLStrategy = class {
|
|
|
2022
2274
|
* predicate. `$and` makes that structurally impossible.
|
|
2023
2275
|
*/
|
|
2024
2276
|
withReadScope(objectName, filter, ctx) {
|
|
2025
|
-
const userFilter = Object.keys(filter).length > 0 ? (0,
|
|
2277
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data5.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
2026
2278
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
2027
2279
|
const scope = ctx.getReadScope(objectName);
|
|
2028
2280
|
if (scope === void 0 || scope === null) return userFilter;
|
|
2029
|
-
|
|
2281
|
+
assertReadScopeCannotVacate(scope, objectName);
|
|
2282
|
+
const scopeFilter = (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
|
|
2030
2283
|
if (!userFilter) return scopeFilter;
|
|
2031
2284
|
return { $and: [userFilter, scopeFilter] };
|
|
2032
2285
|
}
|
|
@@ -2054,7 +2307,7 @@ var ObjectQLStrategy = class {
|
|
|
2054
2307
|
* the members inside were unreadable from the outside and the envelope check
|
|
2055
2308
|
* could not reject what it could not see.
|
|
2056
2309
|
*
|
|
2057
|
-
* ##
|
|
2310
|
+
* ## Three producers, one inventory (#10861, #11461)
|
|
2058
2311
|
*
|
|
2059
2312
|
* The caller's `where` is not the only thing that reaches `engine.aggregate`
|
|
2060
2313
|
* as a predicate. Since PR #10758 the compiled dataset's own definition-level
|
|
@@ -2069,6 +2322,36 @@ var ObjectQLStrategy = class {
|
|
|
2069
2322
|
* which driver will serve the dataset and would refuse a dataset that is
|
|
2070
2323
|
* perfectly legal on a native-SQL deployment.
|
|
2071
2324
|
*
|
|
2325
|
+
* [#11461] #10413 phase 2 then added a THIRD producer with the same reach and
|
|
2326
|
+
* none of the coverage: a compiled measure's own `filter`, lowered onto that
|
|
2327
|
+
* measure's `aggregations[].filter` entry (#10576). This view enumerated two
|
|
2328
|
+
* origins, so the third was invisible to the envelope check and the arm of
|
|
2329
|
+
* `planCrossObject` that inspects `query.measures` reads only each measure's
|
|
2330
|
+
* resolved FIELD, never its filter. Measured on the unfixed tree, one fixture,
|
|
2331
|
+
* both doors:
|
|
2332
|
+
*
|
|
2333
|
+
* ```
|
|
2334
|
+
* BEFORE execute() ACCEPTED -> aggregations: [{field:"*",method:"count",
|
|
2335
|
+
* alias:"west_count",
|
|
2336
|
+
* filter:{"account.region":"West"}}]
|
|
2337
|
+
* -> rows [{stage:"won",total_count:3,west_count:0}]
|
|
2338
|
+
* (the truthful west_count is 2; total_count
|
|
2339
|
+
* is right, so the wrong number arrived in
|
|
2340
|
+
* the same response shape as the right one)
|
|
2341
|
+
* generateSql() ACCEPTED -> COUNT(CASE WHEN account.region = $1 THEN 1 END)
|
|
2342
|
+
* over a FROM with no join in it at all
|
|
2343
|
+
* AFTER both doors REFUSED INVALID_FIELD / 400, engine never reached
|
|
2344
|
+
* ```
|
|
2345
|
+
*
|
|
2346
|
+
* The same maintainer ruling covers it — same hazard, same physical verdict,
|
|
2347
|
+
* one more producer — so it folds in HERE for the #10861 reason and not into
|
|
2348
|
+
* `dataset-compiler.ts`, which still cannot see which driver will serve the
|
|
2349
|
+
* dataset. Only the REQUESTED measures are folded: both doors' aggregation
|
|
2350
|
+
* loops read `measureFilters[m]` for `m of query.measures` and nothing else,
|
|
2351
|
+
* so a filter declared on a measure this query never asks for reaches no
|
|
2352
|
+
* engine, and refusing on it would reject a query for a member that was never
|
|
2353
|
+
* going to be evaluated.
|
|
2354
|
+
*
|
|
2072
2355
|
* Structure is discarded on purpose — a member is cross-object or it is not,
|
|
2073
2356
|
* and which branch of a disjunction it sits in cannot make
|
|
2074
2357
|
* `engine.aggregate` able to join it. PROVENANCE is not discarded, because it
|
|
@@ -2078,9 +2361,12 @@ var ObjectQLStrategy = class {
|
|
|
2078
2361
|
* `planCrossObject`. The value slot carries that and nothing else; it never
|
|
2079
2362
|
* reaches a driver.
|
|
2080
2363
|
*
|
|
2081
|
-
*
|
|
2082
|
-
*
|
|
2083
|
-
*
|
|
2364
|
+
* Insertion order is measure-filter, then dataset-filter, then `where`, and
|
|
2365
|
+
* last write wins on a duplicate key. Two things follow, in that order of
|
|
2366
|
+
* importance. A member named by the request too keeps the CALLER's provenance,
|
|
2367
|
+
* because if it is in the request that is the actionable place to fix it. And
|
|
2368
|
+
* every shape that was refused before #11461 keeps the exact message it had:
|
|
2369
|
+
* the new origin can only ever win a key no older producer names.
|
|
2084
2370
|
*
|
|
2085
2371
|
* Time-dimension WINDOWS are deliberately absent (they live in
|
|
2086
2372
|
* `dateRangeBounds`, not in `where`). They need no arm here: a cross-object
|
|
@@ -2090,13 +2376,21 @@ var ObjectQLStrategy = class {
|
|
|
2090
2376
|
* diagnostic and the reason that loop runs first.
|
|
2091
2377
|
*/
|
|
2092
2378
|
filterMemberView(cube, query, ctx) {
|
|
2093
|
-
const
|
|
2379
|
+
const datasetScope = ctx.getDatasetScope?.(query.cube);
|
|
2094
2380
|
const leaves = (node, origin) => collectFilterLeaves(node).map(
|
|
2095
2381
|
(f) => [this.resolveFieldName(cube, f.member, "any"), origin]
|
|
2096
2382
|
);
|
|
2383
|
+
const measureLeaves = (query.measures ?? []).flatMap((m) => {
|
|
2384
|
+
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2385
|
+
return measureFilter ? leaves(
|
|
2386
|
+
normalizeAnalyticsFilterTree({ where: measureFilter }),
|
|
2387
|
+
{ kind: "measure-filter", measure: m }
|
|
2388
|
+
) : [];
|
|
2389
|
+
});
|
|
2097
2390
|
return Object.fromEntries([
|
|
2098
|
-
...
|
|
2099
|
-
...leaves(normalizeAnalyticsFilterTree(
|
|
2391
|
+
...measureLeaves,
|
|
2392
|
+
...datasetScope?.filter ? leaves(normalizeAnalyticsFilterTree({ where: datasetScope.filter }), { kind: "dataset-filter" }) : [],
|
|
2393
|
+
...leaves(normalizeAnalyticsFilterTree(query), { kind: "where" })
|
|
2100
2394
|
]);
|
|
2101
2395
|
}
|
|
2102
2396
|
/**
|
|
@@ -2111,18 +2405,20 @@ var ObjectQLStrategy = class {
|
|
|
2111
2405
|
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
2112
2406
|
* (needs a real join to evaluate), a cross-object leaf in the DATASET's own
|
|
2113
2407
|
* definition-level `filter` (#10861 — same join it does not have, arriving
|
|
2114
|
-
* from the producer PR #10758 added), a
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2408
|
+
* from the producer PR #10758 added), a cross-object leaf in ONE MEASURE's own
|
|
2409
|
+
* `filter` (#11461 — the same join again, arriving from the producer #10413
|
|
2410
|
+
* phase 2 added), a MULTI-HOP dimension (`a.b.c`), or a non-recombinable
|
|
2411
|
+
* measure (`avg`/`count_distinct`, whose sub-bucket values cannot be merged).
|
|
2412
|
+
* A loud error beats the silent mis-bucket #3654 kills.
|
|
2117
2413
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set
|
|
2118
2414
|
* — and since #10759 both callers derive `filter` from the one
|
|
2119
2415
|
* {@link filterMemberView}, so that sentence is enforced by construction
|
|
2120
2416
|
* instead of restated at two call sites.
|
|
2121
2417
|
*
|
|
2122
|
-
* [#5716] All
|
|
2418
|
+
* [#5716] All six refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
2123
2419
|
* 400, naming the member — and the four that predate #10861 keep their
|
|
2124
2420
|
* MESSAGES unchanged (they are good diagnostics, and #5923's tests read
|
|
2125
|
-
* them). Each is decided by two facts and nothing else: a member that will
|
|
2421
|
+
* them); so does #10861's own, which #11461 left untouched beside it. Each is decided by two facts and nothing else: a member that will
|
|
2126
2422
|
* reach the engine's predicate, and whether that member resolves across a
|
|
2127
2423
|
* join. Neither is an internal invariant — a cube where the member exists and
|
|
2128
2424
|
* a driver that could serve it are both perfectly ordinary, which is exactly
|
|
@@ -2131,14 +2427,15 @@ var ObjectQLStrategy = class {
|
|
|
2131
2427
|
* because the fix is always to change or drop ONE named member, and because
|
|
2132
2428
|
* four of them fire on `/analytics/query` where no dataset exists.
|
|
2133
2429
|
*
|
|
2134
|
-
* [#10861] The fifth
|
|
2135
|
-
* it:
|
|
2136
|
-
* here whose member no request key named — so
|
|
2137
|
-
* `param`, and says in its own words which document to
|
|
2430
|
+
* [#10861, #11461] The fifth and sixth are the exceptions that prove the rule
|
|
2431
|
+
* and are written to it: they can only fire where a dataset DOES exist, and
|
|
2432
|
+
* they are the two refusals here whose member no request key named — so each
|
|
2433
|
+
* carries `cube` and no `param`, and says in its own words which document to
|
|
2434
|
+
* go and edit, the sixth naming the MEASURE inside it as well. Both stay
|
|
2138
2435
|
* `INVALID_FIELD` rather than becoming `DATASET_INVALID` because the verdict
|
|
2139
|
-
* is the same physical one as
|
|
2436
|
+
* is the same physical one as their neighbours — this engine cannot join this
|
|
2140
2437
|
* member — and splitting the code by PROVENANCE would make a caller branch on
|
|
2141
|
-
*
|
|
2438
|
+
* three wire shapes for one capability limit.
|
|
2142
2439
|
*
|
|
2143
2440
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
2144
2441
|
* flattens to a real column is treated as base, not cross-object.
|
|
@@ -2160,7 +2457,7 @@ var ObjectQLStrategy = class {
|
|
|
2160
2457
|
member: m,
|
|
2161
2458
|
field: this.resolveMeasureAggregation(cube, m).field
|
|
2162
2459
|
})),
|
|
2163
|
-
...Object.entries(filter).filter(([, origin]) => origin === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2460
|
+
...Object.entries(filter).filter(([, origin]) => origin.kind === "where").map(([f]) => ({ where: "filter", member: f, field: f }))
|
|
2164
2461
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
2165
2462
|
if (nonDim.length > 0) {
|
|
2166
2463
|
throw invalidMemberError(
|
|
@@ -2174,13 +2471,23 @@ var ObjectQLStrategy = class {
|
|
|
2174
2471
|
}
|
|
2175
2472
|
);
|
|
2176
2473
|
}
|
|
2177
|
-
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2474
|
+
const scopeCross = Object.entries(filter).filter(([field, origin]) => origin.kind === "dataset-filter" && this.isCrossObjectField(cube, field, baseObject)).map(([field]) => field);
|
|
2178
2475
|
if (scopeCross.length > 0) {
|
|
2179
2476
|
throw invalidMemberError(
|
|
2180
2477
|
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${scopeCross[0]}") that dataset "${cube.name}" declares at its definition level \u2014 the engine cannot join in an aggregate, so this predicate matches nothing and the answer would be neither the scoped number nor the unscoped one. Nothing in the request names it: remove the cross-object leaf from the dataset's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2181
2478
|
{ member: scopeCross[0], cube: cube.name }
|
|
2182
2479
|
);
|
|
2183
2480
|
}
|
|
2481
|
+
const measureCross = Object.entries(filter).flatMap(
|
|
2482
|
+
([field, origin]) => origin.kind === "measure-filter" && this.isCrossObjectField(cube, field, baseObject) ? [{ field, measure: origin.measure }] : []
|
|
2483
|
+
);
|
|
2484
|
+
if (measureCross.length > 0) {
|
|
2485
|
+
const { field, measure } = measureCross[0];
|
|
2486
|
+
throw invalidMemberError(
|
|
2487
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the cross-object filter ("${field}") that dataset "${cube.name}" declares on its measure "${measure}" \u2014 the engine cannot join in an aggregate, so this measure would be counted over a predicate that matches nothing and would answer 0 rather than the scoped number. Remove the cross-object leaf from that measure's own \`filter\`, or serve this dataset on a native-SQL driver, where the same definition is valid.`,
|
|
2488
|
+
{ member: field, cube: cube.name }
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2184
2491
|
const crossDims = [];
|
|
2185
2492
|
for (const dim of query.dimensions ?? []) {
|
|
2186
2493
|
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
@@ -2284,7 +2591,8 @@ var ObjectQLStrategy = class {
|
|
|
2284
2591
|
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
2285
2592
|
const idFilter = { id: { $in: fkValues } };
|
|
2286
2593
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2287
|
-
if (scope != null) (
|
|
2594
|
+
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
|
|
2595
|
+
if (scope != null) (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
|
|
2288
2596
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2289
2597
|
const rows = await ctx.executeAggregate(refObject, {
|
|
2290
2598
|
groupBy: ["id", attr],
|
|
@@ -2297,6 +2605,45 @@ var ObjectQLStrategy = class {
|
|
|
2297
2605
|
}
|
|
2298
2606
|
return map;
|
|
2299
2607
|
}
|
|
2608
|
+
/**
|
|
2609
|
+
* A measure's aggregate, restricted to the rows its own `filter` admits
|
|
2610
|
+
* (#10413 phase 2) — the same six functions `generateSql`'s unconditional
|
|
2611
|
+
* branch renders, wrapped in a `CASE WHEN`.
|
|
2612
|
+
*
|
|
2613
|
+
* Spelled `CASE WHEN` rather than SQL-standard `FILTER (WHERE …)`, mirroring
|
|
2614
|
+
* `NativeSQLStrategy.CONDITIONAL_AGGREGATE_SQL`: this string is DOCUMENTATION
|
|
2615
|
+
* of an execution that really goes through `engine.aggregate`'s per-driver
|
|
2616
|
+
* `aggregations[].filter` lowering (#10576), not a statement this class runs
|
|
2617
|
+
* itself, so there is no reason to pick a dialect-restricted spelling over
|
|
2618
|
+
* the portable one the SQL-executing sibling already settled on.
|
|
2619
|
+
*
|
|
2620
|
+
* `count` over `*` counts a constant (`COUNT(CASE WHEN p THEN 1 END)`, since
|
|
2621
|
+
* `COUNT(CASE WHEN p THEN * END)` is not valid SQL); over a real column it
|
|
2622
|
+
* counts that column's non-null values among the admitted rows.
|
|
2623
|
+
*/
|
|
2624
|
+
conditionalAggregateSql(method, col, pred) {
|
|
2625
|
+
const target = col === "*" ? "1" : col;
|
|
2626
|
+
switch (method) {
|
|
2627
|
+
case "count":
|
|
2628
|
+
return `COUNT(CASE WHEN ${pred} THEN ${target} END)`;
|
|
2629
|
+
case "count_distinct":
|
|
2630
|
+
return `COUNT(DISTINCT CASE WHEN ${pred} THEN ${col} END)`;
|
|
2631
|
+
case "sum":
|
|
2632
|
+
return `SUM(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2633
|
+
case "avg":
|
|
2634
|
+
return `AVG(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2635
|
+
case "min":
|
|
2636
|
+
return `MIN(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2637
|
+
case "max":
|
|
2638
|
+
return `MAX(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2639
|
+
// Closed vocabulary, same posture as `resolveMeasureAggregation`'s
|
|
2640
|
+
// callers: `method` comes only from that function, whose own aggTypes
|
|
2641
|
+
// list is exactly these six, so this default is unreachable rather than
|
|
2642
|
+
// a silent fallback for a method this table forgot.
|
|
2643
|
+
default:
|
|
2644
|
+
return `${method.toUpperCase()}(CASE WHEN ${pred} THEN ${col} END)`;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2300
2647
|
/**
|
|
2301
2648
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
2302
2649
|
*
|
|
@@ -2317,7 +2664,7 @@ var ObjectQLStrategy = class {
|
|
|
2317
2664
|
* not render that operator": #5333 was exactly that conflation, and an
|
|
2318
2665
|
* unrenderable operator now THROWS (see the exit below).
|
|
2319
2666
|
*/
|
|
2320
|
-
buildFilterClauseSql(col, operator, values, params) {
|
|
2667
|
+
buildFilterClauseSql(col, operator, values, params, target, ctx) {
|
|
2321
2668
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
2322
2669
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
2323
2670
|
if (!values || values.length === 0) return null;
|
|
@@ -2330,12 +2677,22 @@ var ObjectQLStrategy = class {
|
|
|
2330
2677
|
}
|
|
2331
2678
|
const like = LIKE_SQL_OPS[operator];
|
|
2332
2679
|
if (like) {
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2680
|
+
const polarity = textOperatorPolarity(operator);
|
|
2681
|
+
if (polarity && target && ctx && nonTextColumnResolver(ctx, target.object)?.(target.field)) {
|
|
2682
|
+
return polarity === "negative" ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
2683
|
+
}
|
|
2684
|
+
return textMatchPredicateSql({
|
|
2685
|
+
dialect: target && ctx ? sqlDialectFor(ctx, target.object) : "unknown",
|
|
2686
|
+
column: col,
|
|
2687
|
+
shape: like.shape,
|
|
2688
|
+
value: values[0],
|
|
2689
|
+
negate: like.negate === true,
|
|
2690
|
+
fold: like.fold === true,
|
|
2691
|
+
bind: (v) => {
|
|
2692
|
+
params.push(v);
|
|
2693
|
+
return `$${params.length}`;
|
|
2694
|
+
}
|
|
2695
|
+
});
|
|
2339
2696
|
}
|
|
2340
2697
|
const op = SCALAR_SQL_OPS[operator];
|
|
2341
2698
|
if (!op) {
|
|
@@ -2374,6 +2731,29 @@ var ObjectQLStrategy = class {
|
|
|
2374
2731
|
}
|
|
2375
2732
|
return void 0;
|
|
2376
2733
|
}
|
|
2734
|
+
/**
|
|
2735
|
+
* [#14079] The (object, field) a filter member binds against — the echo's
|
|
2736
|
+
* copy of `NativeSQLStrategy.resolveStorageTarget`, kept beside the
|
|
2737
|
+
* `LIKE_SQL_OPS` table for the same reason that table is a copy: this file
|
|
2738
|
+
* renders a description of the statement THAT compiler produces, and the
|
|
2739
|
+
* declared-type test both apply is keyed by object and field. A dotted
|
|
2740
|
+
* `sql` is a relationship path (ADR-0071): every segment but the last is a
|
|
2741
|
+
* hop whose join alias is the dot-to-`__` spelling the dataset compiler keys
|
|
2742
|
+
* `cube.joins` by, the last is the column.
|
|
2743
|
+
*/
|
|
2744
|
+
resolveStorageTarget(cube, member, baseObject) {
|
|
2745
|
+
const dim = this.lookupMember(cube, member, "dimension");
|
|
2746
|
+
const measure = dim ? void 0 : this.lookupMember(cube, member, "measure");
|
|
2747
|
+
const rawSql = dim?.sql ?? measure?.sql ?? (member.includes(".") ? member.split(".").slice(1).join(".") : member);
|
|
2748
|
+
if (rawSql.includes(".")) {
|
|
2749
|
+
const segments = rawSql.split(".");
|
|
2750
|
+
const field = segments[segments.length - 1];
|
|
2751
|
+
const relPath = segments.slice(0, -1).join(".");
|
|
2752
|
+
const object = cube.joins?.[relPath.replace(/\./g, "__")]?.name ?? relPath;
|
|
2753
|
+
return { object, field };
|
|
2754
|
+
}
|
|
2755
|
+
return { object: baseObject, field: rawSql.replace(/^\$/, "") };
|
|
2756
|
+
}
|
|
2377
2757
|
resolveFieldName(cube, member, kind) {
|
|
2378
2758
|
if (kind === "dimension" || kind === "any") {
|
|
2379
2759
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -2388,8 +2768,20 @@ var ObjectQLStrategy = class {
|
|
|
2388
2768
|
resolveMeasureAggregation(cube, measureName) {
|
|
2389
2769
|
const direct = this.lookupMember(cube, measureName, "measure");
|
|
2390
2770
|
if (direct) {
|
|
2771
|
+
if (EXPRESSION_METRIC_TYPES.has(direct.type)) {
|
|
2772
|
+
throw invalidMemberError(
|
|
2773
|
+
`[Analytics] ObjectQLStrategy cannot evaluate the custom-SQL measure ("${measureName}") \u2014 its type "${direct.type}" declares a raw SQL expression, which the engine aggregate AST cannot carry; served anyway it would answer null for every bucket under the measure's own name. Use an aggregate measure (count/sum/avg/min/max/count_distinct), or run on a native-SQL driver.`,
|
|
2774
|
+
{ member: measureName, param: "measures", cube: cube.name }
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2391
2777
|
return {
|
|
2392
2778
|
field: direct.sql.replace(/^\$/, ""),
|
|
2779
|
+
// The assertion, not a parse: for a CubeSchema-legal cube the type
|
|
2780
|
+
// partition above leaves exactly the six `AggregationFunction` values.
|
|
2781
|
+
// An enum-INVALID type (host drift, the comment above) still flows
|
|
2782
|
+
// through unchecked ON PURPOSE — adding a method allowlist here would
|
|
2783
|
+
// re-blame the caller with a 400 for OUR bug, so the cast keeps the
|
|
2784
|
+
// compile-time contract (#12776) without changing that posture.
|
|
2393
2785
|
method: direct.type === "count_distinct" ? "count_distinct" : direct.type
|
|
2394
2786
|
};
|
|
2395
2787
|
}
|
|
@@ -2403,7 +2795,7 @@ var ObjectQLStrategy = class {
|
|
|
2403
2795
|
if (candidate && candidate.type === type) {
|
|
2404
2796
|
return {
|
|
2405
2797
|
field: candidate.sql.replace(/^\$/, ""),
|
|
2406
|
-
method:
|
|
2798
|
+
method: type
|
|
2407
2799
|
};
|
|
2408
2800
|
}
|
|
2409
2801
|
}
|
|
@@ -2501,7 +2893,7 @@ var ObjectQLStrategy = class {
|
|
|
2501
2893
|
* exactly — including the invariant that a `null` return leaves `params`
|
|
2502
2894
|
* untouched, so no comparand is left with no placeholder to consume it.
|
|
2503
2895
|
*/
|
|
2504
|
-
renderFilterNodeSql(node, cube, params) {
|
|
2896
|
+
renderFilterNodeSql(node, cube, params, ctx) {
|
|
2505
2897
|
if (!node) return null;
|
|
2506
2898
|
if (node.kind === "const") {
|
|
2507
2899
|
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
@@ -2511,17 +2903,23 @@ var ObjectQLStrategy = class {
|
|
|
2511
2903
|
this.resolveFieldName(cube, node.member, "any"),
|
|
2512
2904
|
node.operator,
|
|
2513
2905
|
node.values,
|
|
2514
|
-
params
|
|
2906
|
+
params,
|
|
2907
|
+
// [#14079] The (object, field) this member binds against, resolved the
|
|
2908
|
+
// way `NativeSQLStrategy.resolveStorageTarget` resolves it, so the echo
|
|
2909
|
+
// asks the declared-type hook the same question the executed statement
|
|
2910
|
+
// asked and prints the same constant for a non-text column.
|
|
2911
|
+
this.resolveStorageTarget(cube, node.member, this.extractObjectName(cube)),
|
|
2912
|
+
ctx
|
|
2515
2913
|
);
|
|
2516
2914
|
}
|
|
2517
2915
|
if (node.kind === "not") {
|
|
2518
|
-
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
2916
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
|
|
2519
2917
|
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
2520
2918
|
}
|
|
2521
2919
|
const paramBase = params.length;
|
|
2522
2920
|
const parts = [];
|
|
2523
2921
|
for (const child of node.children) {
|
|
2524
|
-
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
2922
|
+
const clause = this.renderFilterNodeSql(child, cube, params, ctx);
|
|
2525
2923
|
if (clause === null) {
|
|
2526
2924
|
if (node.kind !== "or") continue;
|
|
2527
2925
|
params.length = paramBase;
|
|
@@ -2776,10 +3174,10 @@ var ObjectQLStrategy = class {
|
|
|
2776
3174
|
};
|
|
2777
3175
|
|
|
2778
3176
|
// src/dataset-compiler.ts
|
|
2779
|
-
var
|
|
3177
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
2780
3178
|
var import_ui = require("@objectstack/spec/ui");
|
|
2781
3179
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2782
|
-
var SUPPORTED_AGGREGATES =
|
|
3180
|
+
var SUPPORTED_AGGREGATES = import_data6.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2783
3181
|
function aggregateToMetricType(m) {
|
|
2784
3182
|
if (!m.aggregate) {
|
|
2785
3183
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -2941,7 +3339,7 @@ function compileDataset(dataset, resolver, options) {
|
|
|
2941
3339
|
}
|
|
2942
3340
|
|
|
2943
3341
|
// src/dataset-executor.ts
|
|
2944
|
-
var
|
|
3342
|
+
var import_data7 = require("@objectstack/spec/data");
|
|
2945
3343
|
var import_core4 = require("@objectstack/core");
|
|
2946
3344
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
2947
3345
|
const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
@@ -2981,7 +3379,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
2981
3379
|
}
|
|
2982
3380
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
2983
3381
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
2984
|
-
const empty = (0,
|
|
3382
|
+
const empty = (0, import_data7.emptyGroupValueFor)(aggregate2);
|
|
2985
3383
|
if (empty === void 0) continue;
|
|
2986
3384
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
2987
3385
|
}
|
|
@@ -3735,25 +4133,55 @@ function bucketDate(value, granularity, timezone) {
|
|
|
3735
4133
|
return `${y}-${m}-${day}`;
|
|
3736
4134
|
}
|
|
3737
4135
|
}
|
|
3738
|
-
function
|
|
3739
|
-
if (
|
|
3740
|
-
|
|
3741
|
-
|
|
4136
|
+
function numericOperand(v) {
|
|
4137
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
4138
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
4139
|
+
const n = Number(v);
|
|
4140
|
+
return Number.isFinite(n) ? n : null;
|
|
4141
|
+
}
|
|
4142
|
+
return null;
|
|
4143
|
+
}
|
|
4144
|
+
function compareOperands(a, b) {
|
|
4145
|
+
const an = numericOperand(a);
|
|
4146
|
+
const bn = numericOperand(b);
|
|
4147
|
+
if (an !== null && bn !== null) return an - bn;
|
|
4148
|
+
return compare(a, b);
|
|
4149
|
+
}
|
|
4150
|
+
function extremumOf(rows, field, kind) {
|
|
4151
|
+
let winner;
|
|
4152
|
+
let seen = false;
|
|
4153
|
+
for (const r of rows) {
|
|
4154
|
+
const v = r[field];
|
|
4155
|
+
if (v == null) continue;
|
|
4156
|
+
if (!seen) {
|
|
4157
|
+
winner = v;
|
|
4158
|
+
seen = true;
|
|
4159
|
+
continue;
|
|
3742
4160
|
}
|
|
3743
|
-
|
|
4161
|
+
const c = compareOperands(v, winner);
|
|
4162
|
+
if (kind === "min" ? c < 0 : c > 0) winner = v;
|
|
3744
4163
|
}
|
|
4164
|
+
return seen ? winner : null;
|
|
4165
|
+
}
|
|
4166
|
+
function aggregate(rows, metricType, field) {
|
|
4167
|
+
if (metricType === "count" || field === "*") return rows.length;
|
|
3745
4168
|
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
|
|
3746
4169
|
switch (metricType) {
|
|
3747
|
-
|
|
4170
|
+
// The spec's spelling (`AggregationFunction`), which is what the compiler
|
|
4171
|
+
// copies through. It used to be spelled `countDistinct` here — a word no
|
|
4172
|
+
// producer mints — so the arm was UNREACHABLE and the measure fell to the
|
|
4173
|
+
// numeric `default` below, answering a sum of coerced values (or a row
|
|
4174
|
+
// count) under the author's `count_distinct` name.
|
|
4175
|
+
case "count_distinct":
|
|
3748
4176
|
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
3749
4177
|
case "sum":
|
|
3750
4178
|
return nums.reduce((a, b) => a + b, 0);
|
|
3751
4179
|
case "avg":
|
|
3752
4180
|
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
3753
4181
|
case "min":
|
|
3754
|
-
return
|
|
4182
|
+
return extremumOf(rows, field, "min");
|
|
3755
4183
|
case "max":
|
|
3756
|
-
return
|
|
4184
|
+
return extremumOf(rows, field, "max");
|
|
3757
4185
|
default:
|
|
3758
4186
|
return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
|
|
3759
4187
|
}
|
|
@@ -3814,7 +4242,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3814
4242
|
return {
|
|
3815
4243
|
rows: limited,
|
|
3816
4244
|
fields: [
|
|
3817
|
-
|
|
4245
|
+
// A dimension column is described by the CUBE dimension's own type — the
|
|
4246
|
+
// same expression `NativeSQLStrategy.buildFieldMeta` and its ObjectQL
|
|
4247
|
+
// sibling use (`d?.type || 'string'`), so a `date` dataset dimension is
|
|
4248
|
+
// `'time'` here exactly as it is on the live path. Minting `'string'` for
|
|
4249
|
+
// every dimension made the same column two different things depending
|
|
4250
|
+
// only on whether a pending seed draft existed (#16203 (b)).
|
|
4251
|
+
...dimensions.map((d) => ({ name: d, type: String(cube.dimensions?.[d]?.type || "string") })),
|
|
4252
|
+
// ⛔ A MEASURE column keeps the `'number'` every producer in the platform
|
|
4253
|
+
// mints for it, live faces included. Correcting it is one rule owned by
|
|
4254
|
+
// `measureResultType` (#15768/#16101) and applied at the ADR-0021
|
|
4255
|
+
// descriptor pass; a second copy of it here would be two implementations
|
|
4256
|
+
// free to drift, over a question this producer cannot answer anyway (it
|
|
4257
|
+
// has the cube, not the source object's declared field types).
|
|
3818
4258
|
...query.measures.map((m) => ({ name: m, type: "number" }))
|
|
3819
4259
|
]
|
|
3820
4260
|
};
|
|
@@ -3947,7 +4387,17 @@ var AnalyticsService = class {
|
|
|
3947
4387
|
},
|
|
3948
4388
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
3949
4389
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
3950
|
-
isExternalObject: config.isExternalObject
|
|
4390
|
+
isExternalObject: config.isExternalObject,
|
|
4391
|
+
// [#14079] The declared field type, read off the same `sourceFieldMeta`
|
|
4392
|
+
// hook the display chains use — so the three SQL compilers can give a
|
|
4393
|
+
// text operator over a numeric or boolean column the contract's answer
|
|
4394
|
+
// at compile time. A host that wired no hook answers `undefined`, and
|
|
4395
|
+
// the compilers keep the behaviour they had.
|
|
4396
|
+
declaredFieldType: (object, field) => config.sourceFieldMeta?.(object, field)?.type,
|
|
4397
|
+
// [#15684] The dialect that will run the compiled statement, so the
|
|
4398
|
+
// case-EXACT text family picks a construct that IS case-exact there.
|
|
4399
|
+
// Same tiering as the hook above: `undefined` keeps today's `LIKE`.
|
|
4400
|
+
sqlDialect: (object) => config.sqlDialect?.(object)
|
|
3951
4401
|
};
|
|
3952
4402
|
const builtIn = [
|
|
3953
4403
|
new NativeSQLStrategy(),
|
|
@@ -3967,15 +4417,59 @@ var AnalyticsService = class {
|
|
|
3967
4417
|
* current request's ExecutionContext (ADR-0021 D-C). The strategy then sees a
|
|
3968
4418
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
3969
4419
|
*/
|
|
3970
|
-
async callCtx(query, context) {
|
|
3971
|
-
|
|
4420
|
+
async callCtx(query, context, tokenCtx) {
|
|
4421
|
+
const getDatasetScope = this.resolvedDatasetScopeGetter(tokenCtx);
|
|
4422
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context, getDatasetScope };
|
|
3972
4423
|
const scopes = await this.resolveReadScopes(query, context);
|
|
3973
4424
|
return {
|
|
3974
4425
|
...this.baseCtx,
|
|
3975
4426
|
context,
|
|
4427
|
+
getDatasetScope,
|
|
3976
4428
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
3977
4429
|
};
|
|
3978
4430
|
}
|
|
4431
|
+
/**
|
|
4432
|
+
* [#12230] Copy-on-write expansion of filter placeholders across everything
|
|
4433
|
+
* a DIRECT analytics query compares on: `where` and each time dimension's
|
|
4434
|
+
* `dateRange` — the same positions `DatasetExecutor.resolveSelectionTokens`
|
|
4435
|
+
* covers for the dashboard door, minus the dataset-only channels it alone
|
|
4436
|
+
* carries (measure filters ride the dataset-scope getter below).
|
|
4437
|
+
*
|
|
4438
|
+
* The input is never mutated: a query object can be caller-owned metadata
|
|
4439
|
+
* (a saved report definition, a flow node's config) reused across requests,
|
|
4440
|
+
* and resolving in place would bake one request's user id into every later
|
|
4441
|
+
* render. Returns the SAME object when nothing resolved.
|
|
4442
|
+
*/
|
|
4443
|
+
resolveQueryTokens(query, tokenCtx) {
|
|
4444
|
+
const where = (0, import_core6.resolveFilterTokens)(query.where, tokenCtx);
|
|
4445
|
+
const timeDimensions = query.timeDimensions?.map((td) => {
|
|
4446
|
+
if (td.dateRange == null) return td;
|
|
4447
|
+
const dateRange = (0, import_core6.resolveFilterTokens)(td.dateRange, tokenCtx);
|
|
4448
|
+
return dateRange === td.dateRange ? td : { ...td, dateRange };
|
|
4449
|
+
});
|
|
4450
|
+
const tdChanged = timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== query.timeDimensions[i]);
|
|
4451
|
+
if (where === query.where && !tdChanged) return query;
|
|
4452
|
+
const out = { ...query };
|
|
4453
|
+
if (where !== query.where) out.where = where;
|
|
4454
|
+
if (tdChanged) out.timeDimensions = timeDimensions;
|
|
4455
|
+
return out;
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* [#12230] A per-request `getDatasetScope` whose answers have their filter
|
|
4459
|
+
* placeholders resolved against THIS caller. See `callCtx` for why the
|
|
4460
|
+
* registry's copy cannot be handed out raw. Token-free scopes pass through
|
|
4461
|
+
* by reference — `resolveFilterTokens` returns its input unchanged when the
|
|
4462
|
+
* tree holds no placeholder, so the common case allocates nothing.
|
|
4463
|
+
*/
|
|
4464
|
+
resolvedDatasetScopeGetter(tokenCtx) {
|
|
4465
|
+
return (cubeName) => {
|
|
4466
|
+
const scope = this.baseCtx.getDatasetScope?.(cubeName);
|
|
4467
|
+
if (!scope) return scope;
|
|
4468
|
+
const filter = (0, import_core6.resolveFilterTokens)(scope.filter, tokenCtx);
|
|
4469
|
+
const measureFilters = (0, import_core6.resolveFilterTokens)(scope.measureFilters, tokenCtx);
|
|
4470
|
+
return filter === scope.filter && measureFilters === scope.measureFilters ? scope : { filter, measureFilters };
|
|
4471
|
+
};
|
|
4472
|
+
}
|
|
3979
4473
|
/**
|
|
3980
4474
|
* Resolve the read scope (tenant + RLS `FilterCondition`) for the base object
|
|
3981
4475
|
* AND every joined object of the query's cube, keyed by object name. This is
|
|
@@ -4033,12 +4527,14 @@ var AnalyticsService = class {
|
|
|
4033
4527
|
* aggregate bridge) instead of failing — or worse, fabricating empty rows.
|
|
4034
4528
|
* Any other error propagates untouched.
|
|
4035
4529
|
*/
|
|
4036
|
-
async query(
|
|
4037
|
-
if (!
|
|
4530
|
+
async query(queryInput, context) {
|
|
4531
|
+
if (!queryInput.cube) {
|
|
4038
4532
|
throw new Error("Cube name is required in analytics query");
|
|
4039
4533
|
}
|
|
4534
|
+
const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
4535
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4040
4536
|
this.ensureCube(query);
|
|
4041
|
-
const ctx = await this.callCtx(query, context);
|
|
4537
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4042
4538
|
let skip;
|
|
4043
4539
|
for (; ; ) {
|
|
4044
4540
|
const strategy = this.resolveStrategy(query, ctx, skip);
|
|
@@ -4117,10 +4613,10 @@ var AnalyticsService = class {
|
|
|
4117
4613
|
query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
|
|
4118
4614
|
};
|
|
4119
4615
|
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
|
|
4616
|
+
this.enrichResultColumns(previewResult, dataset, selection, context);
|
|
4120
4617
|
return previewResult;
|
|
4121
4618
|
}
|
|
4122
4619
|
}
|
|
4123
|
-
const requestLocale = context?.locale;
|
|
4124
4620
|
const provider = this.readScopeProvider;
|
|
4125
4621
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
4126
4622
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -4156,7 +4652,7 @@ var AnalyticsService = class {
|
|
|
4156
4652
|
}
|
|
4157
4653
|
throw err;
|
|
4158
4654
|
}
|
|
4159
|
-
const selectedDims =
|
|
4655
|
+
const selectedDims = this.selectedDimensions(dataset, selection);
|
|
4160
4656
|
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
|
|
4161
4657
|
if (drillDims.length && result.rows.length) {
|
|
4162
4658
|
result.object = dataset.object;
|
|
@@ -4225,6 +4721,48 @@ var AnalyticsService = class {
|
|
|
4225
4721
|
}
|
|
4226
4722
|
}
|
|
4227
4723
|
}
|
|
4724
|
+
this.enrichResultColumns(result, dataset, selection, context);
|
|
4725
|
+
return result;
|
|
4726
|
+
}
|
|
4727
|
+
/**
|
|
4728
|
+
* The dataset dimensions this selection GROUPED THE GRID BY, resolved against
|
|
4729
|
+
* the dataset definition. Shared by drill metadata, row-value label
|
|
4730
|
+
* resolution and — through {@link enrichResultColumns} — the dimension column
|
|
4731
|
+
* headers, so all three answer "which dimensions" the same way.
|
|
4732
|
+
*/
|
|
4733
|
+
selectedDimensions(dataset, selection) {
|
|
4734
|
+
return (selection.dimensions ?? []).map((name) => dataset.dimensions?.find((d) => d.name === name)).filter((d) => !!d);
|
|
4735
|
+
}
|
|
4736
|
+
/**
|
|
4737
|
+
* ADR-0021 — describe the result's COLUMNS from the dataset's own authored
|
|
4738
|
+
* definition: a measure's `label` / `format` / `currency` / `percentScale` /
|
|
4739
|
+
* `builtinAggregate` and the `type` its aggregate really returns, then a
|
|
4740
|
+
* dimension column's header `label`.
|
|
4741
|
+
*
|
|
4742
|
+
* **Every key here is read off the DATASET** (the authored measure or
|
|
4743
|
+
* dimension) **and `sourceFieldMeta`** (the source object's declared field
|
|
4744
|
+
* metadata). Not one is read off `result.rows`. That is what makes this one
|
|
4745
|
+
* seam serve both paths that produce a dataset response — the live engine
|
|
4746
|
+
* query and the ADR-0037 P3 draft-data preview — and it is why #16097 was a
|
|
4747
|
+
* defect rather than a deliberate omission: the preview branch returns ~250
|
|
4748
|
+
* lines before this ran, so a response over drafted seed rows carried none of
|
|
4749
|
+
* these keys and a renderer fell back to humanizing the raw measure name and
|
|
4750
|
+
* guessing a percent scale from magnitude — the exact failures #5537,
|
|
4751
|
+
* objectui#3136 and #14492 each closed on the live path.
|
|
4752
|
+
*
|
|
4753
|
+
* Extracted rather than copied onto the second path, for the reason the
|
|
4754
|
+
* `type` correction below already gives for living here at all: this is ONE
|
|
4755
|
+
* rule holding both halves of the question, and a per-path copy would be two
|
|
4756
|
+
* implementations of it, free to drift.
|
|
4757
|
+
*
|
|
4758
|
+
* ⛔ Not here, and deliberately: dimension VALUE label resolution
|
|
4759
|
+
* ({@link resolveDimensionLabels}), which rewrites the grouped value in each
|
|
4760
|
+
* ROW. That reads the rows, it is the one enrichment a seed-draft row set can
|
|
4761
|
+
* make unnecessary, and the preview path skips it on purpose — see the note
|
|
4762
|
+
* at that early return.
|
|
4763
|
+
*/
|
|
4764
|
+
enrichResultColumns(result, dataset, selection, context) {
|
|
4765
|
+
const requestLocale = context?.locale;
|
|
4228
4766
|
if (result.fields?.length && dataset.measures?.length) {
|
|
4229
4767
|
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
4230
4768
|
for (const f of result.fields) {
|
|
@@ -4234,6 +4772,7 @@ var AnalyticsService = class {
|
|
|
4234
4772
|
const label = (0, import_ui2.resolveI18nLabel)(m.label, requestLocale);
|
|
4235
4773
|
if (label !== void 0) f.label = label;
|
|
4236
4774
|
}
|
|
4775
|
+
if (f.builtinAggregate == null && m.label == null && m.aggregate) f.builtinAggregate = m.aggregate;
|
|
4237
4776
|
if (f.format == null && m.format) f.format = m.format;
|
|
4238
4777
|
const fc = f;
|
|
4239
4778
|
const mc = m;
|
|
@@ -4246,11 +4785,13 @@ var AnalyticsService = class {
|
|
|
4246
4785
|
}
|
|
4247
4786
|
}
|
|
4248
4787
|
if (f.percentScale == null) {
|
|
4249
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4788
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data8.percentScaleOf)(meta);
|
|
4250
4789
|
}
|
|
4790
|
+
const resultType = measureResultType(m.aggregate, meta?.type);
|
|
4791
|
+
if (resultType) f.type = resultType;
|
|
4251
4792
|
}
|
|
4252
4793
|
}
|
|
4253
|
-
const describableDims = [...
|
|
4794
|
+
const describableDims = [...this.selectedDimensions(dataset, selection)];
|
|
4254
4795
|
for (const t of selection.timeDimensions ?? []) {
|
|
4255
4796
|
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
4256
4797
|
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
@@ -4267,7 +4808,6 @@ var AnalyticsService = class {
|
|
|
4267
4808
|
if (label !== void 0) f.label = label;
|
|
4268
4809
|
}
|
|
4269
4810
|
}
|
|
4270
|
-
return result;
|
|
4271
4811
|
}
|
|
4272
4812
|
/**
|
|
4273
4813
|
* Get cube metadata for discovery.
|
|
@@ -4292,12 +4832,14 @@ var AnalyticsService = class {
|
|
|
4292
4832
|
/**
|
|
4293
4833
|
* Generate SQL for a query without executing it (dry-run).
|
|
4294
4834
|
*/
|
|
4295
|
-
async generateSql(
|
|
4296
|
-
if (!
|
|
4835
|
+
async generateSql(queryInput, context) {
|
|
4836
|
+
if (!queryInput.cube) {
|
|
4297
4837
|
throw new Error("Cube name is required for SQL generation");
|
|
4298
4838
|
}
|
|
4839
|
+
const tokenCtx = (0, import_core6.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
4840
|
+
const query = this.resolveQueryTokens(queryInput, tokenCtx);
|
|
4299
4841
|
this.ensureCube(query);
|
|
4300
|
-
const ctx = await this.callCtx(query, context);
|
|
4842
|
+
const ctx = await this.callCtx(query, context, tokenCtx);
|
|
4301
4843
|
const strategy = this.resolveStrategy(query, ctx);
|
|
4302
4844
|
this.logger.debug(`[Analytics] generateSql on cube "${query.cube}" \u2192 ${strategy.name}`);
|
|
4303
4845
|
return strategy.generateSql(query, ctx);
|
|
@@ -4811,6 +5353,16 @@ var FallbackDelegateStrategy = class {
|
|
|
4811
5353
|
};
|
|
4812
5354
|
|
|
4813
5355
|
// src/plugin.ts
|
|
5356
|
+
var import_data9 = require("@objectstack/spec/data");
|
|
5357
|
+
function parseEngineAggregateFunction(method, alias) {
|
|
5358
|
+
const parsed = import_data9.AggregationFunction.safeParse(method);
|
|
5359
|
+
if (!parsed.success) {
|
|
5360
|
+
throw new Error(
|
|
5361
|
+
`[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${import_data9.AggregationFunction.options.join(", ")}). A custom-SQL measure is refused earlier, with a caller-facing diagnostic, by ObjectQLStrategy; reaching this point means the analytics layer produced a method the engine contract does not declare.`
|
|
5362
|
+
);
|
|
5363
|
+
}
|
|
5364
|
+
return parsed.data;
|
|
5365
|
+
}
|
|
4814
5366
|
var AnalyticsServicePlugin = class {
|
|
4815
5367
|
constructor(options = {}) {
|
|
4816
5368
|
this.name = "com.objectstack.service-analytics";
|
|
@@ -4868,10 +5420,58 @@ var AnalyticsServicePlugin = class {
|
|
|
4868
5420
|
const rows = await engine.aggregate(objectName, {
|
|
4869
5421
|
where: filter,
|
|
4870
5422
|
groupBy,
|
|
5423
|
+
// [#10413 phase 2 / #10576] `a.filter` is the per-aggregation
|
|
5424
|
+
// predicate `ObjectQLStrategy` lowers a measure's own scoped
|
|
5425
|
+
// `filter` into. This map already renames `method` → `function`
|
|
5426
|
+
// for the engine's own vocabulary; dropping `filter` here — as this
|
|
5427
|
+
// bridge did before this line existed — would have made the
|
|
5428
|
+
// strategy's lowering a NO-OP on every real deployment that boots
|
|
5429
|
+
// through this auto-bridge (the default path: `new
|
|
5430
|
+
// AnalyticsServicePlugin({ cubes })` with no custom
|
|
5431
|
+
// `executeAggregate`), passing every unit test that stubs
|
|
5432
|
+
// `executeAggregate` directly while silently dropping the filter in
|
|
5433
|
+
// production — the exact declared-≠-enforced shape Prime Directive
|
|
5434
|
+
// #10 calls out. Omitted (not `filter: undefined`) when the
|
|
5435
|
+
// aggregation carries none, matching the engine's own
|
|
5436
|
+
// vacuous-filter convention.
|
|
4871
5437
|
aggregations: aggregations?.map((a) => ({
|
|
4872
|
-
function
|
|
5438
|
+
// [#11833] `function` is the engine contract's SIX-value
|
|
5439
|
+
// `AggregationFunction`. This bridge's own input declared
|
|
5440
|
+
// `method: string` when that history was written
|
|
5441
|
+
// (`StrategyContext.executeAggregate`, spec
|
|
5442
|
+
// `contracts/analytics-service.ts`), so the two ends of this
|
|
5443
|
+
// rename spoke different vocabularies: narrowing the engine side
|
|
5444
|
+
// to the contract turned the forward into a compile error — the
|
|
5445
|
+
// correct signal, and the one the deleted structural type hid by
|
|
5446
|
+
// declaring `function: string` on both sides.
|
|
5447
|
+
//
|
|
5448
|
+
// Since #12776 (contract) and #12940 (this plugin's own config
|
|
5449
|
+
// mirror above), BOTH ends declare the enum, so the rename is
|
|
5450
|
+
// enum-to-enum and the parse below is defence in depth behind a
|
|
5451
|
+
// compile-time check rather than the only check — see
|
|
5452
|
+
// `parseEngineAggregateFunction` for why erased types still leave
|
|
5453
|
+
// it load-bearing.
|
|
5454
|
+
//
|
|
5455
|
+
// It was closed by PARSING with the spec enum itself rather than
|
|
5456
|
+
// by widening back to `string` (what hid it) or casting past it
|
|
5457
|
+
// (which keeps the hole and adds a lie). `AggregationFunction` is
|
|
5458
|
+
// the same schema `AggregationNodeSchema.function` is built from,
|
|
5459
|
+
// so there is one vocabulary, and its own error map already
|
|
5460
|
+
// carries the `array_agg`/`string_agg` retirement prescriptions.
|
|
5461
|
+
//
|
|
5462
|
+
// TIERING, deliberately: the reachable producer of a non-aggregate
|
|
5463
|
+
// method — a custom-SQL measure (`AggregationMetricType`
|
|
5464
|
+
// `number`/`string`/`boolean`) — is already refused upstream with a
|
|
5465
|
+
// caller-blaming 400 by `ObjectQLStrategy.resolveMeasureAggregation`
|
|
5466
|
+
// (#12209). Anything still arriving here is host drift, which that
|
|
5467
|
+
// refusal's docblock assigns to the undeclared-500 tier — so this
|
|
5468
|
+
// throws rather than re-blaming the caller, and it answers loudly
|
|
5469
|
+
// instead of letting the engine answer `null` per bucket under the
|
|
5470
|
+
// author's own measure name (the #4157 class).
|
|
5471
|
+
function: parseEngineAggregateFunction(a.method, a.alias),
|
|
4873
5472
|
field: a.field,
|
|
4874
|
-
alias: a.alias
|
|
5473
|
+
alias: a.alias,
|
|
5474
|
+
...a.filter ? { filter: a.filter } : {}
|
|
4875
5475
|
})),
|
|
4876
5476
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
4877
5477
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
@@ -4973,6 +5573,7 @@ var AnalyticsServicePlugin = class {
|
|
|
4973
5573
|
const map = /* @__PURE__ */ new Map();
|
|
4974
5574
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
4975
5575
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
5576
|
+
if (scope) assertReadScopeCannotVacate(scope, targetObject);
|
|
4976
5577
|
const CHUNK = 500;
|
|
4977
5578
|
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
4978
5579
|
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|
|
@@ -5040,6 +5641,16 @@ var AnalyticsServicePlugin = class {
|
|
|
5040
5641
|
}
|
|
5041
5642
|
return columnSql;
|
|
5042
5643
|
};
|
|
5644
|
+
const sqlDialect = (objectName) => {
|
|
5645
|
+
try {
|
|
5646
|
+
const svc = ctx.getService("data");
|
|
5647
|
+
const driver = svc?.getDriverForObject?.(objectName);
|
|
5648
|
+
const named = driver?.dialectName;
|
|
5649
|
+
return typeof named === "string" ? named : void 0;
|
|
5650
|
+
} catch {
|
|
5651
|
+
return void 0;
|
|
5652
|
+
}
|
|
5653
|
+
};
|
|
5043
5654
|
const config = {
|
|
5044
5655
|
cubes: this.options.cubes,
|
|
5045
5656
|
logger: ctx.logger,
|
|
@@ -5081,6 +5692,8 @@ var AnalyticsServicePlugin = class {
|
|
|
5081
5692
|
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
5082
5693
|
// an error message pointing at the wrong database.
|
|
5083
5694
|
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
5695
|
+
// [#15684] The executing driver's own dialect — see `sqlDialect` above.
|
|
5696
|
+
sqlDialect,
|
|
5084
5697
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
5085
5698
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
5086
5699
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|