@objectstack/service-analytics 17.3.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 +298 -0
- package/dist/index.cjs +442 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -14
- package/dist/index.d.ts +145 -14
- package/dist/index.js +424 -77
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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.
|
|
90
100
|
*
|
|
91
|
-
* Heuristic rules
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* -
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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.
|
|
114
|
+
*
|
|
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,10 +849,10 @@ 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
858
|
function emptyMembershipFinding(spec, negated, path) {
|
|
@@ -771,12 +902,12 @@ function assertReadScopeCannotVacate(scope, objectName) {
|
|
|
771
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).`
|
|
772
903
|
);
|
|
773
904
|
}
|
|
774
|
-
function compileSub(node, qAlias) {
|
|
905
|
+
function compileSub(node, qAlias, opts) {
|
|
775
906
|
const params = [];
|
|
776
|
-
const sql = compileNode(node, qAlias, params);
|
|
907
|
+
const sql = compileNode(node, qAlias, params, opts);
|
|
777
908
|
return { sql, params };
|
|
778
909
|
}
|
|
779
|
-
function compileNode(node, qAlias, params) {
|
|
910
|
+
function compileNode(node, qAlias, params, opts) {
|
|
780
911
|
if (!isFilterNode(node)) {
|
|
781
912
|
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
782
913
|
}
|
|
@@ -790,7 +921,7 @@ function compileNode(node, qAlias, params) {
|
|
|
790
921
|
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
791
922
|
continue;
|
|
792
923
|
}
|
|
793
|
-
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
924
|
+
const compiled = value.map((child) => compileSub(child, qAlias, opts));
|
|
794
925
|
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
795
926
|
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
796
927
|
if (kept.length === 0) continue;
|
|
@@ -799,7 +930,7 @@ function compileNode(node, qAlias, params) {
|
|
|
799
930
|
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
800
931
|
} else if (key === "$not") {
|
|
801
932
|
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
802
|
-
const inner = compileSub(operand, qAlias);
|
|
933
|
+
const inner = compileSub(operand, qAlias, opts);
|
|
803
934
|
if (inner.sql.length === 0) {
|
|
804
935
|
clauses.push(FALSE_CLAUSE);
|
|
805
936
|
} else {
|
|
@@ -809,12 +940,12 @@ function compileNode(node, qAlias, params) {
|
|
|
809
940
|
} else if (key.startsWith("$")) {
|
|
810
941
|
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
811
942
|
} else {
|
|
812
|
-
clauses.push(compileField(key, value, qAlias, params));
|
|
943
|
+
clauses.push(compileField(key, value, qAlias, params, opts));
|
|
813
944
|
}
|
|
814
945
|
}
|
|
815
946
|
return clauses.join(" AND ");
|
|
816
947
|
}
|
|
817
|
-
function compileField(field, value, qAlias, params) {
|
|
948
|
+
function compileField(field, value, qAlias, params, opts) {
|
|
818
949
|
const col = `${qAlias}.${quoteIdent(field, "field")}`;
|
|
819
950
|
assertDefinedComparands2(field, value);
|
|
820
951
|
assertBooleanFlagComparands(field, value);
|
|
@@ -834,7 +965,7 @@ function compileField(field, value, qAlias, params) {
|
|
|
834
965
|
}
|
|
835
966
|
const parts = [];
|
|
836
967
|
for (const op of keys) {
|
|
837
|
-
parts.push(compileOperator(col, op, ops[op], field, params));
|
|
968
|
+
parts.push(compileOperator(col, op, ops[op], field, params, opts));
|
|
838
969
|
}
|
|
839
970
|
return parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`;
|
|
840
971
|
}
|
|
@@ -842,8 +973,20 @@ function bind(params, v) {
|
|
|
842
973
|
params.push(v);
|
|
843
974
|
return "?";
|
|
844
975
|
}
|
|
845
|
-
function
|
|
846
|
-
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;
|
|
847
990
|
}
|
|
848
991
|
function nullSafeNegative(col, test) {
|
|
849
992
|
return `(${col} IS NULL OR ${test})`;
|
|
@@ -908,7 +1051,7 @@ function assertNoFieldReferenceComparand2(field, spec) {
|
|
|
908
1051
|
});
|
|
909
1052
|
}
|
|
910
1053
|
}
|
|
911
|
-
function compileOperator(col, op, val, field, params) {
|
|
1054
|
+
function compileOperator(col, op, val, field, params, opts) {
|
|
912
1055
|
switch (op) {
|
|
913
1056
|
case "$eq":
|
|
914
1057
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
@@ -942,12 +1085,19 @@ function compileOperator(col, op, val, field, params) {
|
|
|
942
1085
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
943
1086
|
}
|
|
944
1087
|
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
945
|
-
// character is bound with it. See {@link
|
|
1088
|
+
// character is bound with it. See {@link textMatch}.
|
|
946
1089
|
// [#5234] …and it must be a value `String()` can render, which is asserted
|
|
947
|
-
// 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).
|
|
948
1098
|
case "$contains":
|
|
949
1099
|
assertRenderableText(op, field, val);
|
|
950
|
-
return
|
|
1100
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts);
|
|
951
1101
|
/**
|
|
952
1102
|
* [#6520] `$icontains` on the READ-SCOPE lowering — the one compiler in this
|
|
953
1103
|
* package where a wrong answer is an ADR-0021 scope over-reach rather than a
|
|
@@ -963,23 +1113,39 @@ function compileOperator(col, op, val, field, params) {
|
|
|
963
1113
|
* the rows already lower-case — and on a read scope that is a row set the
|
|
964
1114
|
* policy author never wrote, in the narrowing direction here but in the
|
|
965
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.
|
|
966
1134
|
*/
|
|
967
|
-
case "$icontains":
|
|
1135
|
+
case "$icontains":
|
|
968
1136
|
assertRenderableText(op, field, val);
|
|
969
|
-
|
|
970
|
-
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
971
|
-
}
|
|
1137
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "contains", val, false, params, opts, true);
|
|
972
1138
|
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
973
1139
|
// contain" is true of a value that is not there.
|
|
974
1140
|
case "$notContains":
|
|
975
1141
|
assertRenderableText(op, field, val);
|
|
976
|
-
return
|
|
1142
|
+
return textOverNonTextColumn(op, field, opts) ?? nullSafeNegative(col, textMatch(col, "contains", val, true, params, opts));
|
|
977
1143
|
case "$startsWith":
|
|
978
1144
|
assertRenderableText(op, field, val);
|
|
979
|
-
return
|
|
1145
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "starts", val, false, params, opts);
|
|
980
1146
|
case "$endsWith":
|
|
981
1147
|
assertRenderableText(op, field, val);
|
|
982
|
-
return
|
|
1148
|
+
return textOverNonTextColumn(op, field, opts) ?? textMatch(col, "ends", val, false, params, opts);
|
|
983
1149
|
// [#6387] `val` is a boolean here — {@link assertBooleanFlagComparands}
|
|
984
1150
|
// refused anything else at {@link compileField}, before this emitter runs.
|
|
985
1151
|
// So `=== true` is an exhaustive TWO-WAY choice over the declared domain,
|
|
@@ -1410,7 +1576,10 @@ var NativeSQLStrategy = class {
|
|
|
1410
1576
|
if (typeof ctx.getReadScope !== "function") return;
|
|
1411
1577
|
const filter = ctx.getReadScope(objectName);
|
|
1412
1578
|
if (filter === void 0 || filter === null) return;
|
|
1413
|
-
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
|
+
});
|
|
1414
1583
|
assertReadScopeCannotVacate(filter, objectName);
|
|
1415
1584
|
if (!sql) return;
|
|
1416
1585
|
let i = 0;
|
|
@@ -1688,12 +1857,16 @@ var NativeSQLStrategy = class {
|
|
|
1688
1857
|
gte: ">=",
|
|
1689
1858
|
lt: "<",
|
|
1690
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.
|
|
1691
1866
|
contains: "LIKE",
|
|
1692
1867
|
notContains: "NOT LIKE",
|
|
1693
1868
|
startsWith: "LIKE",
|
|
1694
1869
|
endsWith: "LIKE",
|
|
1695
|
-
// [#6520] `$icontains` — `LIKE` like its neighbours; what separates it is
|
|
1696
|
-
// the ASCII fold applied below, not the keyword.
|
|
1697
1870
|
icontains: "LIKE"
|
|
1698
1871
|
};
|
|
1699
1872
|
const likeShape = {
|
|
@@ -1719,13 +1892,22 @@ var NativeSQLStrategy = class {
|
|
|
1719
1892
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
1720
1893
|
const shape = likeShape[operator];
|
|
1721
1894
|
if (shape) {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
if (operator === "icontains") {
|
|
1726
|
-
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;
|
|
1727
1898
|
}
|
|
1728
|
-
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
|
+
});
|
|
1729
1911
|
}
|
|
1730
1912
|
if (operator === "lte") {
|
|
1731
1913
|
const nextDay = (0, import_core2.nextUtcCalendarDay)(values[0]);
|
|
@@ -1758,7 +1940,7 @@ var NativeSQLStrategy = class {
|
|
|
1758
1940
|
};
|
|
1759
1941
|
|
|
1760
1942
|
// src/strategies/objectql-strategy.ts
|
|
1761
|
-
var
|
|
1943
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
1762
1944
|
var import_core3 = require("@objectstack/core");
|
|
1763
1945
|
|
|
1764
1946
|
// src/strategies/cross-object-rebucket.ts
|
|
@@ -1826,15 +2008,25 @@ var SCALAR_SQL_OPS = {
|
|
|
1826
2008
|
lte: "<="
|
|
1827
2009
|
};
|
|
1828
2010
|
var LIKE_SQL_OPS = {
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
1836
|
-
//
|
|
1837
|
-
|
|
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 }
|
|
1838
2030
|
};
|
|
1839
2031
|
var ObjectQLStrategy = class {
|
|
1840
2032
|
constructor() {
|
|
@@ -2004,7 +2196,7 @@ var ObjectQLStrategy = class {
|
|
|
2004
2196
|
for (const m of query.measures) {
|
|
2005
2197
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
2006
2198
|
const measureFilter = datasetScope?.measureFilters?.[m];
|
|
2007
|
-
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params) : null;
|
|
2199
|
+
const predicate = measureFilter ? this.renderFilterNodeSql(normalizeAnalyticsFilterTree({ where: measureFilter }), cube, params, ctx) : null;
|
|
2008
2200
|
const aggSql = predicate ? this.conditionalAggregateSql(method, field, predicate) : method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
2009
2201
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
2010
2202
|
}
|
|
@@ -2013,14 +2205,16 @@ var ObjectQLStrategy = class {
|
|
|
2013
2205
|
const filterClause = this.renderFilterNodeSql(
|
|
2014
2206
|
normalizeAnalyticsFilterTree(query),
|
|
2015
2207
|
cube,
|
|
2016
|
-
params
|
|
2208
|
+
params,
|
|
2209
|
+
ctx
|
|
2017
2210
|
);
|
|
2018
2211
|
if (filterClause) whereParts.push(filterClause);
|
|
2019
2212
|
if (datasetScope?.filter) {
|
|
2020
2213
|
const scopeSql = this.renderFilterNodeSql(
|
|
2021
2214
|
normalizeAnalyticsFilterTree({ where: datasetScope.filter }),
|
|
2022
2215
|
cube,
|
|
2023
|
-
params
|
|
2216
|
+
params,
|
|
2217
|
+
ctx
|
|
2024
2218
|
);
|
|
2025
2219
|
if (scopeSql) whereParts.push(scopeSql);
|
|
2026
2220
|
}
|
|
@@ -2033,7 +2227,10 @@ var ObjectQLStrategy = class {
|
|
|
2033
2227
|
}
|
|
2034
2228
|
const scope = ctx.getReadScope?.(tableName);
|
|
2035
2229
|
if (scope != null) {
|
|
2036
|
-
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
|
+
});
|
|
2037
2234
|
assertReadScopeCannotVacate(scope, tableName);
|
|
2038
2235
|
if (scopeSql) {
|
|
2039
2236
|
let i = 0;
|
|
@@ -2077,12 +2274,12 @@ var ObjectQLStrategy = class {
|
|
|
2077
2274
|
* predicate. `$and` makes that structurally impossible.
|
|
2078
2275
|
*/
|
|
2079
2276
|
withReadScope(objectName, filter, ctx) {
|
|
2080
|
-
const userFilter = Object.keys(filter).length > 0 ? (0,
|
|
2277
|
+
const userFilter = Object.keys(filter).length > 0 ? (0, import_data5.markFilterSubtreeProvenance)(filter, "author") : void 0;
|
|
2081
2278
|
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
2082
2279
|
const scope = ctx.getReadScope(objectName);
|
|
2083
2280
|
if (scope === void 0 || scope === null) return userFilter;
|
|
2084
2281
|
assertReadScopeCannotVacate(scope, objectName);
|
|
2085
|
-
const scopeFilter = (0,
|
|
2282
|
+
const scopeFilter = (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
|
|
2086
2283
|
if (!userFilter) return scopeFilter;
|
|
2087
2284
|
return { $and: [userFilter, scopeFilter] };
|
|
2088
2285
|
}
|
|
@@ -2395,7 +2592,7 @@ var ObjectQLStrategy = class {
|
|
|
2395
2592
|
const idFilter = { id: { $in: fkValues } };
|
|
2396
2593
|
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
2397
2594
|
if (scope != null) assertReadScopeCannotVacate(scope, refObject);
|
|
2398
|
-
if (scope != null) (0,
|
|
2595
|
+
if (scope != null) (0, import_data5.markFilterSubtreeProvenance)(scope, "policy");
|
|
2399
2596
|
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
2400
2597
|
const rows = await ctx.executeAggregate(refObject, {
|
|
2401
2598
|
groupBy: ["id", attr],
|
|
@@ -2467,7 +2664,7 @@ var ObjectQLStrategy = class {
|
|
|
2467
2664
|
* not render that operator": #5333 was exactly that conflation, and an
|
|
2468
2665
|
* unrenderable operator now THROWS (see the exit below).
|
|
2469
2666
|
*/
|
|
2470
|
-
buildFilterClauseSql(col, operator, values, params) {
|
|
2667
|
+
buildFilterClauseSql(col, operator, values, params, target, ctx) {
|
|
2471
2668
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
2472
2669
|
if (operator === "notSet") return `${col} IS NULL`;
|
|
2473
2670
|
if (!values || values.length === 0) return null;
|
|
@@ -2480,12 +2677,22 @@ var ObjectQLStrategy = class {
|
|
|
2480
2677
|
}
|
|
2481
2678
|
const like = LIKE_SQL_OPS[operator];
|
|
2482
2679
|
if (like) {
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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
|
+
});
|
|
2489
2696
|
}
|
|
2490
2697
|
const op = SCALAR_SQL_OPS[operator];
|
|
2491
2698
|
if (!op) {
|
|
@@ -2524,6 +2731,29 @@ var ObjectQLStrategy = class {
|
|
|
2524
2731
|
}
|
|
2525
2732
|
return void 0;
|
|
2526
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
|
+
}
|
|
2527
2757
|
resolveFieldName(cube, member, kind) {
|
|
2528
2758
|
if (kind === "dimension" || kind === "any") {
|
|
2529
2759
|
const dim = this.lookupMember(cube, member, "dimension");
|
|
@@ -2663,7 +2893,7 @@ var ObjectQLStrategy = class {
|
|
|
2663
2893
|
* exactly — including the invariant that a `null` return leaves `params`
|
|
2664
2894
|
* untouched, so no comparand is left with no placeholder to consume it.
|
|
2665
2895
|
*/
|
|
2666
|
-
renderFilterNodeSql(node, cube, params) {
|
|
2896
|
+
renderFilterNodeSql(node, cube, params, ctx) {
|
|
2667
2897
|
if (!node) return null;
|
|
2668
2898
|
if (node.kind === "const") {
|
|
2669
2899
|
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
@@ -2673,17 +2903,23 @@ var ObjectQLStrategy = class {
|
|
|
2673
2903
|
this.resolveFieldName(cube, node.member, "any"),
|
|
2674
2904
|
node.operator,
|
|
2675
2905
|
node.values,
|
|
2676
|
-
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
|
|
2677
2913
|
);
|
|
2678
2914
|
}
|
|
2679
2915
|
if (node.kind === "not") {
|
|
2680
|
-
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
2916
|
+
const inner = this.renderFilterNodeSql(node.child, cube, params, ctx);
|
|
2681
2917
|
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
2682
2918
|
}
|
|
2683
2919
|
const paramBase = params.length;
|
|
2684
2920
|
const parts = [];
|
|
2685
2921
|
for (const child of node.children) {
|
|
2686
|
-
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
2922
|
+
const clause = this.renderFilterNodeSql(child, cube, params, ctx);
|
|
2687
2923
|
if (clause === null) {
|
|
2688
2924
|
if (node.kind !== "or") continue;
|
|
2689
2925
|
params.length = paramBase;
|
|
@@ -2938,10 +3174,10 @@ var ObjectQLStrategy = class {
|
|
|
2938
3174
|
};
|
|
2939
3175
|
|
|
2940
3176
|
// src/dataset-compiler.ts
|
|
2941
|
-
var
|
|
3177
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
2942
3178
|
var import_ui = require("@objectstack/spec/ui");
|
|
2943
3179
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set();
|
|
2944
|
-
var SUPPORTED_AGGREGATES =
|
|
3180
|
+
var SUPPORTED_AGGREGATES = import_data6.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
2945
3181
|
function aggregateToMetricType(m) {
|
|
2946
3182
|
if (!m.aggregate) {
|
|
2947
3183
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
@@ -3103,7 +3339,7 @@ function compileDataset(dataset, resolver, options) {
|
|
|
3103
3339
|
}
|
|
3104
3340
|
|
|
3105
3341
|
// src/dataset-executor.ts
|
|
3106
|
-
var
|
|
3342
|
+
var import_data7 = require("@objectstack/spec/data");
|
|
3107
3343
|
var import_core4 = require("@objectstack/core");
|
|
3108
3344
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
3109
3345
|
const tokenCtx = (0, import_core4.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
@@ -3143,7 +3379,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
3143
3379
|
}
|
|
3144
3380
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
3145
3381
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
3146
|
-
const empty = (0,
|
|
3382
|
+
const empty = (0, import_data7.emptyGroupValueFor)(aggregate2);
|
|
3147
3383
|
if (empty === void 0) continue;
|
|
3148
3384
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
3149
3385
|
}
|
|
@@ -3897,25 +4133,55 @@ function bucketDate(value, granularity, timezone) {
|
|
|
3897
4133
|
return `${y}-${m}-${day}`;
|
|
3898
4134
|
}
|
|
3899
4135
|
}
|
|
3900
|
-
function
|
|
3901
|
-
if (
|
|
3902
|
-
|
|
3903
|
-
|
|
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;
|
|
3904
4160
|
}
|
|
3905
|
-
|
|
4161
|
+
const c = compareOperands(v, winner);
|
|
4162
|
+
if (kind === "min" ? c < 0 : c > 0) winner = v;
|
|
3906
4163
|
}
|
|
4164
|
+
return seen ? winner : null;
|
|
4165
|
+
}
|
|
4166
|
+
function aggregate(rows, metricType, field) {
|
|
4167
|
+
if (metricType === "count" || field === "*") return rows.length;
|
|
3907
4168
|
const nums = rows.map((r) => Number(r[field])).filter((n) => Number.isFinite(n));
|
|
3908
4169
|
switch (metricType) {
|
|
3909
|
-
|
|
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":
|
|
3910
4176
|
return new Set(rows.map((r) => r[field]).filter((v) => v != null)).size;
|
|
3911
4177
|
case "sum":
|
|
3912
4178
|
return nums.reduce((a, b) => a + b, 0);
|
|
3913
4179
|
case "avg":
|
|
3914
4180
|
return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
|
|
3915
4181
|
case "min":
|
|
3916
|
-
return
|
|
4182
|
+
return extremumOf(rows, field, "min");
|
|
3917
4183
|
case "max":
|
|
3918
|
-
return
|
|
4184
|
+
return extremumOf(rows, field, "max");
|
|
3919
4185
|
default:
|
|
3920
4186
|
return nums.length ? nums.reduce((a, b) => a + b, 0) : rows.length;
|
|
3921
4187
|
}
|
|
@@ -3976,7 +4242,19 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
3976
4242
|
return {
|
|
3977
4243
|
rows: limited,
|
|
3978
4244
|
fields: [
|
|
3979
|
-
|
|
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).
|
|
3980
4258
|
...query.measures.map((m) => ({ name: m, type: "number" }))
|
|
3981
4259
|
]
|
|
3982
4260
|
};
|
|
@@ -4109,7 +4387,17 @@ var AnalyticsService = class {
|
|
|
4109
4387
|
},
|
|
4110
4388
|
coerceTemporalFilterValue: config.coerceTemporalFilterValue,
|
|
4111
4389
|
coerceTemporalFilterColumn: config.coerceTemporalFilterColumn,
|
|
4112
|
-
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)
|
|
4113
4401
|
};
|
|
4114
4402
|
const builtIn = [
|
|
4115
4403
|
new NativeSQLStrategy(),
|
|
@@ -4325,10 +4613,10 @@ var AnalyticsService = class {
|
|
|
4325
4613
|
query: async (q) => evaluateAnalyticsQueryOverRows(q, compiled.cube, seedRows)
|
|
4326
4614
|
};
|
|
4327
4615
|
const previewResult = await new DatasetExecutor(previewService).execute(compiled, selection, context);
|
|
4616
|
+
this.enrichResultColumns(previewResult, dataset, selection, context);
|
|
4328
4617
|
return previewResult;
|
|
4329
4618
|
}
|
|
4330
4619
|
}
|
|
4331
|
-
const requestLocale = context?.locale;
|
|
4332
4620
|
const provider = this.readScopeProvider;
|
|
4333
4621
|
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
4334
4622
|
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
@@ -4364,7 +4652,7 @@ var AnalyticsService = class {
|
|
|
4364
4652
|
}
|
|
4365
4653
|
throw err;
|
|
4366
4654
|
}
|
|
4367
|
-
const selectedDims =
|
|
4655
|
+
const selectedDims = this.selectedDimensions(dataset, selection);
|
|
4368
4656
|
const drillDims = selectedDims.filter((d) => !!d.field && d.type !== "date");
|
|
4369
4657
|
if (drillDims.length && result.rows.length) {
|
|
4370
4658
|
result.object = dataset.object;
|
|
@@ -4433,6 +4721,48 @@ var AnalyticsService = class {
|
|
|
4433
4721
|
}
|
|
4434
4722
|
}
|
|
4435
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;
|
|
4436
4766
|
if (result.fields?.length && dataset.measures?.length) {
|
|
4437
4767
|
const measureByName = new Map(dataset.measures.map((m) => [m.name, m]));
|
|
4438
4768
|
for (const f of result.fields) {
|
|
@@ -4455,11 +4785,13 @@ var AnalyticsService = class {
|
|
|
4455
4785
|
}
|
|
4456
4786
|
}
|
|
4457
4787
|
if (f.percentScale == null) {
|
|
4458
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
4788
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data8.percentScaleOf)(meta);
|
|
4459
4789
|
}
|
|
4790
|
+
const resultType = measureResultType(m.aggregate, meta?.type);
|
|
4791
|
+
if (resultType) f.type = resultType;
|
|
4460
4792
|
}
|
|
4461
4793
|
}
|
|
4462
|
-
const describableDims = [...
|
|
4794
|
+
const describableDims = [...this.selectedDimensions(dataset, selection)];
|
|
4463
4795
|
for (const t of selection.timeDimensions ?? []) {
|
|
4464
4796
|
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
4465
4797
|
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
@@ -4476,7 +4808,6 @@ var AnalyticsService = class {
|
|
|
4476
4808
|
if (label !== void 0) f.label = label;
|
|
4477
4809
|
}
|
|
4478
4810
|
}
|
|
4479
|
-
return result;
|
|
4480
4811
|
}
|
|
4481
4812
|
/**
|
|
4482
4813
|
* Get cube metadata for discovery.
|
|
@@ -5022,12 +5353,12 @@ var FallbackDelegateStrategy = class {
|
|
|
5022
5353
|
};
|
|
5023
5354
|
|
|
5024
5355
|
// src/plugin.ts
|
|
5025
|
-
var
|
|
5356
|
+
var import_data9 = require("@objectstack/spec/data");
|
|
5026
5357
|
function parseEngineAggregateFunction(method, alias) {
|
|
5027
|
-
const parsed =
|
|
5358
|
+
const parsed = import_data9.AggregationFunction.safeParse(method);
|
|
5028
5359
|
if (!parsed.success) {
|
|
5029
5360
|
throw new Error(
|
|
5030
|
-
`[Analytics] The aggregate bridge cannot forward the aggregation "${alias}": "${method}" is not one of the engine's aggregate functions (${
|
|
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.`
|
|
5031
5362
|
);
|
|
5032
5363
|
}
|
|
5033
5364
|
return parsed.data;
|
|
@@ -5310,6 +5641,16 @@ var AnalyticsServicePlugin = class {
|
|
|
5310
5641
|
}
|
|
5311
5642
|
return columnSql;
|
|
5312
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
|
+
};
|
|
5313
5654
|
const config = {
|
|
5314
5655
|
cubes: this.options.cubes,
|
|
5315
5656
|
logger: ctx.logger,
|
|
@@ -5351,6 +5692,8 @@ var AnalyticsServicePlugin = class {
|
|
|
5351
5692
|
// prevent: it drifts by one step, silently, and the drift only surfaces as
|
|
5352
5693
|
// an error message pointing at the wrong database.
|
|
5353
5694
|
getObjectDatasource: (objectName) => dataEngine()?.resolveEffectiveDatasource?.(objectName),
|
|
5695
|
+
// [#15684] The executing driver's own dialect — see `sqlDialect` above.
|
|
5696
|
+
sqlDialect,
|
|
5354
5697
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
5355
5698
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
5356
5699
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|