@objectstack/service-analytics 16.1.0 → 17.0.0-rc.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/dist/index.cjs +815 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +285 -24
- package/dist/index.d.ts +285 -24
- package/dist/index.js +807 -65
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
package/dist/index.cjs
CHANGED
|
@@ -29,16 +29,18 @@ __export(index_exports, {
|
|
|
29
29
|
combineFilters: () => combineFilters,
|
|
30
30
|
compileDataset: () => compileDataset,
|
|
31
31
|
compileScopedFilterToSql: () => compileScopedFilterToSql,
|
|
32
|
+
createOrderLabelResolver: () => createOrderLabelResolver,
|
|
32
33
|
evaluateDerivedMeasures: () => evaluateDerivedMeasures,
|
|
33
34
|
mergeByDimensions: () => mergeByDimensions,
|
|
34
35
|
pickDisplayField: () => pickDisplayField,
|
|
35
36
|
resolveDimensionLabels: () => resolveDimensionLabels,
|
|
36
|
-
shiftRange: () => shiftRange
|
|
37
|
+
shiftRange: () => shiftRange,
|
|
38
|
+
withLabelFetchCache: () => withLabelFetchCache
|
|
37
39
|
});
|
|
38
40
|
module.exports = __toCommonJS(index_exports);
|
|
39
41
|
|
|
40
42
|
// src/analytics-service.ts
|
|
41
|
-
var
|
|
43
|
+
var import_core3 = require("@objectstack/core");
|
|
42
44
|
|
|
43
45
|
// src/cube-registry.ts
|
|
44
46
|
var CubeRegistry = class {
|
|
@@ -707,7 +709,70 @@ var NativeSQLStrategy = class {
|
|
|
707
709
|
}
|
|
708
710
|
};
|
|
709
711
|
|
|
712
|
+
// src/strategies/cross-object-rebucket.ts
|
|
713
|
+
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
714
|
+
"sum",
|
|
715
|
+
"count",
|
|
716
|
+
"min",
|
|
717
|
+
"max"
|
|
718
|
+
]);
|
|
719
|
+
var RESTRICTED_BUCKET = "(restricted)";
|
|
720
|
+
function orderableValue(v) {
|
|
721
|
+
if (v == null) return NaN;
|
|
722
|
+
if (typeof v === "number") return v;
|
|
723
|
+
if (v instanceof Date) return v.getTime();
|
|
724
|
+
const n = Number(v);
|
|
725
|
+
if (Number.isFinite(n)) return n;
|
|
726
|
+
return Date.parse(String(v));
|
|
727
|
+
}
|
|
728
|
+
function recombine(method, acc, next) {
|
|
729
|
+
if (method === "min" || method === "max") {
|
|
730
|
+
if (acc === void 0) return next ?? 0;
|
|
731
|
+
const a = orderableValue(acc);
|
|
732
|
+
const n2 = orderableValue(next);
|
|
733
|
+
if (Number.isNaN(n2)) return acc;
|
|
734
|
+
if (Number.isNaN(a)) return next;
|
|
735
|
+
const nextWins = method === "min" ? n2 < a : n2 > a;
|
|
736
|
+
return nextWins ? next : acc;
|
|
737
|
+
}
|
|
738
|
+
const n = Number(next ?? 0);
|
|
739
|
+
return acc === void 0 ? n : Number(acc) + n;
|
|
740
|
+
}
|
|
741
|
+
function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
|
|
742
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
743
|
+
for (const row of baseRows) {
|
|
744
|
+
const resolved = {};
|
|
745
|
+
for (const cd of crossDims) {
|
|
746
|
+
const fk = row[cd.fkField];
|
|
747
|
+
resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
|
|
748
|
+
}
|
|
749
|
+
const keyParts = [];
|
|
750
|
+
for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
|
|
751
|
+
for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
|
|
752
|
+
const key = keyParts.join("");
|
|
753
|
+
let bucket = buckets.get(key);
|
|
754
|
+
if (!bucket) {
|
|
755
|
+
bucket = {};
|
|
756
|
+
for (const f of baseDimFields) bucket[f] = row[f];
|
|
757
|
+
for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
|
|
758
|
+
buckets.set(key, bucket);
|
|
759
|
+
}
|
|
760
|
+
for (const m of measures) {
|
|
761
|
+
bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return [...buckets.values()];
|
|
765
|
+
}
|
|
766
|
+
|
|
710
767
|
// src/strategies/objectql-strategy.ts
|
|
768
|
+
var SCALAR_SQL_OPS = {
|
|
769
|
+
equals: "=",
|
|
770
|
+
notEquals: "!=",
|
|
771
|
+
gt: ">",
|
|
772
|
+
gte: ">=",
|
|
773
|
+
lt: "<",
|
|
774
|
+
lte: "<="
|
|
775
|
+
};
|
|
711
776
|
var ObjectQLStrategy = class {
|
|
712
777
|
constructor() {
|
|
713
778
|
this.name = "ObjectQLStrategy";
|
|
@@ -745,15 +810,22 @@ var ObjectQLStrategy = class {
|
|
|
745
810
|
}
|
|
746
811
|
}
|
|
747
812
|
const filter = {};
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
813
|
+
const conjuncts = [];
|
|
814
|
+
for (const f of normalizeAnalyticsFilters(query)) {
|
|
815
|
+
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
816
|
+
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
|
|
817
|
+
if (extra) conjuncts.push(extra);
|
|
818
|
+
}
|
|
819
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
820
|
+
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
821
|
+
if (extra) conjuncts.push(extra);
|
|
822
|
+
}
|
|
823
|
+
if (conjuncts.length > 0) {
|
|
824
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
825
|
+
}
|
|
826
|
+
const plan = this.planCrossObject(cube, query, filter);
|
|
827
|
+
if (plan) {
|
|
828
|
+
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
757
829
|
}
|
|
758
830
|
const rows = await ctx.executeAggregate(objectName, {
|
|
759
831
|
// Structured groupBy items ({field, dateGranularity}) pass through the
|
|
@@ -761,11 +833,17 @@ var ObjectQLStrategy = class {
|
|
|
761
833
|
// contract types groupBy as string[]; the cast carries the richer shape.
|
|
762
834
|
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
763
835
|
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
764
|
-
filter:
|
|
836
|
+
filter: this.withReadScope(objectName, filter, ctx),
|
|
765
837
|
// ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
|
|
766
838
|
// on that zone's calendar days. A non-UTC zone makes the engine bucket
|
|
767
839
|
// in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
|
|
768
|
-
timezone: query.timezone
|
|
840
|
+
timezone: query.timezone,
|
|
841
|
+
// ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
|
|
842
|
+
// layer's own scoping; handing the engine the context makes ITS middleware
|
|
843
|
+
// inject RLS too, so a future strategy that forgets `withReadScope` still
|
|
844
|
+
// cannot read across tenants. Without it the operation reaches the engine
|
|
845
|
+
// principal-less and plugin-security falls open — the #3597 shape.
|
|
846
|
+
context: ctx.context
|
|
769
847
|
});
|
|
770
848
|
const mappedRows = rows.map((row) => {
|
|
771
849
|
const mapped = {};
|
|
@@ -783,8 +861,28 @@ var ObjectQLStrategy = class {
|
|
|
783
861
|
return mapped;
|
|
784
862
|
});
|
|
785
863
|
const fields = this.buildFieldMeta(query, cube);
|
|
786
|
-
|
|
864
|
+
let sql;
|
|
865
|
+
try {
|
|
866
|
+
sql = (await this.generateSql(query, ctx)).sql;
|
|
867
|
+
} catch {
|
|
868
|
+
sql = void 0;
|
|
869
|
+
}
|
|
870
|
+
return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
|
|
787
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
|
|
874
|
+
*
|
|
875
|
+
* This path executes through `engine.aggregate()`, not raw SQL, so the string
|
|
876
|
+
* is documentation rather than the literal statement — but it must be an
|
|
877
|
+
* honest account of what the query does, because dataset responses echo it
|
|
878
|
+
* and authors read it to verify their widget options landed (#3588). It
|
|
879
|
+
* therefore renders date bucketing (`date_trunc`), the WHERE predicate,
|
|
880
|
+
* ordering, and the row window.
|
|
881
|
+
*
|
|
882
|
+
* Filter VALUES are rendered as `$n` placeholders and returned in `params`,
|
|
883
|
+
* never inlined: the echoed statement travels to the browser, and a filter
|
|
884
|
+
* comparand can carry tenant data.
|
|
885
|
+
*/
|
|
788
886
|
async generateSql(query, ctx) {
|
|
789
887
|
const cube = ctx.getCube(query.cube);
|
|
790
888
|
if (!cube) {
|
|
@@ -792,28 +890,307 @@ var ObjectQLStrategy = class {
|
|
|
792
890
|
}
|
|
793
891
|
const selectParts = [];
|
|
794
892
|
const groupByParts = [];
|
|
893
|
+
const params = [];
|
|
894
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
895
|
+
for (const td of query.timeDimensions ?? []) {
|
|
896
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
897
|
+
}
|
|
898
|
+
const tableName = this.extractObjectName(cube);
|
|
899
|
+
const plan = this.planCrossObject(cube, query, Object.fromEntries(
|
|
900
|
+
normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
901
|
+
));
|
|
902
|
+
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
903
|
+
const joinClauses = [];
|
|
904
|
+
const dimExpr = (dim) => {
|
|
905
|
+
const cd = crossByDim.get(dim);
|
|
906
|
+
if (cd) {
|
|
907
|
+
joinClauses.push(
|
|
908
|
+
`LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
|
|
909
|
+
);
|
|
910
|
+
return `"${cd.refObject}"."${cd.attr}"`;
|
|
911
|
+
}
|
|
912
|
+
const col = this.resolveFieldName(cube, dim, "dimension");
|
|
913
|
+
const gran = granByDim.get(dim);
|
|
914
|
+
return gran ? `date_trunc('${gran}', ${col})` : col;
|
|
915
|
+
};
|
|
795
916
|
if (query.dimensions) {
|
|
796
917
|
for (const dim of query.dimensions) {
|
|
797
|
-
const
|
|
798
|
-
selectParts.push(`${
|
|
799
|
-
groupByParts.push(
|
|
918
|
+
const expr = dimExpr(dim);
|
|
919
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
920
|
+
groupByParts.push(expr);
|
|
800
921
|
}
|
|
801
922
|
}
|
|
923
|
+
for (const [dim] of granByDim) {
|
|
924
|
+
if (query.dimensions?.includes(dim)) continue;
|
|
925
|
+
const expr = dimExpr(dim);
|
|
926
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
927
|
+
groupByParts.push(expr);
|
|
928
|
+
}
|
|
802
929
|
if (query.measures) {
|
|
803
930
|
for (const m of query.measures) {
|
|
804
931
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
805
|
-
const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
|
|
932
|
+
const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
806
933
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
807
934
|
}
|
|
808
935
|
}
|
|
809
|
-
const
|
|
936
|
+
const whereParts = [];
|
|
937
|
+
for (const f of normalizeAnalyticsFilters(query)) {
|
|
938
|
+
const clause = this.buildFilterClauseSql(
|
|
939
|
+
this.resolveFieldName(cube, f.member, "any"),
|
|
940
|
+
f.operator,
|
|
941
|
+
f.values,
|
|
942
|
+
params
|
|
943
|
+
);
|
|
944
|
+
if (clause) whereParts.push(clause);
|
|
945
|
+
}
|
|
946
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
947
|
+
params.push(bounds.$gte, bounds.$lte);
|
|
948
|
+
whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
|
|
949
|
+
}
|
|
950
|
+
const scope = ctx.getReadScope?.(tableName);
|
|
951
|
+
if (scope != null) {
|
|
952
|
+
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
|
|
953
|
+
if (scopeSql) {
|
|
954
|
+
let i = 0;
|
|
955
|
+
const rendered = scopeSql.replace(/\?/g, () => {
|
|
956
|
+
params.push(scopeParams[i++]);
|
|
957
|
+
return `$${params.length}`;
|
|
958
|
+
});
|
|
959
|
+
whereParts.push(`(${rendered})`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
810
962
|
let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
|
|
963
|
+
if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
|
|
964
|
+
if (whereParts.length > 0) {
|
|
965
|
+
sql += ` WHERE ${whereParts.join(" AND ")}`;
|
|
966
|
+
}
|
|
811
967
|
if (groupByParts.length > 0) {
|
|
812
968
|
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
813
969
|
}
|
|
814
|
-
|
|
970
|
+
if (query.order && Object.keys(query.order).length > 0) {
|
|
971
|
+
const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
|
|
972
|
+
sql += ` ORDER BY ${orderClauses.join(", ")}`;
|
|
973
|
+
}
|
|
974
|
+
if (query.limit != null) sql += ` LIMIT ${query.limit}`;
|
|
975
|
+
if (query.offset != null) sql += ` OFFSET ${query.offset}`;
|
|
976
|
+
return { sql, params };
|
|
815
977
|
}
|
|
816
978
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
979
|
+
/**
|
|
980
|
+
* ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
|
|
981
|
+
* filter handed to `engine.aggregate`.
|
|
982
|
+
*
|
|
983
|
+
* This path used to drop the scope entirely, and the engine could not make up
|
|
984
|
+
* for it: the aggregate bridge passes no `ExecutionContext`, so the security
|
|
985
|
+
* middleware's principal-less fall-open skipped its own RLS injection. Both
|
|
986
|
+
* belts were off at once — an authenticated caller received aggregates
|
|
987
|
+
* computed over EVERY tenant's rows.
|
|
988
|
+
*
|
|
989
|
+
* Composed with `$and`, never by key merge: the query's own filter and the
|
|
990
|
+
* scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
|
|
991
|
+
* and a spread would let caller input silently overwrite the security
|
|
992
|
+
* predicate. `$and` makes that structurally impossible.
|
|
993
|
+
*/
|
|
994
|
+
withReadScope(objectName, filter, ctx) {
|
|
995
|
+
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
996
|
+
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
997
|
+
const scope = ctx.getReadScope(objectName);
|
|
998
|
+
if (scope === void 0 || scope === null) return userFilter;
|
|
999
|
+
const scopeFilter = scope;
|
|
1000
|
+
if (!userFilter) return scopeFilter;
|
|
1001
|
+
return { $and: [userFilter, scopeFilter] };
|
|
1002
|
+
}
|
|
1003
|
+
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
1004
|
+
isCrossObjectField(cube, field, baseObject) {
|
|
1005
|
+
if (!field.includes(".")) return false;
|
|
1006
|
+
const alias = field.split(".")[0];
|
|
1007
|
+
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
1008
|
+
return joinedObject !== baseObject;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
1012
|
+
*
|
|
1013
|
+
* `engine.aggregate()` cannot join. A cross-object DIMENSION within a
|
|
1014
|
+
* supported envelope is served by an FK-expand (`executeCrossObject`): group
|
|
1015
|
+
* the base aggregate on the lookup FK, resolve the FK to the related attribute
|
|
1016
|
+
* with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
|
|
1017
|
+
* query (direct path), a plan for an in-envelope cross-object query.
|
|
1018
|
+
*
|
|
1019
|
+
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
1020
|
+
* (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
|
|
1021
|
+
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
1022
|
+
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1023
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
1024
|
+
*
|
|
1025
|
+
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1026
|
+
* flattens to a real column is treated as base, not cross-object.
|
|
1027
|
+
*/
|
|
1028
|
+
planCrossObject(cube, query, filter) {
|
|
1029
|
+
const baseObject = this.extractObjectName(cube);
|
|
1030
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1031
|
+
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1032
|
+
if (this.isCrossObjectField(cube, field, baseObject)) {
|
|
1033
|
+
throw new Error(
|
|
1034
|
+
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const nonDim = [
|
|
1039
|
+
...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
|
|
1040
|
+
...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
|
|
1041
|
+
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1042
|
+
if (nonDim.length > 0) {
|
|
1043
|
+
throw new Error(
|
|
1044
|
+
`[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
const crossDims = [];
|
|
1048
|
+
for (const dim of query.dimensions ?? []) {
|
|
1049
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1050
|
+
if (!this.isCrossObjectField(cube, field, baseObject)) continue;
|
|
1051
|
+
const [alias, ...rest] = field.split(".");
|
|
1052
|
+
const attr = rest.join(".");
|
|
1053
|
+
if (attr.includes(".")) {
|
|
1054
|
+
throw new Error(
|
|
1055
|
+
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
|
|
1059
|
+
}
|
|
1060
|
+
if (crossDims.length === 0) return null;
|
|
1061
|
+
for (const m of query.measures ?? []) {
|
|
1062
|
+
const { method } = this.resolveMeasureAggregation(cube, m);
|
|
1063
|
+
if (!RECOMBINABLE_METHODS.has(method)) {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
`[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver.`
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
return { crossDims };
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Serve a cross-object-dimension query by FK-expand (#3654). The pure
|
|
1073
|
+
* re-bucketing step lives in `cross-object-rebucket.ts`.
|
|
1074
|
+
*/
|
|
1075
|
+
async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
|
|
1076
|
+
const baseObject = this.extractObjectName(cube);
|
|
1077
|
+
const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
|
|
1078
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
1079
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1080
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1081
|
+
}
|
|
1082
|
+
const groupBy = [];
|
|
1083
|
+
const baseDimFields = [];
|
|
1084
|
+
for (const dim of query.dimensions ?? []) {
|
|
1085
|
+
const cd = crossByDim.get(dim);
|
|
1086
|
+
if (cd) {
|
|
1087
|
+
groupBy.push(cd.fkField);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1091
|
+
const gran = granByDim.get(dim);
|
|
1092
|
+
groupBy.push(gran ? { field, dateGranularity: gran } : field);
|
|
1093
|
+
baseDimFields.push(field);
|
|
1094
|
+
granByDim.delete(dim);
|
|
1095
|
+
}
|
|
1096
|
+
for (const [dim, gran] of granByDim) {
|
|
1097
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1098
|
+
groupBy.push({ field, dateGranularity: gran });
|
|
1099
|
+
baseDimFields.push(field);
|
|
1100
|
+
}
|
|
1101
|
+
const baseRows = await ctx.executeAggregate(baseObject, {
|
|
1102
|
+
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
1103
|
+
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
1104
|
+
filter: this.withReadScope(baseObject, filter, ctx),
|
|
1105
|
+
timezone: query.timezone,
|
|
1106
|
+
context: ctx.context
|
|
1107
|
+
});
|
|
1108
|
+
const resolvedDims = [];
|
|
1109
|
+
for (const cd of plan.crossDims) {
|
|
1110
|
+
const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
|
|
1111
|
+
const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
|
|
1112
|
+
resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
|
|
1113
|
+
}
|
|
1114
|
+
const measures = (query.measures ?? []).map((m) => ({
|
|
1115
|
+
alias: m,
|
|
1116
|
+
// planCrossObject already asserted every measure is recombinable.
|
|
1117
|
+
method: this.resolveMeasureAggregation(cube, m).method
|
|
1118
|
+
}));
|
|
1119
|
+
const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
|
|
1120
|
+
const mappedRows = merged.map((row) => {
|
|
1121
|
+
const out = {};
|
|
1122
|
+
for (const dim of query.dimensions ?? []) {
|
|
1123
|
+
if (crossByDim.has(dim)) {
|
|
1124
|
+
if (dim in row) out[dim] = row[dim];
|
|
1125
|
+
} else {
|
|
1126
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1127
|
+
if (field in row) out[dim] = row[field];
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1131
|
+
if (query.dimensions?.includes(td.dimension)) continue;
|
|
1132
|
+
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1133
|
+
if (field in row) out[td.dimension] = row[field];
|
|
1134
|
+
}
|
|
1135
|
+
for (const m of query.measures ?? []) {
|
|
1136
|
+
if (m in row) out[m] = row[m];
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
});
|
|
1140
|
+
return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
|
|
1144
|
+
* referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
|
|
1145
|
+
* bridge — `group by (id, attr)` is one row per record. Ids the scope hides
|
|
1146
|
+
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
1147
|
+
*/
|
|
1148
|
+
async resolveFkAttr(refObject, attr, fkValues, ctx) {
|
|
1149
|
+
const map = /* @__PURE__ */ new Map();
|
|
1150
|
+
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1151
|
+
const idFilter = { id: { $in: fkValues } };
|
|
1152
|
+
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
1153
|
+
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1154
|
+
const rows = await ctx.executeAggregate(refObject, {
|
|
1155
|
+
groupBy: ["id", attr],
|
|
1156
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
1157
|
+
filter,
|
|
1158
|
+
context: ctx.context
|
|
1159
|
+
});
|
|
1160
|
+
for (const r of rows) {
|
|
1161
|
+
if (r.id != null) map.set(r.id, r[attr]);
|
|
1162
|
+
}
|
|
1163
|
+
return map;
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1167
|
+
*
|
|
1168
|
+
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
1169
|
+
* two previews read alike, but binds through `coerceFilterValueForObjectQL`:
|
|
1170
|
+
* the comparand shown is the one THIS path actually hands the engine (a real
|
|
1171
|
+
* boolean, not SQL's 1/0). Returns null for an operator/value combination
|
|
1172
|
+
* that carries no predicate, matching `execute()`, which drops it too.
|
|
1173
|
+
*/
|
|
1174
|
+
buildFilterClauseSql(col, operator, values, params) {
|
|
1175
|
+
if (operator === "set") return `${col} IS NOT NULL`;
|
|
1176
|
+
if (operator === "notSet") return `${col} IS NULL`;
|
|
1177
|
+
if (!values || values.length === 0) return null;
|
|
1178
|
+
if (operator === "in" || operator === "notIn") {
|
|
1179
|
+
const placeholders = values.map((v) => {
|
|
1180
|
+
params.push(coerceFilterValueForObjectQL(v));
|
|
1181
|
+
return `$${params.length}`;
|
|
1182
|
+
}).join(", ");
|
|
1183
|
+
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
1184
|
+
}
|
|
1185
|
+
if (operator === "contains" || operator === "notContains") {
|
|
1186
|
+
params.push(`%${values[0]}%`);
|
|
1187
|
+
return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
|
|
1188
|
+
}
|
|
1189
|
+
const op = SCALAR_SQL_OPS[operator];
|
|
1190
|
+
if (!op) return null;
|
|
1191
|
+
params.push(coerceFilterValueForObjectQL(values[0]));
|
|
1192
|
+
return `${col} ${op} $${params.length}`;
|
|
1193
|
+
}
|
|
817
1194
|
/**
|
|
818
1195
|
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
819
1196
|
*
|
|
@@ -878,6 +1255,89 @@ var ObjectQLStrategy = class {
|
|
|
878
1255
|
}
|
|
879
1256
|
return { field: "*", method: "count" };
|
|
880
1257
|
}
|
|
1258
|
+
/**
|
|
1259
|
+
* AND one more operand onto `filter[field]`, merging operator objects rather
|
|
1260
|
+
* than overwriting them. Returns a standalone conjunct when the two cannot
|
|
1261
|
+
* share one entry, or `null` when the merge absorbed the operand.
|
|
1262
|
+
*
|
|
1263
|
+
* Every predicate this strategy contributes goes through here — the caller's
|
|
1264
|
+
* `where` and the time-dimension `dateRange` alike. Two operands on one field
|
|
1265
|
+
* are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
|
|
1266
|
+
* window on `close_date`), and a plain assignment would keep only the last:
|
|
1267
|
+
* that is how a range used to lose a bound.
|
|
1268
|
+
*
|
|
1269
|
+
* Spreading is sound only while the operands name DIFFERENT operators. Where
|
|
1270
|
+
* they collide — two `$gte` bounds on one field, which a window makes routine
|
|
1271
|
+
* and which a `where` can already produce on its own through `$and` — the
|
|
1272
|
+
* spread keeps whichever came last and WIDENS the query. Same for a bare
|
|
1273
|
+
* equality meeting an operator object: neither can absorb the other. Those
|
|
1274
|
+
* are handed back for the caller to AND in separately, so the engine
|
|
1275
|
+
* intersects them instead of the strategy picking a winner.
|
|
1276
|
+
*/
|
|
1277
|
+
mergeFilterOperand(filter, field, operand) {
|
|
1278
|
+
const existing = filter[field];
|
|
1279
|
+
if (existing === void 0) {
|
|
1280
|
+
filter[field] = operand;
|
|
1281
|
+
return null;
|
|
1282
|
+
}
|
|
1283
|
+
const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
1284
|
+
if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
|
|
1285
|
+
if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
|
|
1286
|
+
filter[field] = { ...existing, ...operand };
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
|
|
1291
|
+
*
|
|
1292
|
+
* `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
|
|
1293
|
+
* never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
|
|
1294
|
+
* this path used to drop the window on the floor — no error, just every row
|
|
1295
|
+
* ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
|
|
1296
|
+
* declines any query carrying a `granularity`, so a date-bucketed trend lands
|
|
1297
|
+
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
1298
|
+
* carries a range ("last 12 months", "this quarter").
|
|
1299
|
+
*
|
|
1300
|
+
* Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
|
|
1301
|
+
* `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
|
|
1302
|
+
* `$match`, so one dashboard reads the same on every driver.
|
|
1303
|
+
*
|
|
1304
|
+
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
1305
|
+
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
1306
|
+
* STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
|
|
1307
|
+
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1308
|
+
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1309
|
+
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
1310
|
+
* the very coercion that already makes a `where` bound on that same column
|
|
1311
|
+
* work today.
|
|
1312
|
+
*
|
|
1313
|
+
* A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
|
|
1314
|
+
* `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
|
|
1315
|
+
* neither SQL path resolves them, and inventing a second interpretation on the
|
|
1316
|
+
* driver-independent path is how the two would drift apart again.
|
|
1317
|
+
*
|
|
1318
|
+
* An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
|
|
1319
|
+
* takes its first two entries, a one-entry array degenerating to a point.
|
|
1320
|
+
* `NativeSQLStrategy` drops such a window entirely — but "drop the window"
|
|
1321
|
+
* means "plot all of history", which is the very failure this fixes, so the
|
|
1322
|
+
* fallback here errs toward the narrower query instead.
|
|
1323
|
+
*/
|
|
1324
|
+
dateRangeBounds(cube, query) {
|
|
1325
|
+
const out = [];
|
|
1326
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1327
|
+
if (!td.dateRange) continue;
|
|
1328
|
+
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
1329
|
+
const [start, end = start] = range;
|
|
1330
|
+
if (start == null) continue;
|
|
1331
|
+
out.push({
|
|
1332
|
+
field: this.resolveFieldName(cube, td.dimension, "dimension"),
|
|
1333
|
+
bounds: {
|
|
1334
|
+
$gte: coerceFilterValueForObjectQL(String(start)),
|
|
1335
|
+
$lte: coerceFilterValueForObjectQL(String(end))
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
}
|
|
881
1341
|
convertFilter(operator, values) {
|
|
882
1342
|
if (operator === "set") return { $ne: null };
|
|
883
1343
|
if (operator === "notSet") return null;
|
|
@@ -1062,6 +1522,23 @@ function compileDataset(dataset, resolver) {
|
|
|
1062
1522
|
}
|
|
1063
1523
|
|
|
1064
1524
|
// src/dataset-executor.ts
|
|
1525
|
+
var import_core = require("@objectstack/core");
|
|
1526
|
+
function resolveSelectionTokens(compiled, selection, context) {
|
|
1527
|
+
const tokenCtx = (0, import_core.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
1528
|
+
const resolve = (v) => (0, import_core.resolveFilterTokens)(v, tokenCtx);
|
|
1529
|
+
const filter = resolve(compiled.filter);
|
|
1530
|
+
const measureFilters = resolve(compiled.measureFilters);
|
|
1531
|
+
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
1532
|
+
const timeDimensions = selection.timeDimensions?.map(
|
|
1533
|
+
(td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
|
|
1534
|
+
);
|
|
1535
|
+
const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
|
|
1536
|
+
const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
|
|
1537
|
+
return {
|
|
1538
|
+
compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
|
|
1539
|
+
selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1065
1542
|
function combineFilters(a, b) {
|
|
1066
1543
|
if (a && b) return { $and: [a, b] };
|
|
1067
1544
|
return a ?? b;
|
|
@@ -1100,6 +1577,72 @@ function computeDerived(d, row) {
|
|
|
1100
1577
|
return null;
|
|
1101
1578
|
}
|
|
1102
1579
|
}
|
|
1580
|
+
function resolveDimensionGranularity(selection, dimension, datasetDefault) {
|
|
1581
|
+
const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
|
|
1582
|
+
if (stated) return stated;
|
|
1583
|
+
return selection.dateGranularity ?? datasetDefault;
|
|
1584
|
+
}
|
|
1585
|
+
function compareValues(a, b) {
|
|
1586
|
+
const aNull = a == null || a === "";
|
|
1587
|
+
const bNull = b == null || b === "";
|
|
1588
|
+
if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
|
|
1589
|
+
if (a instanceof Date || b instanceof Date) {
|
|
1590
|
+
return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof a === "boolean" || typeof b === "boolean") {
|
|
1593
|
+
return Number(a) - Number(b);
|
|
1594
|
+
}
|
|
1595
|
+
const an = typeof a === "number" ? a : Number(a);
|
|
1596
|
+
const bn = typeof b === "number" ? b : Number(b);
|
|
1597
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
1598
|
+
return String(a).localeCompare(String(b));
|
|
1599
|
+
}
|
|
1600
|
+
function applyOrdering(rows, order, sortKeys) {
|
|
1601
|
+
const keys = Object.entries(order ?? {});
|
|
1602
|
+
if (keys.length === 0 || rows.length < 2) return rows;
|
|
1603
|
+
return [...rows].sort((ra, rb) => {
|
|
1604
|
+
for (const [key, dir] of keys) {
|
|
1605
|
+
const map = sortKeys?.[key];
|
|
1606
|
+
const av = map?.get(ra[key]) ?? ra[key];
|
|
1607
|
+
const bv = map?.get(rb[key]) ?? rb[key];
|
|
1608
|
+
const aNull = av == null || av === "";
|
|
1609
|
+
const bNull = bv == null || bv === "";
|
|
1610
|
+
if (aNull || bNull) {
|
|
1611
|
+
if (aNull && bNull) continue;
|
|
1612
|
+
return aNull ? 1 : -1;
|
|
1613
|
+
}
|
|
1614
|
+
const c = compareValues(av, bv);
|
|
1615
|
+
if (c !== 0) return dir === "desc" ? -c : c;
|
|
1616
|
+
}
|
|
1617
|
+
return 0;
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
function applyWindow(rows, limit, offset) {
|
|
1621
|
+
const start = offset != null && offset > 0 ? offset : 0;
|
|
1622
|
+
if (start === 0 && limit == null) return rows;
|
|
1623
|
+
return rows.slice(start, limit != null ? start + limit : void 0);
|
|
1624
|
+
}
|
|
1625
|
+
function resolveOrdering(selection, dimensions) {
|
|
1626
|
+
const order = selection.order;
|
|
1627
|
+
if (order && Object.keys(order).length > 0) {
|
|
1628
|
+
const selectable = /* @__PURE__ */ new Set([
|
|
1629
|
+
...dimensions,
|
|
1630
|
+
...selection.measures,
|
|
1631
|
+
...selection.measures.map((m) => `${m}__compare`)
|
|
1632
|
+
]);
|
|
1633
|
+
const unknown = Object.keys(order).filter((k) => !selectable.has(k));
|
|
1634
|
+
if (unknown.length) {
|
|
1635
|
+
throw new Error(
|
|
1636
|
+
`[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
return order;
|
|
1640
|
+
}
|
|
1641
|
+
if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
|
|
1642
|
+
return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
|
|
1643
|
+
}
|
|
1644
|
+
return void 0;
|
|
1645
|
+
}
|
|
1103
1646
|
function parseUTC(date) {
|
|
1104
1647
|
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
1105
1648
|
if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
@@ -1127,8 +1670,17 @@ function shiftRange(range, kind) {
|
|
|
1127
1670
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
1128
1671
|
}
|
|
1129
1672
|
var DatasetExecutor = class {
|
|
1130
|
-
|
|
1673
|
+
/**
|
|
1674
|
+
* @param service - The analytics service the executor issues its queries to.
|
|
1675
|
+
* @param orderLabels - Optional sort-key label hook (#3680). When provided,
|
|
1676
|
+
* an order key naming a label-bearing (`select`/`lookup`) dimension sorts
|
|
1677
|
+
* by its display label instead of the stored value. Omit to sort by stored
|
|
1678
|
+
* values everywhere (e.g. the draft-preview path, whose seed rows already
|
|
1679
|
+
* carry display names).
|
|
1680
|
+
*/
|
|
1681
|
+
constructor(service, orderLabels) {
|
|
1131
1682
|
this.service = service;
|
|
1683
|
+
this.orderLabels = orderLabels;
|
|
1132
1684
|
}
|
|
1133
1685
|
/**
|
|
1134
1686
|
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
@@ -1137,7 +1689,8 @@ var DatasetExecutor = class {
|
|
|
1137
1689
|
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
1138
1690
|
* applied per request (ADR-0021 D-C).
|
|
1139
1691
|
*/
|
|
1140
|
-
async execute(
|
|
1692
|
+
async execute(compiledInput, selectionInput, context) {
|
|
1693
|
+
const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
|
|
1141
1694
|
const result = await this.executeSelection(compiled, selection, context);
|
|
1142
1695
|
const groupings = selection.totals?.groupings;
|
|
1143
1696
|
if (groupings?.length) {
|
|
@@ -1181,6 +1734,14 @@ var DatasetExecutor = class {
|
|
|
1181
1734
|
}
|
|
1182
1735
|
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1183
1736
|
const dimensions = selection.dimensions ?? [];
|
|
1737
|
+
const order = resolveOrdering(selection, dimensions);
|
|
1738
|
+
const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
|
|
1739
|
+
(k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
|
|
1740
|
+
) : [];
|
|
1741
|
+
const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
|
|
1742
|
+
const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
|
|
1743
|
+
const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
|
|
1744
|
+
const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
|
|
1184
1745
|
let result;
|
|
1185
1746
|
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1186
1747
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
@@ -1188,7 +1749,8 @@ var DatasetExecutor = class {
|
|
|
1188
1749
|
dimensions,
|
|
1189
1750
|
where: baseFilter,
|
|
1190
1751
|
selection,
|
|
1191
|
-
contextTimezone: context?.timezone
|
|
1752
|
+
contextTimezone: context?.timezone,
|
|
1753
|
+
window: windowQuery
|
|
1192
1754
|
}), context);
|
|
1193
1755
|
} else {
|
|
1194
1756
|
result = { rows: [], fields: [] };
|
|
@@ -1217,6 +1779,15 @@ var DatasetExecutor = class {
|
|
|
1217
1779
|
}
|
|
1218
1780
|
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1219
1781
|
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1782
|
+
let sortKeys;
|
|
1783
|
+
for (const key of labelOrderKeys) {
|
|
1784
|
+
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
1785
|
+
if (values.length === 0) continue;
|
|
1786
|
+
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
1787
|
+
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
1788
|
+
}
|
|
1789
|
+
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
1790
|
+
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1220
1791
|
return result;
|
|
1221
1792
|
}
|
|
1222
1793
|
buildQuery(compiled, opts) {
|
|
@@ -1231,18 +1802,28 @@ var DatasetExecutor = class {
|
|
|
1231
1802
|
if (opts.where) q.where = opts.where;
|
|
1232
1803
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
1233
1804
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
1805
|
+
const granularityFor = (name) => {
|
|
1806
|
+
const cd = compiled.cube.dimensions[name];
|
|
1807
|
+
if (cd?.type !== "time") return void 0;
|
|
1808
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
1809
|
+
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
1810
|
+
};
|
|
1811
|
+
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
1812
|
+
if (t.granularity) return t;
|
|
1813
|
+
const granularity = granularityFor(t.dimension);
|
|
1814
|
+
return granularity ? { ...t, granularity } : t;
|
|
1815
|
+
});
|
|
1234
1816
|
const explicitTimeDims = [];
|
|
1235
1817
|
for (const name of opts.dimensions) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
}
|
|
1818
|
+
if (selDims.has(name)) continue;
|
|
1819
|
+
const granularity = granularityFor(name);
|
|
1820
|
+
if (granularity) explicitTimeDims.push({ dimension: name, granularity });
|
|
1240
1821
|
}
|
|
1241
|
-
const mergedTimeDims = [...
|
|
1822
|
+
const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
|
|
1242
1823
|
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1243
|
-
if (opts.
|
|
1244
|
-
if (opts.
|
|
1245
|
-
if (opts.
|
|
1824
|
+
if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
|
|
1825
|
+
if (opts.window?.limit != null) q.limit = opts.window.limit;
|
|
1826
|
+
if (opts.window?.offset != null) q.offset = opts.window.offset;
|
|
1246
1827
|
return q;
|
|
1247
1828
|
}
|
|
1248
1829
|
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
@@ -1258,14 +1839,13 @@ var DatasetExecutor = class {
|
|
|
1258
1839
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1259
1840
|
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1260
1841
|
);
|
|
1261
|
-
const sub = await this.service.query({
|
|
1262
|
-
cube: compiled.cube.name,
|
|
1842
|
+
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
1263
1843
|
measures,
|
|
1264
1844
|
dimensions,
|
|
1265
1845
|
where: baseFilter,
|
|
1266
|
-
timeDimensions: shiftedTd,
|
|
1267
|
-
|
|
1268
|
-
}, context);
|
|
1846
|
+
selection: { ...selection, timeDimensions: shiftedTd },
|
|
1847
|
+
contextTimezone: context?.timezone
|
|
1848
|
+
}), context);
|
|
1269
1849
|
return sub.rows.map((row) => {
|
|
1270
1850
|
const out = {};
|
|
1271
1851
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
@@ -1296,11 +1876,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
|
1296
1876
|
|
|
1297
1877
|
// src/dimension-labels.ts
|
|
1298
1878
|
var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
|
|
1879
|
+
function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
|
|
1880
|
+
const dimByName = new Map(dims.map((d) => [d.name, d]));
|
|
1881
|
+
const metaFor = (dimension) => {
|
|
1882
|
+
const dim = dimByName.get(dimension);
|
|
1883
|
+
return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
|
|
1884
|
+
};
|
|
1885
|
+
return {
|
|
1886
|
+
isLabelBearing(dimension) {
|
|
1887
|
+
const meta = metaFor(dimension);
|
|
1888
|
+
if (!meta) return false;
|
|
1889
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) return true;
|
|
1890
|
+
return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
|
|
1891
|
+
},
|
|
1892
|
+
async resolveLabels(dimension, values) {
|
|
1893
|
+
const meta = metaFor(dimension);
|
|
1894
|
+
if (!meta) return void 0;
|
|
1895
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) {
|
|
1896
|
+
const labelByValue = /* @__PURE__ */ new Map();
|
|
1897
|
+
for (const opt of meta.options) {
|
|
1898
|
+
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
|
|
1899
|
+
}
|
|
1900
|
+
return labelByValue;
|
|
1901
|
+
}
|
|
1902
|
+
if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
|
|
1903
|
+
let scope;
|
|
1904
|
+
if (resolveScope) {
|
|
1905
|
+
try {
|
|
1906
|
+
scope = await resolveScope(meta.reference);
|
|
1907
|
+
} catch {
|
|
1908
|
+
return void 0;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
|
|
1912
|
+
}
|
|
1913
|
+
return void 0;
|
|
1914
|
+
}
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
function withLabelFetchCache(deps) {
|
|
1918
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1919
|
+
return {
|
|
1920
|
+
getObjectFields: (objectName) => deps.getObjectFields(objectName),
|
|
1921
|
+
async fetchRecordLabels(targetObject, ids, scope, context) {
|
|
1922
|
+
let known = cache.get(targetObject);
|
|
1923
|
+
if (!known) {
|
|
1924
|
+
known = /* @__PURE__ */ new Map();
|
|
1925
|
+
cache.set(targetObject, known);
|
|
1926
|
+
}
|
|
1927
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
1928
|
+
if (missing.length > 0) {
|
|
1929
|
+
const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
|
|
1930
|
+
for (const id of missing) known.set(id, fetched.get(id) ?? null);
|
|
1931
|
+
}
|
|
1932
|
+
const out = /* @__PURE__ */ new Map();
|
|
1933
|
+
for (const id of ids) {
|
|
1934
|
+
const label = known.get(id);
|
|
1935
|
+
if (label != null) out.set(id, label);
|
|
1936
|
+
}
|
|
1937
|
+
return out;
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1299
1941
|
var pad = (n) => String(n).padStart(2, "0");
|
|
1300
1942
|
function formatDateBucket(value, granularity) {
|
|
1301
1943
|
if (value == null || value instanceof Date === false) {
|
|
1302
1944
|
if (typeof value !== "number" && typeof value !== "string") return value;
|
|
1303
1945
|
}
|
|
1946
|
+
if (granularity === "year") {
|
|
1947
|
+
const y2 = typeof value === "number" ? value : Number(String(value).trim());
|
|
1948
|
+
if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
|
|
1949
|
+
}
|
|
1304
1950
|
let d;
|
|
1305
1951
|
if (value instanceof Date) d = value;
|
|
1306
1952
|
else if (typeof value === "number") d = new Date(value);
|
|
@@ -1324,7 +1970,7 @@ function formatDateBucket(value, granularity) {
|
|
|
1324
1970
|
return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
|
|
1325
1971
|
}
|
|
1326
1972
|
}
|
|
1327
|
-
async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
1973
|
+
async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
|
|
1328
1974
|
if (!rows.length || !dims.length) return;
|
|
1329
1975
|
const fields = deps.getObjectFields(baseObject);
|
|
1330
1976
|
if (!fields) return;
|
|
@@ -1356,7 +2002,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
|
1356
2002
|
new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
|
|
1357
2003
|
);
|
|
1358
2004
|
if (ids.length === 0) continue;
|
|
1359
|
-
|
|
2005
|
+
let scope;
|
|
2006
|
+
if (resolveScope) {
|
|
2007
|
+
try {
|
|
2008
|
+
scope = await resolveScope(meta.reference);
|
|
2009
|
+
} catch {
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
|
|
1360
2014
|
if (!labelById || labelById.size === 0) continue;
|
|
1361
2015
|
for (const row of rows) {
|
|
1362
2016
|
const label = labelById.get(row[dim.name]);
|
|
@@ -1377,7 +2031,7 @@ function pickDisplayField(fields) {
|
|
|
1377
2031
|
}
|
|
1378
2032
|
|
|
1379
2033
|
// src/preview-evaluator.ts
|
|
1380
|
-
var
|
|
2034
|
+
var import_core2 = require("@objectstack/core");
|
|
1381
2035
|
function compare(a, b) {
|
|
1382
2036
|
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
1383
2037
|
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
@@ -1428,7 +2082,7 @@ function matchesWhere(row, where) {
|
|
|
1428
2082
|
function bucketDate(value, granularity, timezone) {
|
|
1429
2083
|
const d = new Date(String(value));
|
|
1430
2084
|
if (Number.isNaN(d.getTime())) return null;
|
|
1431
|
-
const { year: y, month, day: dayNum } = (0,
|
|
2085
|
+
const { year: y, month, day: dayNum } = (0, import_core2.calendarPartsInTzOrUtc)(d, timezone);
|
|
1432
2086
|
const m = `${month}`.padStart(2, "0");
|
|
1433
2087
|
const day = `${dayNum}`.padStart(2, "0");
|
|
1434
2088
|
switch (granularity) {
|
|
@@ -1550,7 +2204,9 @@ var AnalyticsService = class {
|
|
|
1550
2204
|
constructor(config = {}) {
|
|
1551
2205
|
/** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
|
|
1552
2206
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
1553
|
-
|
|
2207
|
+
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
2208
|
+
this.warnedNoObjectRegistry = false;
|
|
2209
|
+
this.logger = config.logger || (0, import_core3.createLogger)({ level: "info", format: "pretty" });
|
|
1554
2210
|
this.cubeRegistry = new CubeRegistry();
|
|
1555
2211
|
if (config.cubes) {
|
|
1556
2212
|
this.cubeRegistry.registerAll(config.cubes);
|
|
@@ -1560,6 +2216,7 @@ var AnalyticsService = class {
|
|
|
1560
2216
|
this.measureCurrency = config.measureCurrency;
|
|
1561
2217
|
this.labelResolver = config.labelResolver;
|
|
1562
2218
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2219
|
+
this.isRegisteredObject = config.isRegisteredObject;
|
|
1563
2220
|
if (config.datasets) {
|
|
1564
2221
|
for (const ds of config.datasets) {
|
|
1565
2222
|
try {
|
|
@@ -1600,10 +2257,11 @@ var AnalyticsService = class {
|
|
|
1600
2257
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1601
2258
|
*/
|
|
1602
2259
|
async callCtx(query, context) {
|
|
1603
|
-
if (!this.readScopeProvider) return this.baseCtx;
|
|
2260
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context };
|
|
1604
2261
|
const scopes = await this.resolveReadScopes(query, context);
|
|
1605
2262
|
return {
|
|
1606
2263
|
...this.baseCtx,
|
|
2264
|
+
context,
|
|
1607
2265
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1608
2266
|
};
|
|
1609
2267
|
}
|
|
@@ -1724,9 +2382,19 @@ var AnalyticsService = class {
|
|
|
1724
2382
|
return previewResult;
|
|
1725
2383
|
}
|
|
1726
2384
|
}
|
|
2385
|
+
const provider = this.readScopeProvider;
|
|
2386
|
+
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
2387
|
+
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
2388
|
+
const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
|
|
2389
|
+
dataset.object,
|
|
2390
|
+
dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
|
|
2391
|
+
labelDeps,
|
|
2392
|
+
resolveScope,
|
|
2393
|
+
context
|
|
2394
|
+
) : void 0;
|
|
1727
2395
|
let result;
|
|
1728
2396
|
try {
|
|
1729
|
-
result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
2397
|
+
result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
|
|
1730
2398
|
} catch (err) {
|
|
1731
2399
|
if (isMissingSourceError(err)) {
|
|
1732
2400
|
this.logger.warn(
|
|
@@ -1762,18 +2430,20 @@ var AnalyticsService = class {
|
|
|
1762
2430
|
const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
|
|
1763
2431
|
const rangeDims = [];
|
|
1764
2432
|
for (const d of selectedDims) {
|
|
1765
|
-
if (!d.field || d.type !== "date"
|
|
2433
|
+
if (!d.field || d.type !== "date") continue;
|
|
2434
|
+
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
|
|
2435
|
+
if (!granularity) continue;
|
|
1766
2436
|
const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
|
|
1767
|
-
if (ftype === "datetime") rangeDims.push({ d, instant: true });
|
|
1768
|
-
else if (ftype === "date") rangeDims.push({ d, instant: false });
|
|
1769
|
-
else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
|
|
2437
|
+
if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
|
|
2438
|
+
else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
|
|
2439
|
+
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
1770
2440
|
}
|
|
1771
2441
|
if (rangeDims.length && result.rows.length) {
|
|
1772
|
-
const bound = (ymd, instant) => instant ? new Date((0,
|
|
2442
|
+
const bound = (ymd, instant) => instant ? new Date((0, import_core3.zonedDateStartToUtcMs)(ymd, rangeTz)).toISOString() : ymd;
|
|
1773
2443
|
result.drillRanges = result.rows.map((row) => {
|
|
1774
2444
|
const ranges = {};
|
|
1775
|
-
for (const { d, instant } of rangeDims) {
|
|
1776
|
-
const cal = (0,
|
|
2445
|
+
for (const { d, granularity, instant } of rangeDims) {
|
|
2446
|
+
const cal = (0, import_core3.bucketKeyToCalendarRange)(row[d.name], granularity);
|
|
1777
2447
|
if (cal) {
|
|
1778
2448
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
1779
2449
|
}
|
|
@@ -1782,15 +2452,20 @@ var AnalyticsService = class {
|
|
|
1782
2452
|
});
|
|
1783
2453
|
result.object = dataset.object;
|
|
1784
2454
|
}
|
|
1785
|
-
if (
|
|
1786
|
-
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2455
|
+
if (labelDeps && selectedDims.length) {
|
|
2456
|
+
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2457
|
+
name: d.name,
|
|
2458
|
+
field: d.field,
|
|
2459
|
+
type: d.type,
|
|
2460
|
+
dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
|
|
2461
|
+
}));
|
|
1787
2462
|
if (dims.length) {
|
|
1788
2463
|
try {
|
|
1789
|
-
await resolveDimensionLabels(dataset.object, dims, result.rows,
|
|
2464
|
+
await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
|
|
1790
2465
|
for (const total of result.totals ?? []) {
|
|
1791
2466
|
const subset = dims.filter((d) => total.dimensions.includes(d.name));
|
|
1792
2467
|
if (subset.length) {
|
|
1793
|
-
await resolveDimensionLabels(dataset.object, subset, total.rows,
|
|
2468
|
+
await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
|
|
1794
2469
|
}
|
|
1795
2470
|
}
|
|
1796
2471
|
} catch (e) {
|
|
@@ -1878,6 +2553,7 @@ var AnalyticsService = class {
|
|
|
1878
2553
|
const name = query.cube;
|
|
1879
2554
|
let cube = this.cubeRegistry.get(name);
|
|
1880
2555
|
if (!cube) {
|
|
2556
|
+
this.assertInferableCube(name);
|
|
1881
2557
|
cube = this.inferCubeFromQuery(query);
|
|
1882
2558
|
this.cubeRegistry.register(cube);
|
|
1883
2559
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
@@ -1904,6 +2580,39 @@ var AnalyticsService = class {
|
|
|
1904
2580
|
);
|
|
1905
2581
|
}
|
|
1906
2582
|
}
|
|
2583
|
+
/**
|
|
2584
|
+
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
2585
|
+
* Cube may only be inferred into one if it is a registered object.
|
|
2586
|
+
*
|
|
2587
|
+
* Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
|
|
2588
|
+
* answers "no such cube" instead of letting the name reach the driver as a
|
|
2589
|
+
* table and surfacing whatever the driver says about it. The message names
|
|
2590
|
+
* both ways the request could be made valid, because from here the two are
|
|
2591
|
+
* genuinely indistinguishable: register a Cube, or register the object.
|
|
2592
|
+
*
|
|
2593
|
+
* Skips when `isRegisteredObject` was not supplied — see the config field's
|
|
2594
|
+
* doc for why that tier is a deliberate stand-down and not a hole.
|
|
2595
|
+
*/
|
|
2596
|
+
assertInferableCube(name) {
|
|
2597
|
+
const isRegisteredObject = this.isRegisteredObject;
|
|
2598
|
+
if (!isRegisteredObject) {
|
|
2599
|
+
if (!this.warnedNoObjectRegistry) {
|
|
2600
|
+
this.warnedNoObjectRegistry = true;
|
|
2601
|
+
this.logger.warn(
|
|
2602
|
+
"[Analytics] no object-registry hook configured \u2014 the cube-inference existence gate (#3867) is INACTIVE for this service; an unregistered cube name reaches the driver as a raw table name."
|
|
2603
|
+
);
|
|
2604
|
+
}
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
if (isRegisteredObject(name)) return;
|
|
2608
|
+
const err = new Error(
|
|
2609
|
+
`Cube '${name}' not found: no cube is registered under that name, and it is not a registered object either (a cube can only be auto-inferred from a registered object). Define a Cube in your stack, or check the object name.`
|
|
2610
|
+
);
|
|
2611
|
+
err.code = "CUBE_NOT_FOUND";
|
|
2612
|
+
err.status = 404;
|
|
2613
|
+
err.cube = name;
|
|
2614
|
+
throw err;
|
|
2615
|
+
}
|
|
1907
2616
|
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
1908
2617
|
inferCubeFromQuery(query) {
|
|
1909
2618
|
const cubeName = query.cube;
|
|
@@ -2045,7 +2754,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2045
2754
|
'[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
|
|
2046
2755
|
);
|
|
2047
2756
|
}
|
|
2048
|
-
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
|
|
2757
|
+
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
|
|
2049
2758
|
const engine = tryGetDataEngine();
|
|
2050
2759
|
if (!engine) {
|
|
2051
2760
|
throw new Error(
|
|
@@ -2062,7 +2771,13 @@ var AnalyticsServicePlugin = class {
|
|
|
2062
2771
|
})),
|
|
2063
2772
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
2064
2773
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
2065
|
-
timezone
|
|
2774
|
+
timezone,
|
|
2775
|
+
// ADR-0021 D-C (#3602): thread the caller's identity so the engine's
|
|
2776
|
+
// middleware chain scopes the read itself. `BaseEngineOptions.context`
|
|
2777
|
+
// is `.optional()`, so nothing ever forced this bridge to pass it —
|
|
2778
|
+
// and it did not, which is how an authenticated aggregate reached the
|
|
2779
|
+
// engine with no principal and plugin-security fell open (#3597).
|
|
2780
|
+
context
|
|
2066
2781
|
});
|
|
2067
2782
|
return rows;
|
|
2068
2783
|
};
|
|
@@ -2110,6 +2825,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2110
2825
|
}));
|
|
2111
2826
|
let getReadScope = this.options.getReadScope;
|
|
2112
2827
|
let autoBridgedReadScope = false;
|
|
2828
|
+
let securityPresentAtInit = false;
|
|
2113
2829
|
if (!getReadScope) {
|
|
2114
2830
|
const trySecurity = () => {
|
|
2115
2831
|
try {
|
|
@@ -2119,10 +2835,9 @@ var AnalyticsServicePlugin = class {
|
|
|
2119
2835
|
return void 0;
|
|
2120
2836
|
}
|
|
2121
2837
|
};
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
}
|
|
2838
|
+
securityPresentAtInit = !!trySecurity();
|
|
2839
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
2840
|
+
autoBridgedReadScope = true;
|
|
2126
2841
|
}
|
|
2127
2842
|
const relationshipResolver = (baseObject, relationshipName) => {
|
|
2128
2843
|
const engine = (() => {
|
|
@@ -2150,17 +2865,26 @@ var AnalyticsServicePlugin = class {
|
|
|
2150
2865
|
};
|
|
2151
2866
|
const labelResolver = {
|
|
2152
2867
|
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
|
|
2153
|
-
fetchRecordLabels: async (targetObject, ids) => {
|
|
2868
|
+
fetchRecordLabels: async (targetObject, ids, scope, context) => {
|
|
2154
2869
|
const map = /* @__PURE__ */ new Map();
|
|
2155
2870
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
2156
2871
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
2157
|
-
const
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
filter
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2872
|
+
const CHUNK = 500;
|
|
2873
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
2874
|
+
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|
|
2875
|
+
const filter = scope ? { $and: [idFilter, scope] } : idFilter;
|
|
2876
|
+
const rows = await executeAggregate(targetObject, {
|
|
2877
|
+
groupBy: ["id", displayField],
|
|
2878
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
2879
|
+
filter,
|
|
2880
|
+
// #3602 second belt — `scope` above is the analytics layer's own
|
|
2881
|
+
// predicate on this per-record read; the context makes the engine's
|
|
2882
|
+
// middleware scope it as well.
|
|
2883
|
+
context
|
|
2884
|
+
});
|
|
2885
|
+
for (const r of rows) {
|
|
2886
|
+
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
|
|
2887
|
+
}
|
|
2164
2888
|
}
|
|
2165
2889
|
return map;
|
|
2166
2890
|
}
|
|
@@ -2225,13 +2949,31 @@ var AnalyticsServicePlugin = class {
|
|
|
2225
2949
|
const obj = dataEngine()?.getObject?.(objectName);
|
|
2226
2950
|
return !!(obj && obj.external != null);
|
|
2227
2951
|
},
|
|
2952
|
+
// [#3867] Existence probe for the cube auto-inference gate. Reads the
|
|
2953
|
+
// same schema registry the data path's #3770 gate consults, through the
|
|
2954
|
+
// engine accessor this bridge already uses above — so "which objects
|
|
2955
|
+
// exist" has one answer across /data and /analytics.
|
|
2956
|
+
//
|
|
2957
|
+
// `dataEngine()` resolves lazily and may be absent entirely (analytics
|
|
2958
|
+
// installed without a data engine). Reporting `false` there would 404
|
|
2959
|
+
// every cube, so an unresolvable engine reports `true` — "cannot answer,
|
|
2960
|
+
// do not block" — mirroring the tiering #3770 took on the data path.
|
|
2961
|
+
isRegisteredObject: (name) => {
|
|
2962
|
+
const engine = dataEngine();
|
|
2963
|
+
if (!engine) return true;
|
|
2964
|
+
return engine.getObject?.(name) != null;
|
|
2965
|
+
},
|
|
2228
2966
|
draftRowsResolver
|
|
2229
2967
|
};
|
|
2230
|
-
if (autoBridgedReadScope) {
|
|
2968
|
+
if (autoBridgedReadScope && securityPresentAtInit) {
|
|
2231
2969
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
2970
|
+
} else if (autoBridgedReadScope) {
|
|
2971
|
+
ctx.logger.info(
|
|
2972
|
+
'[Analytics] getReadScope bridged to the "security" service; that service is not registered yet at init and will be resolved per query (plugin order is not significant).'
|
|
2973
|
+
);
|
|
2232
2974
|
} else if (!getReadScope) {
|
|
2233
2975
|
ctx.logger.warn(
|
|
2234
|
-
'[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014
|
|
2976
|
+
'[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014 analytics queries will NOT enforce tenant/RLS scoping (ADR-0021 D-C). Supply getReadScope or register a security service in multi-tenant deployments.'
|
|
2235
2977
|
);
|
|
2236
2978
|
}
|
|
2237
2979
|
if (autoBridged) {
|
|
@@ -2275,10 +3017,12 @@ var AnalyticsServicePlugin = class {
|
|
|
2275
3017
|
combineFilters,
|
|
2276
3018
|
compileDataset,
|
|
2277
3019
|
compileScopedFilterToSql,
|
|
3020
|
+
createOrderLabelResolver,
|
|
2278
3021
|
evaluateDerivedMeasures,
|
|
2279
3022
|
mergeByDimensions,
|
|
2280
3023
|
pickDisplayField,
|
|
2281
3024
|
resolveDimensionLabels,
|
|
2282
|
-
shiftRange
|
|
3025
|
+
shiftRange,
|
|
3026
|
+
withLabelFetchCache
|
|
2283
3027
|
});
|
|
2284
3028
|
//# sourceMappingURL=index.cjs.map
|