@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.js
CHANGED
|
@@ -668,7 +668,70 @@ var NativeSQLStrategy = class {
|
|
|
668
668
|
}
|
|
669
669
|
};
|
|
670
670
|
|
|
671
|
+
// src/strategies/cross-object-rebucket.ts
|
|
672
|
+
var RECOMBINABLE_METHODS = /* @__PURE__ */ new Set([
|
|
673
|
+
"sum",
|
|
674
|
+
"count",
|
|
675
|
+
"min",
|
|
676
|
+
"max"
|
|
677
|
+
]);
|
|
678
|
+
var RESTRICTED_BUCKET = "(restricted)";
|
|
679
|
+
function orderableValue(v) {
|
|
680
|
+
if (v == null) return NaN;
|
|
681
|
+
if (typeof v === "number") return v;
|
|
682
|
+
if (v instanceof Date) return v.getTime();
|
|
683
|
+
const n = Number(v);
|
|
684
|
+
if (Number.isFinite(n)) return n;
|
|
685
|
+
return Date.parse(String(v));
|
|
686
|
+
}
|
|
687
|
+
function recombine(method, acc, next) {
|
|
688
|
+
if (method === "min" || method === "max") {
|
|
689
|
+
if (acc === void 0) return next ?? 0;
|
|
690
|
+
const a = orderableValue(acc);
|
|
691
|
+
const n2 = orderableValue(next);
|
|
692
|
+
if (Number.isNaN(n2)) return acc;
|
|
693
|
+
if (Number.isNaN(a)) return next;
|
|
694
|
+
const nextWins = method === "min" ? n2 < a : n2 > a;
|
|
695
|
+
return nextWins ? next : acc;
|
|
696
|
+
}
|
|
697
|
+
const n = Number(next ?? 0);
|
|
698
|
+
return acc === void 0 ? n : Number(acc) + n;
|
|
699
|
+
}
|
|
700
|
+
function rebucketCrossObject(baseRows, baseDimFields, crossDims, measures) {
|
|
701
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
702
|
+
for (const row of baseRows) {
|
|
703
|
+
const resolved = {};
|
|
704
|
+
for (const cd of crossDims) {
|
|
705
|
+
const fk = row[cd.fkField];
|
|
706
|
+
resolved[cd.outputName] = cd.fkToAttr.has(fk) ? cd.fkToAttr.get(fk) : RESTRICTED_BUCKET;
|
|
707
|
+
}
|
|
708
|
+
const keyParts = [];
|
|
709
|
+
for (const f of baseDimFields) keyParts.push(`${f}=${JSON.stringify(row[f] ?? null)}`);
|
|
710
|
+
for (const cd of crossDims) keyParts.push(`${cd.outputName}=${String(resolved[cd.outputName])}`);
|
|
711
|
+
const key = keyParts.join("");
|
|
712
|
+
let bucket = buckets.get(key);
|
|
713
|
+
if (!bucket) {
|
|
714
|
+
bucket = {};
|
|
715
|
+
for (const f of baseDimFields) bucket[f] = row[f];
|
|
716
|
+
for (const cd of crossDims) bucket[cd.outputName] = resolved[cd.outputName];
|
|
717
|
+
buckets.set(key, bucket);
|
|
718
|
+
}
|
|
719
|
+
for (const m of measures) {
|
|
720
|
+
bucket[m.alias] = recombine(m.method, bucket[m.alias], row[m.alias]);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return [...buckets.values()];
|
|
724
|
+
}
|
|
725
|
+
|
|
671
726
|
// src/strategies/objectql-strategy.ts
|
|
727
|
+
var SCALAR_SQL_OPS = {
|
|
728
|
+
equals: "=",
|
|
729
|
+
notEquals: "!=",
|
|
730
|
+
gt: ">",
|
|
731
|
+
gte: ">=",
|
|
732
|
+
lt: "<",
|
|
733
|
+
lte: "<="
|
|
734
|
+
};
|
|
672
735
|
var ObjectQLStrategy = class {
|
|
673
736
|
constructor() {
|
|
674
737
|
this.name = "ObjectQLStrategy";
|
|
@@ -706,15 +769,22 @@ var ObjectQLStrategy = class {
|
|
|
706
769
|
}
|
|
707
770
|
}
|
|
708
771
|
const filter = {};
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
772
|
+
const conjuncts = [];
|
|
773
|
+
for (const f of normalizeAnalyticsFilters(query)) {
|
|
774
|
+
const fieldName = this.resolveFieldName(cube, f.member, "any");
|
|
775
|
+
const extra = this.mergeFilterOperand(filter, fieldName, this.convertFilter(f.operator, f.values));
|
|
776
|
+
if (extra) conjuncts.push(extra);
|
|
777
|
+
}
|
|
778
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
779
|
+
const extra = this.mergeFilterOperand(filter, field, bounds);
|
|
780
|
+
if (extra) conjuncts.push(extra);
|
|
781
|
+
}
|
|
782
|
+
if (conjuncts.length > 0) {
|
|
783
|
+
filter.$and = [...Array.isArray(filter.$and) ? filter.$and : [], ...conjuncts];
|
|
784
|
+
}
|
|
785
|
+
const plan = this.planCrossObject(cube, query, filter);
|
|
786
|
+
if (plan) {
|
|
787
|
+
return this.executeCrossObject(cube, query, aggregations, filter, plan, ctx);
|
|
718
788
|
}
|
|
719
789
|
const rows = await ctx.executeAggregate(objectName, {
|
|
720
790
|
// Structured groupBy items ({field, dateGranularity}) pass through the
|
|
@@ -722,11 +792,17 @@ var ObjectQLStrategy = class {
|
|
|
722
792
|
// contract types groupBy as string[]; the cast carries the richer shape.
|
|
723
793
|
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
724
794
|
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
725
|
-
filter:
|
|
795
|
+
filter: this.withReadScope(objectName, filter, ctx),
|
|
726
796
|
// ADR-0053 Phase 2 (D2): forward the reference tz so date buckets resolve
|
|
727
797
|
// on that zone's calendar days. A non-UTC zone makes the engine bucket
|
|
728
798
|
// in-memory (uniform across drivers); UTC/unset keeps the DB fast path.
|
|
729
|
-
timezone: query.timezone
|
|
799
|
+
timezone: query.timezone,
|
|
800
|
+
// ADR-0021 D-C (#3602): the second belt. `withReadScope` above is this
|
|
801
|
+
// layer's own scoping; handing the engine the context makes ITS middleware
|
|
802
|
+
// inject RLS too, so a future strategy that forgets `withReadScope` still
|
|
803
|
+
// cannot read across tenants. Without it the operation reaches the engine
|
|
804
|
+
// principal-less and plugin-security falls open — the #3597 shape.
|
|
805
|
+
context: ctx.context
|
|
730
806
|
});
|
|
731
807
|
const mappedRows = rows.map((row) => {
|
|
732
808
|
const mapped = {};
|
|
@@ -744,8 +820,28 @@ var ObjectQLStrategy = class {
|
|
|
744
820
|
return mapped;
|
|
745
821
|
});
|
|
746
822
|
const fields = this.buildFieldMeta(query, cube);
|
|
747
|
-
|
|
823
|
+
let sql;
|
|
824
|
+
try {
|
|
825
|
+
sql = (await this.generateSql(query, ctx)).sql;
|
|
826
|
+
} catch {
|
|
827
|
+
sql = void 0;
|
|
828
|
+
}
|
|
829
|
+
return sql ? { rows: mappedRows, fields, sql } : { rows: mappedRows, fields };
|
|
748
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* Render a REPRESENTATIVE SQL string for an ObjectQL aggregate query.
|
|
833
|
+
*
|
|
834
|
+
* This path executes through `engine.aggregate()`, not raw SQL, so the string
|
|
835
|
+
* is documentation rather than the literal statement — but it must be an
|
|
836
|
+
* honest account of what the query does, because dataset responses echo it
|
|
837
|
+
* and authors read it to verify their widget options landed (#3588). It
|
|
838
|
+
* therefore renders date bucketing (`date_trunc`), the WHERE predicate,
|
|
839
|
+
* ordering, and the row window.
|
|
840
|
+
*
|
|
841
|
+
* Filter VALUES are rendered as `$n` placeholders and returned in `params`,
|
|
842
|
+
* never inlined: the echoed statement travels to the browser, and a filter
|
|
843
|
+
* comparand can carry tenant data.
|
|
844
|
+
*/
|
|
749
845
|
async generateSql(query, ctx) {
|
|
750
846
|
const cube = ctx.getCube(query.cube);
|
|
751
847
|
if (!cube) {
|
|
@@ -753,28 +849,307 @@ var ObjectQLStrategy = class {
|
|
|
753
849
|
}
|
|
754
850
|
const selectParts = [];
|
|
755
851
|
const groupByParts = [];
|
|
852
|
+
const params = [];
|
|
853
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
854
|
+
for (const td of query.timeDimensions ?? []) {
|
|
855
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
856
|
+
}
|
|
857
|
+
const tableName = this.extractObjectName(cube);
|
|
858
|
+
const plan = this.planCrossObject(cube, query, Object.fromEntries(
|
|
859
|
+
normalizeAnalyticsFilters(query).map((f) => [this.resolveFieldName(cube, f.member, "any"), true])
|
|
860
|
+
));
|
|
861
|
+
const crossByDim = new Map((plan?.crossDims ?? []).map((cd) => [cd.outputName, cd]));
|
|
862
|
+
const joinClauses = [];
|
|
863
|
+
const dimExpr = (dim) => {
|
|
864
|
+
const cd = crossByDim.get(dim);
|
|
865
|
+
if (cd) {
|
|
866
|
+
joinClauses.push(
|
|
867
|
+
`LEFT JOIN "${cd.refObject}" ON "${tableName}"."${cd.fkField}" = "${cd.refObject}"."id"`
|
|
868
|
+
);
|
|
869
|
+
return `"${cd.refObject}"."${cd.attr}"`;
|
|
870
|
+
}
|
|
871
|
+
const col = this.resolveFieldName(cube, dim, "dimension");
|
|
872
|
+
const gran = granByDim.get(dim);
|
|
873
|
+
return gran ? `date_trunc('${gran}', ${col})` : col;
|
|
874
|
+
};
|
|
756
875
|
if (query.dimensions) {
|
|
757
876
|
for (const dim of query.dimensions) {
|
|
758
|
-
const
|
|
759
|
-
selectParts.push(`${
|
|
760
|
-
groupByParts.push(
|
|
877
|
+
const expr = dimExpr(dim);
|
|
878
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
879
|
+
groupByParts.push(expr);
|
|
761
880
|
}
|
|
762
881
|
}
|
|
882
|
+
for (const [dim] of granByDim) {
|
|
883
|
+
if (query.dimensions?.includes(dim)) continue;
|
|
884
|
+
const expr = dimExpr(dim);
|
|
885
|
+
selectParts.push(`${expr} AS "${dim}"`);
|
|
886
|
+
groupByParts.push(expr);
|
|
887
|
+
}
|
|
763
888
|
if (query.measures) {
|
|
764
889
|
for (const m of query.measures) {
|
|
765
890
|
const { field, method } = this.resolveMeasureAggregation(cube, m);
|
|
766
|
-
const aggSql = method === "count" ? "COUNT(*)" : `${method.toUpperCase()}(${field})`;
|
|
891
|
+
const aggSql = method === "count" ? "COUNT(*)" : method === "count_distinct" ? `COUNT(DISTINCT ${field})` : `${method.toUpperCase()}(${field})`;
|
|
767
892
|
selectParts.push(`${aggSql} AS "${m}"`);
|
|
768
893
|
}
|
|
769
894
|
}
|
|
770
|
-
const
|
|
895
|
+
const whereParts = [];
|
|
896
|
+
for (const f of normalizeAnalyticsFilters(query)) {
|
|
897
|
+
const clause = this.buildFilterClauseSql(
|
|
898
|
+
this.resolveFieldName(cube, f.member, "any"),
|
|
899
|
+
f.operator,
|
|
900
|
+
f.values,
|
|
901
|
+
params
|
|
902
|
+
);
|
|
903
|
+
if (clause) whereParts.push(clause);
|
|
904
|
+
}
|
|
905
|
+
for (const { field, bounds } of this.dateRangeBounds(cube, query)) {
|
|
906
|
+
params.push(bounds.$gte, bounds.$lte);
|
|
907
|
+
whereParts.push(`${field} BETWEEN $${params.length - 1} AND $${params.length}`);
|
|
908
|
+
}
|
|
909
|
+
const scope = ctx.getReadScope?.(tableName);
|
|
910
|
+
if (scope != null) {
|
|
911
|
+
const { sql: scopeSql, params: scopeParams } = compileScopedFilterToSql(scope, tableName);
|
|
912
|
+
if (scopeSql) {
|
|
913
|
+
let i = 0;
|
|
914
|
+
const rendered = scopeSql.replace(/\?/g, () => {
|
|
915
|
+
params.push(scopeParams[i++]);
|
|
916
|
+
return `$${params.length}`;
|
|
917
|
+
});
|
|
918
|
+
whereParts.push(`(${rendered})`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
771
921
|
let sql = `SELECT ${selectParts.join(", ")} FROM "${tableName}"`;
|
|
922
|
+
if (joinClauses.length > 0) sql += " " + joinClauses.join(" ");
|
|
923
|
+
if (whereParts.length > 0) {
|
|
924
|
+
sql += ` WHERE ${whereParts.join(" AND ")}`;
|
|
925
|
+
}
|
|
772
926
|
if (groupByParts.length > 0) {
|
|
773
927
|
sql += ` GROUP BY ${groupByParts.join(", ")}`;
|
|
774
928
|
}
|
|
775
|
-
|
|
929
|
+
if (query.order && Object.keys(query.order).length > 0) {
|
|
930
|
+
const orderClauses = Object.entries(query.order).map(([f, d]) => `"${f}" ${d.toUpperCase()}`);
|
|
931
|
+
sql += ` ORDER BY ${orderClauses.join(", ")}`;
|
|
932
|
+
}
|
|
933
|
+
if (query.limit != null) sql += ` LIMIT ${query.limit}`;
|
|
934
|
+
if (query.offset != null) sql += ` OFFSET ${query.offset}`;
|
|
935
|
+
return { sql, params };
|
|
776
936
|
}
|
|
777
937
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
938
|
+
/**
|
|
939
|
+
* ADR-0021 D-C (#3597) — AND the object's read scope (tenant + RLS) into the
|
|
940
|
+
* filter handed to `engine.aggregate`.
|
|
941
|
+
*
|
|
942
|
+
* This path used to drop the scope entirely, and the engine could not make up
|
|
943
|
+
* for it: the aggregate bridge passes no `ExecutionContext`, so the security
|
|
944
|
+
* middleware's principal-less fall-open skipped its own RLS injection. Both
|
|
945
|
+
* belts were off at once — an authenticated caller received aggregates
|
|
946
|
+
* computed over EVERY tenant's rows.
|
|
947
|
+
*
|
|
948
|
+
* Composed with `$and`, never by key merge: the query's own filter and the
|
|
949
|
+
* scope can name the SAME field (e.g. a dashboard filtering `organization_id`),
|
|
950
|
+
* and a spread would let caller input silently overwrite the security
|
|
951
|
+
* predicate. `$and` makes that structurally impossible.
|
|
952
|
+
*/
|
|
953
|
+
withReadScope(objectName, filter, ctx) {
|
|
954
|
+
const userFilter = Object.keys(filter).length > 0 ? filter : void 0;
|
|
955
|
+
if (typeof ctx.getReadScope !== "function") return userFilter;
|
|
956
|
+
const scope = ctx.getReadScope(objectName);
|
|
957
|
+
if (scope === void 0 || scope === null) return userFilter;
|
|
958
|
+
const scopeFilter = scope;
|
|
959
|
+
if (!userFilter) return scopeFilter;
|
|
960
|
+
return { $and: [userFilter, scopeFilter] };
|
|
961
|
+
}
|
|
962
|
+
/** Is `field` a resolved cross-object (relationship-traversal) reference? */
|
|
963
|
+
isCrossObjectField(cube, field, baseObject) {
|
|
964
|
+
if (!field.includes(".")) return false;
|
|
965
|
+
const alias = field.split(".")[0];
|
|
966
|
+
const joinedObject = cube.joins?.[alias]?.name ?? alias;
|
|
967
|
+
return joinedObject !== baseObject;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Plan how to serve cross-object references on this join-less path (#3654).
|
|
971
|
+
*
|
|
972
|
+
* `engine.aggregate()` cannot join. A cross-object DIMENSION within a
|
|
973
|
+
* supported envelope is served by an FK-expand (`executeCrossObject`): group
|
|
974
|
+
* the base aggregate on the lookup FK, resolve the FK to the related attribute
|
|
975
|
+
* with a SCOPED read, re-bucket in memory. Returns `null` for a base-only
|
|
976
|
+
* query (direct path), a plan for an in-envelope cross-object query.
|
|
977
|
+
*
|
|
978
|
+
* THROWS for anything outside the envelope — a cross-object MEASURE or FILTER
|
|
979
|
+
* (needs a real join to evaluate), a MULTI-HOP dimension (`a.b.c`), or a
|
|
980
|
+
* non-recombinable measure (`avg`/`count_distinct`, whose sub-bucket values
|
|
981
|
+
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
982
|
+
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
983
|
+
*
|
|
984
|
+
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
985
|
+
* flattens to a real column is treated as base, not cross-object.
|
|
986
|
+
*/
|
|
987
|
+
planCrossObject(cube, query, filter) {
|
|
988
|
+
const baseObject = this.extractObjectName(cube);
|
|
989
|
+
for (const td of query.timeDimensions ?? []) {
|
|
990
|
+
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
991
|
+
if (this.isCrossObjectField(cube, field, baseObject)) {
|
|
992
|
+
throw new Error(
|
|
993
|
+
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const nonDim = [
|
|
998
|
+
...(query.measures ?? []).map((m) => ({ where: "measure", field: this.resolveMeasureAggregation(cube, m).field })),
|
|
999
|
+
...Object.keys(filter).map((f) => ({ where: "filter", field: f }))
|
|
1000
|
+
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1001
|
+
if (nonDim.length > 0) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`[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}.`
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
const crossDims = [];
|
|
1007
|
+
for (const dim of query.dimensions ?? []) {
|
|
1008
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1009
|
+
if (!this.isCrossObjectField(cube, field, baseObject)) continue;
|
|
1010
|
+
const [alias, ...rest] = field.split(".");
|
|
1011
|
+
const attr = rest.join(".");
|
|
1012
|
+
if (attr.includes(".")) {
|
|
1013
|
+
throw new Error(
|
|
1014
|
+
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
|
|
1018
|
+
}
|
|
1019
|
+
if (crossDims.length === 0) return null;
|
|
1020
|
+
for (const m of query.measures ?? []) {
|
|
1021
|
+
const { method } = this.resolveMeasureAggregation(cube, m);
|
|
1022
|
+
if (!RECOMBINABLE_METHODS.has(method)) {
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`[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.`
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return { crossDims };
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Serve a cross-object-dimension query by FK-expand (#3654). The pure
|
|
1032
|
+
* re-bucketing step lives in `cross-object-rebucket.ts`.
|
|
1033
|
+
*/
|
|
1034
|
+
async executeCrossObject(cube, query, aggregations, filter, plan, ctx) {
|
|
1035
|
+
const baseObject = this.extractObjectName(cube);
|
|
1036
|
+
const crossByDim = new Map(plan.crossDims.map((cd) => [cd.outputName, cd]));
|
|
1037
|
+
const granByDim = /* @__PURE__ */ new Map();
|
|
1038
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1039
|
+
if (td.granularity) granByDim.set(td.dimension, td.granularity);
|
|
1040
|
+
}
|
|
1041
|
+
const groupBy = [];
|
|
1042
|
+
const baseDimFields = [];
|
|
1043
|
+
for (const dim of query.dimensions ?? []) {
|
|
1044
|
+
const cd = crossByDim.get(dim);
|
|
1045
|
+
if (cd) {
|
|
1046
|
+
groupBy.push(cd.fkField);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1050
|
+
const gran = granByDim.get(dim);
|
|
1051
|
+
groupBy.push(gran ? { field, dateGranularity: gran } : field);
|
|
1052
|
+
baseDimFields.push(field);
|
|
1053
|
+
granByDim.delete(dim);
|
|
1054
|
+
}
|
|
1055
|
+
for (const [dim, gran] of granByDim) {
|
|
1056
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1057
|
+
groupBy.push({ field, dateGranularity: gran });
|
|
1058
|
+
baseDimFields.push(field);
|
|
1059
|
+
}
|
|
1060
|
+
const baseRows = await ctx.executeAggregate(baseObject, {
|
|
1061
|
+
groupBy: groupBy.length > 0 ? groupBy : void 0,
|
|
1062
|
+
aggregations: aggregations.length > 0 ? aggregations : void 0,
|
|
1063
|
+
filter: this.withReadScope(baseObject, filter, ctx),
|
|
1064
|
+
timezone: query.timezone,
|
|
1065
|
+
context: ctx.context
|
|
1066
|
+
});
|
|
1067
|
+
const resolvedDims = [];
|
|
1068
|
+
for (const cd of plan.crossDims) {
|
|
1069
|
+
const fkValues = [...new Set(baseRows.map((r) => r[cd.fkField]).filter((v) => v != null))];
|
|
1070
|
+
const fkToAttr = await this.resolveFkAttr(cd.refObject, cd.attr, fkValues, ctx);
|
|
1071
|
+
resolvedDims.push({ outputName: cd.outputName, fkField: cd.fkField, fkToAttr });
|
|
1072
|
+
}
|
|
1073
|
+
const measures = (query.measures ?? []).map((m) => ({
|
|
1074
|
+
alias: m,
|
|
1075
|
+
// planCrossObject already asserted every measure is recombinable.
|
|
1076
|
+
method: this.resolveMeasureAggregation(cube, m).method
|
|
1077
|
+
}));
|
|
1078
|
+
const merged = rebucketCrossObject(baseRows, baseDimFields, resolvedDims, measures);
|
|
1079
|
+
const mappedRows = merged.map((row) => {
|
|
1080
|
+
const out = {};
|
|
1081
|
+
for (const dim of query.dimensions ?? []) {
|
|
1082
|
+
if (crossByDim.has(dim)) {
|
|
1083
|
+
if (dim in row) out[dim] = row[dim];
|
|
1084
|
+
} else {
|
|
1085
|
+
const field = this.resolveFieldName(cube, dim, "dimension");
|
|
1086
|
+
if (field in row) out[dim] = row[field];
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1090
|
+
if (query.dimensions?.includes(td.dimension)) continue;
|
|
1091
|
+
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1092
|
+
if (field in row) out[td.dimension] = row[field];
|
|
1093
|
+
}
|
|
1094
|
+
for (const m of query.measures ?? []) {
|
|
1095
|
+
if (m in row) out[m] = row[m];
|
|
1096
|
+
}
|
|
1097
|
+
return out;
|
|
1098
|
+
});
|
|
1099
|
+
return { rows: mappedRows, fields: this.buildFieldMeta(query, cube) };
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Resolve `fkValues` (ids of `refObject`) to their `attr` values, applying the
|
|
1103
|
+
* referenced object's OWN read scope (#3654 / #3602). Reuses the aggregate
|
|
1104
|
+
* bridge — `group by (id, attr)` is one row per record. Ids the scope hides
|
|
1105
|
+
* are simply absent from the map (⇒ RESTRICTED bucket downstream).
|
|
1106
|
+
*/
|
|
1107
|
+
async resolveFkAttr(refObject, attr, fkValues, ctx) {
|
|
1108
|
+
const map = /* @__PURE__ */ new Map();
|
|
1109
|
+
if (fkValues.length === 0 || typeof ctx.executeAggregate !== "function") return map;
|
|
1110
|
+
const idFilter = { id: { $in: fkValues } };
|
|
1111
|
+
const scope = typeof ctx.getReadScope === "function" ? ctx.getReadScope(refObject) : null;
|
|
1112
|
+
const filter = scope != null ? { $and: [idFilter, scope] } : idFilter;
|
|
1113
|
+
const rows = await ctx.executeAggregate(refObject, {
|
|
1114
|
+
groupBy: ["id", attr],
|
|
1115
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
1116
|
+
filter,
|
|
1117
|
+
context: ctx.context
|
|
1118
|
+
});
|
|
1119
|
+
for (const r of rows) {
|
|
1120
|
+
if (r.id != null) map.set(r.id, r[attr]);
|
|
1121
|
+
}
|
|
1122
|
+
return map;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1126
|
+
*
|
|
1127
|
+
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
1128
|
+
* two previews read alike, but binds through `coerceFilterValueForObjectQL`:
|
|
1129
|
+
* the comparand shown is the one THIS path actually hands the engine (a real
|
|
1130
|
+
* boolean, not SQL's 1/0). Returns null for an operator/value combination
|
|
1131
|
+
* that carries no predicate, matching `execute()`, which drops it too.
|
|
1132
|
+
*/
|
|
1133
|
+
buildFilterClauseSql(col, operator, values, params) {
|
|
1134
|
+
if (operator === "set") return `${col} IS NOT NULL`;
|
|
1135
|
+
if (operator === "notSet") return `${col} IS NULL`;
|
|
1136
|
+
if (!values || values.length === 0) return null;
|
|
1137
|
+
if (operator === "in" || operator === "notIn") {
|
|
1138
|
+
const placeholders = values.map((v) => {
|
|
1139
|
+
params.push(coerceFilterValueForObjectQL(v));
|
|
1140
|
+
return `$${params.length}`;
|
|
1141
|
+
}).join(", ");
|
|
1142
|
+
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
1143
|
+
}
|
|
1144
|
+
if (operator === "contains" || operator === "notContains") {
|
|
1145
|
+
params.push(`%${values[0]}%`);
|
|
1146
|
+
return `${col} ${operator === "contains" ? "LIKE" : "NOT LIKE"} $${params.length}`;
|
|
1147
|
+
}
|
|
1148
|
+
const op = SCALAR_SQL_OPS[operator];
|
|
1149
|
+
if (!op) return null;
|
|
1150
|
+
params.push(coerceFilterValueForObjectQL(values[0]));
|
|
1151
|
+
return `${col} ${op} $${params.length}`;
|
|
1152
|
+
}
|
|
778
1153
|
/**
|
|
779
1154
|
* Resolve a member ref to a `{ sql, type? }` definition.
|
|
780
1155
|
*
|
|
@@ -839,6 +1214,89 @@ var ObjectQLStrategy = class {
|
|
|
839
1214
|
}
|
|
840
1215
|
return { field: "*", method: "count" };
|
|
841
1216
|
}
|
|
1217
|
+
/**
|
|
1218
|
+
* AND one more operand onto `filter[field]`, merging operator objects rather
|
|
1219
|
+
* than overwriting them. Returns a standalone conjunct when the two cannot
|
|
1220
|
+
* share one entry, or `null` when the merge absorbed the operand.
|
|
1221
|
+
*
|
|
1222
|
+
* Every predicate this strategy contributes goes through here — the caller's
|
|
1223
|
+
* `where` and the time-dimension `dateRange` alike. Two operands on one field
|
|
1224
|
+
* are the normal case (`{$gte}` from a `where` plus `{$gte,$lte}` from a
|
|
1225
|
+
* window on `close_date`), and a plain assignment would keep only the last:
|
|
1226
|
+
* that is how a range used to lose a bound.
|
|
1227
|
+
*
|
|
1228
|
+
* Spreading is sound only while the operands name DIFFERENT operators. Where
|
|
1229
|
+
* they collide — two `$gte` bounds on one field, which a window makes routine
|
|
1230
|
+
* and which a `where` can already produce on its own through `$and` — the
|
|
1231
|
+
* spread keeps whichever came last and WIDENS the query. Same for a bare
|
|
1232
|
+
* equality meeting an operator object: neither can absorb the other. Those
|
|
1233
|
+
* are handed back for the caller to AND in separately, so the engine
|
|
1234
|
+
* intersects them instead of the strategy picking a winner.
|
|
1235
|
+
*/
|
|
1236
|
+
mergeFilterOperand(filter, field, operand) {
|
|
1237
|
+
const existing = filter[field];
|
|
1238
|
+
if (existing === void 0) {
|
|
1239
|
+
filter[field] = operand;
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
const mergeable = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
1243
|
+
if (!mergeable(existing) || !mergeable(operand)) return { [field]: operand };
|
|
1244
|
+
if (Object.keys(operand).some((op) => op in existing)) return { [field]: operand };
|
|
1245
|
+
filter[field] = { ...existing, ...operand };
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Lower `timeDimensions[].dateRange` into resolved-field bounds (#3650).
|
|
1250
|
+
*
|
|
1251
|
+
* `dateRange` states a WINDOW on a time dimension; it is a SIBLING of `where`,
|
|
1252
|
+
* never folded into it. `normalizeAnalyticsFilters` reads only `where`, so
|
|
1253
|
+
* this path used to drop the window on the floor — no error, just every row
|
|
1254
|
+
* ever recorded. Nor is that a corner case: `NativeSQLStrategy.canHandle`
|
|
1255
|
+
* declines any query carrying a `granularity`, so a date-bucketed trend lands
|
|
1256
|
+
* HERE on every driver — and "bucketed trend" is precisely the shape that also
|
|
1257
|
+
* carries a range ("last 12 months", "this quarter").
|
|
1258
|
+
*
|
|
1259
|
+
* Bounds are inclusive on both ends — the same `$gte`/`$lte` pair
|
|
1260
|
+
* `NativeSQLStrategy` binds as `BETWEEN` and the memory driver builds as a
|
|
1261
|
+
* `$match`, so one dashboard reads the same on every driver.
|
|
1262
|
+
*
|
|
1263
|
+
* Comparands are coerced by the SAME helper the `where` path uses, so an
|
|
1264
|
+
* epoch-ms bound recovers as a number and an ISO string stays a string. No
|
|
1265
|
+
* STORAGE coercion happens here, deliberately: `NativeSQLStrategy` needs
|
|
1266
|
+
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1267
|
+
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1268
|
+
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
1269
|
+
* the very coercion that already makes a `where` bound on that same column
|
|
1270
|
+
* work today.
|
|
1271
|
+
*
|
|
1272
|
+
* A bare-string `dateRange` degenerates to the single point `[s, s]`, matching
|
|
1273
|
+
* `NativeSQLStrategy`. Relative phrases ("Last 7 days") are NOT resolved here;
|
|
1274
|
+
* neither SQL path resolves them, and inventing a second interpretation on the
|
|
1275
|
+
* driver-independent path is how the two would drift apart again.
|
|
1276
|
+
*
|
|
1277
|
+
* An oddly-sized array (the schema types `dateRange` as a plain `string[]`)
|
|
1278
|
+
* takes its first two entries, a one-entry array degenerating to a point.
|
|
1279
|
+
* `NativeSQLStrategy` drops such a window entirely — but "drop the window"
|
|
1280
|
+
* means "plot all of history", which is the very failure this fixes, so the
|
|
1281
|
+
* fallback here errs toward the narrower query instead.
|
|
1282
|
+
*/
|
|
1283
|
+
dateRangeBounds(cube, query) {
|
|
1284
|
+
const out = [];
|
|
1285
|
+
for (const td of query.timeDimensions ?? []) {
|
|
1286
|
+
if (!td.dateRange) continue;
|
|
1287
|
+
const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
|
|
1288
|
+
const [start, end = start] = range;
|
|
1289
|
+
if (start == null) continue;
|
|
1290
|
+
out.push({
|
|
1291
|
+
field: this.resolveFieldName(cube, td.dimension, "dimension"),
|
|
1292
|
+
bounds: {
|
|
1293
|
+
$gte: coerceFilterValueForObjectQL(String(start)),
|
|
1294
|
+
$lte: coerceFilterValueForObjectQL(String(end))
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|
|
842
1300
|
convertFilter(operator, values) {
|
|
843
1301
|
if (operator === "set") return { $ne: null };
|
|
844
1302
|
if (operator === "notSet") return null;
|
|
@@ -1023,6 +1481,23 @@ function compileDataset(dataset, resolver) {
|
|
|
1023
1481
|
}
|
|
1024
1482
|
|
|
1025
1483
|
// src/dataset-executor.ts
|
|
1484
|
+
import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
|
|
1485
|
+
function resolveSelectionTokens(compiled, selection, context) {
|
|
1486
|
+
const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
|
|
1487
|
+
const resolve = (v) => resolveFilterTokens(v, tokenCtx);
|
|
1488
|
+
const filter = resolve(compiled.filter);
|
|
1489
|
+
const measureFilters = resolve(compiled.measureFilters);
|
|
1490
|
+
const runtimeFilter = resolve(selection.runtimeFilter);
|
|
1491
|
+
const timeDimensions = selection.timeDimensions?.map(
|
|
1492
|
+
(td) => td.dateRange == null ? td : { ...td, dateRange: resolve(td.dateRange) }
|
|
1493
|
+
);
|
|
1494
|
+
const compiledChanged = filter !== compiled.filter || measureFilters !== compiled.measureFilters;
|
|
1495
|
+
const selectionChanged = runtimeFilter !== selection.runtimeFilter || timeDimensions !== void 0 && timeDimensions.some((td, i) => td !== selection.timeDimensions[i]);
|
|
1496
|
+
return {
|
|
1497
|
+
compiled: compiledChanged ? { ...compiled, filter, measureFilters } : compiled,
|
|
1498
|
+
selection: selectionChanged ? { ...selection, runtimeFilter, timeDimensions } : selection
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1026
1501
|
function combineFilters(a, b) {
|
|
1027
1502
|
if (a && b) return { $and: [a, b] };
|
|
1028
1503
|
return a ?? b;
|
|
@@ -1061,6 +1536,72 @@ function computeDerived(d, row) {
|
|
|
1061
1536
|
return null;
|
|
1062
1537
|
}
|
|
1063
1538
|
}
|
|
1539
|
+
function resolveDimensionGranularity(selection, dimension, datasetDefault) {
|
|
1540
|
+
const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
|
|
1541
|
+
if (stated) return stated;
|
|
1542
|
+
return selection.dateGranularity ?? datasetDefault;
|
|
1543
|
+
}
|
|
1544
|
+
function compareValues(a, b) {
|
|
1545
|
+
const aNull = a == null || a === "";
|
|
1546
|
+
const bNull = b == null || b === "";
|
|
1547
|
+
if (aNull || bNull) return aNull && bNull ? 0 : aNull ? 1 : -1;
|
|
1548
|
+
if (a instanceof Date || b instanceof Date) {
|
|
1549
|
+
return Number(a instanceof Date ? a.getTime() : a) - Number(b instanceof Date ? b.getTime() : b);
|
|
1550
|
+
}
|
|
1551
|
+
if (typeof a === "boolean" || typeof b === "boolean") {
|
|
1552
|
+
return Number(a) - Number(b);
|
|
1553
|
+
}
|
|
1554
|
+
const an = typeof a === "number" ? a : Number(a);
|
|
1555
|
+
const bn = typeof b === "number" ? b : Number(b);
|
|
1556
|
+
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
1557
|
+
return String(a).localeCompare(String(b));
|
|
1558
|
+
}
|
|
1559
|
+
function applyOrdering(rows, order, sortKeys) {
|
|
1560
|
+
const keys = Object.entries(order ?? {});
|
|
1561
|
+
if (keys.length === 0 || rows.length < 2) return rows;
|
|
1562
|
+
return [...rows].sort((ra, rb) => {
|
|
1563
|
+
for (const [key, dir] of keys) {
|
|
1564
|
+
const map = sortKeys?.[key];
|
|
1565
|
+
const av = map?.get(ra[key]) ?? ra[key];
|
|
1566
|
+
const bv = map?.get(rb[key]) ?? rb[key];
|
|
1567
|
+
const aNull = av == null || av === "";
|
|
1568
|
+
const bNull = bv == null || bv === "";
|
|
1569
|
+
if (aNull || bNull) {
|
|
1570
|
+
if (aNull && bNull) continue;
|
|
1571
|
+
return aNull ? 1 : -1;
|
|
1572
|
+
}
|
|
1573
|
+
const c = compareValues(av, bv);
|
|
1574
|
+
if (c !== 0) return dir === "desc" ? -c : c;
|
|
1575
|
+
}
|
|
1576
|
+
return 0;
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
function applyWindow(rows, limit, offset) {
|
|
1580
|
+
const start = offset != null && offset > 0 ? offset : 0;
|
|
1581
|
+
if (start === 0 && limit == null) return rows;
|
|
1582
|
+
return rows.slice(start, limit != null ? start + limit : void 0);
|
|
1583
|
+
}
|
|
1584
|
+
function resolveOrdering(selection, dimensions) {
|
|
1585
|
+
const order = selection.order;
|
|
1586
|
+
if (order && Object.keys(order).length > 0) {
|
|
1587
|
+
const selectable = /* @__PURE__ */ new Set([
|
|
1588
|
+
...dimensions,
|
|
1589
|
+
...selection.measures,
|
|
1590
|
+
...selection.measures.map((m) => `${m}__compare`)
|
|
1591
|
+
]);
|
|
1592
|
+
const unknown = Object.keys(order).filter((k) => !selectable.has(k));
|
|
1593
|
+
if (unknown.length) {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
return order;
|
|
1599
|
+
}
|
|
1600
|
+
if ((selection.limit != null || selection.offset != null) && dimensions.length > 0) {
|
|
1601
|
+
return Object.fromEntries(dimensions.map((d) => [d, "asc"]));
|
|
1602
|
+
}
|
|
1603
|
+
return void 0;
|
|
1604
|
+
}
|
|
1064
1605
|
function parseUTC(date) {
|
|
1065
1606
|
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
1066
1607
|
if (Number.isNaN(ms)) throw new Error(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
@@ -1088,8 +1629,17 @@ function shiftRange(range, kind) {
|
|
|
1088
1629
|
return [toISODate(prevStartMs), toISODate(prevEndMs)];
|
|
1089
1630
|
}
|
|
1090
1631
|
var DatasetExecutor = class {
|
|
1091
|
-
|
|
1632
|
+
/**
|
|
1633
|
+
* @param service - The analytics service the executor issues its queries to.
|
|
1634
|
+
* @param orderLabels - Optional sort-key label hook (#3680). When provided,
|
|
1635
|
+
* an order key naming a label-bearing (`select`/`lookup`) dimension sorts
|
|
1636
|
+
* by its display label instead of the stored value. Omit to sort by stored
|
|
1637
|
+
* values everywhere (e.g. the draft-preview path, whose seed rows already
|
|
1638
|
+
* carry display names).
|
|
1639
|
+
*/
|
|
1640
|
+
constructor(service, orderLabels) {
|
|
1092
1641
|
this.service = service;
|
|
1642
|
+
this.orderLabels = orderLabels;
|
|
1093
1643
|
}
|
|
1094
1644
|
/**
|
|
1095
1645
|
* Execute a dataset selection and return the shaped rows (+ field metadata).
|
|
@@ -1098,7 +1648,8 @@ var DatasetExecutor = class {
|
|
|
1098
1648
|
* underlying `IAnalyticsService.query` so the tenant/RLS read scope is
|
|
1099
1649
|
* applied per request (ADR-0021 D-C).
|
|
1100
1650
|
*/
|
|
1101
|
-
async execute(
|
|
1651
|
+
async execute(compiledInput, selectionInput, context) {
|
|
1652
|
+
const { compiled, selection } = resolveSelectionTokens(compiledInput, selectionInput, context);
|
|
1102
1653
|
const result = await this.executeSelection(compiled, selection, context);
|
|
1103
1654
|
const groupings = selection.totals?.groupings;
|
|
1104
1655
|
if (groupings?.length) {
|
|
@@ -1142,6 +1693,14 @@ var DatasetExecutor = class {
|
|
|
1142
1693
|
}
|
|
1143
1694
|
const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
|
|
1144
1695
|
const dimensions = selection.dimensions ?? [];
|
|
1696
|
+
const order = resolveOrdering(selection, dimensions);
|
|
1697
|
+
const labelOrderKeys = this.orderLabels ? Object.keys(order ?? {}).filter(
|
|
1698
|
+
(k) => dimensions.includes(k) && this.orderLabels.isLabelBearing(k)
|
|
1699
|
+
) : [];
|
|
1700
|
+
const singleQuery = filtered.length === 0 && !selection.compareTo && selectedDerived.length === 0;
|
|
1701
|
+
const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
|
|
1702
|
+
const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
|
|
1703
|
+
const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
|
|
1145
1704
|
let result;
|
|
1146
1705
|
if (unfiltered.length > 0 || filtered.length === 0) {
|
|
1147
1706
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
@@ -1149,7 +1708,8 @@ var DatasetExecutor = class {
|
|
|
1149
1708
|
dimensions,
|
|
1150
1709
|
where: baseFilter,
|
|
1151
1710
|
selection,
|
|
1152
|
-
contextTimezone: context?.timezone
|
|
1711
|
+
contextTimezone: context?.timezone,
|
|
1712
|
+
window: windowQuery
|
|
1153
1713
|
}), context);
|
|
1154
1714
|
} else {
|
|
1155
1715
|
result = { rows: [], fields: [] };
|
|
@@ -1178,6 +1738,15 @@ var DatasetExecutor = class {
|
|
|
1178
1738
|
}
|
|
1179
1739
|
result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
|
|
1180
1740
|
for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
|
|
1741
|
+
let sortKeys;
|
|
1742
|
+
for (const key of labelOrderKeys) {
|
|
1743
|
+
const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
|
|
1744
|
+
if (values.length === 0) continue;
|
|
1745
|
+
const labels = await this.orderLabels.resolveLabels(key, values);
|
|
1746
|
+
if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
|
|
1747
|
+
}
|
|
1748
|
+
result.rows = applyOrdering(result.rows, order, sortKeys);
|
|
1749
|
+
result.rows = applyWindow(result.rows, selection.limit, selection.offset);
|
|
1181
1750
|
return result;
|
|
1182
1751
|
}
|
|
1183
1752
|
buildQuery(compiled, opts) {
|
|
@@ -1192,18 +1761,28 @@ var DatasetExecutor = class {
|
|
|
1192
1761
|
if (opts.where) q.where = opts.where;
|
|
1193
1762
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
1194
1763
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
1764
|
+
const granularityFor = (name) => {
|
|
1765
|
+
const cd = compiled.cube.dimensions[name];
|
|
1766
|
+
if (cd?.type !== "time") return void 0;
|
|
1767
|
+
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
1768
|
+
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
1769
|
+
};
|
|
1770
|
+
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
1771
|
+
if (t.granularity) return t;
|
|
1772
|
+
const granularity = granularityFor(t.dimension);
|
|
1773
|
+
return granularity ? { ...t, granularity } : t;
|
|
1774
|
+
});
|
|
1195
1775
|
const explicitTimeDims = [];
|
|
1196
1776
|
for (const name of opts.dimensions) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1777
|
+
if (selDims.has(name)) continue;
|
|
1778
|
+
const granularity = granularityFor(name);
|
|
1779
|
+
if (granularity) explicitTimeDims.push({ dimension: name, granularity });
|
|
1201
1780
|
}
|
|
1202
|
-
const mergedTimeDims = [...
|
|
1781
|
+
const mergedTimeDims = [...resolvedTimeDims, ...explicitTimeDims];
|
|
1203
1782
|
if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims;
|
|
1204
|
-
if (opts.
|
|
1205
|
-
if (opts.
|
|
1206
|
-
if (opts.
|
|
1783
|
+
if (opts.window?.order && Object.keys(opts.window.order).length > 0) q.order = opts.window.order;
|
|
1784
|
+
if (opts.window?.limit != null) q.limit = opts.window.limit;
|
|
1785
|
+
if (opts.window?.offset != null) q.offset = opts.window.offset;
|
|
1207
1786
|
return q;
|
|
1208
1787
|
}
|
|
1209
1788
|
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
@@ -1219,14 +1798,13 @@ var DatasetExecutor = class {
|
|
|
1219
1798
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
1220
1799
|
(t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
|
|
1221
1800
|
);
|
|
1222
|
-
const sub = await this.service.query({
|
|
1223
|
-
cube: compiled.cube.name,
|
|
1801
|
+
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
1224
1802
|
measures,
|
|
1225
1803
|
dimensions,
|
|
1226
1804
|
where: baseFilter,
|
|
1227
|
-
timeDimensions: shiftedTd,
|
|
1228
|
-
|
|
1229
|
-
}, context);
|
|
1805
|
+
selection: { ...selection, timeDimensions: shiftedTd },
|
|
1806
|
+
contextTimezone: context?.timezone
|
|
1807
|
+
}), context);
|
|
1230
1808
|
return sub.rows.map((row) => {
|
|
1231
1809
|
const out = {};
|
|
1232
1810
|
for (const dim of dimensions) out[dim] = row[dim];
|
|
@@ -1257,11 +1835,77 @@ function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
|
1257
1835
|
|
|
1258
1836
|
// src/dimension-labels.ts
|
|
1259
1837
|
var LOOKUP_TYPES = /* @__PURE__ */ new Set(["lookup", "master_detail"]);
|
|
1838
|
+
function createOrderLabelResolver(baseObject, dims, deps, resolveScope, context) {
|
|
1839
|
+
const dimByName = new Map(dims.map((d) => [d.name, d]));
|
|
1840
|
+
const metaFor = (dimension) => {
|
|
1841
|
+
const dim = dimByName.get(dimension);
|
|
1842
|
+
return dim ? deps.getObjectFields(baseObject)?.[dim.field] : void 0;
|
|
1843
|
+
};
|
|
1844
|
+
return {
|
|
1845
|
+
isLabelBearing(dimension) {
|
|
1846
|
+
const meta = metaFor(dimension);
|
|
1847
|
+
if (!meta) return false;
|
|
1848
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) return true;
|
|
1849
|
+
return !!(meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference);
|
|
1850
|
+
},
|
|
1851
|
+
async resolveLabels(dimension, values) {
|
|
1852
|
+
const meta = metaFor(dimension);
|
|
1853
|
+
if (!meta) return void 0;
|
|
1854
|
+
if (Array.isArray(meta.options) && meta.options.length > 0) {
|
|
1855
|
+
const labelByValue = /* @__PURE__ */ new Map();
|
|
1856
|
+
for (const opt of meta.options) {
|
|
1857
|
+
if (opt && opt.label != null) labelByValue.set(opt.value, String(opt.label));
|
|
1858
|
+
}
|
|
1859
|
+
return labelByValue;
|
|
1860
|
+
}
|
|
1861
|
+
if (meta.type && LOOKUP_TYPES.has(meta.type) && meta.reference) {
|
|
1862
|
+
let scope;
|
|
1863
|
+
if (resolveScope) {
|
|
1864
|
+
try {
|
|
1865
|
+
scope = await resolveScope(meta.reference);
|
|
1866
|
+
} catch {
|
|
1867
|
+
return void 0;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return deps.fetchRecordLabels(meta.reference, values, scope ?? void 0, context);
|
|
1871
|
+
}
|
|
1872
|
+
return void 0;
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
function withLabelFetchCache(deps) {
|
|
1877
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1878
|
+
return {
|
|
1879
|
+
getObjectFields: (objectName) => deps.getObjectFields(objectName),
|
|
1880
|
+
async fetchRecordLabels(targetObject, ids, scope, context) {
|
|
1881
|
+
let known = cache.get(targetObject);
|
|
1882
|
+
if (!known) {
|
|
1883
|
+
known = /* @__PURE__ */ new Map();
|
|
1884
|
+
cache.set(targetObject, known);
|
|
1885
|
+
}
|
|
1886
|
+
const missing = ids.filter((id) => !known.has(id));
|
|
1887
|
+
if (missing.length > 0) {
|
|
1888
|
+
const fetched = await deps.fetchRecordLabels(targetObject, missing, scope, context);
|
|
1889
|
+
for (const id of missing) known.set(id, fetched.get(id) ?? null);
|
|
1890
|
+
}
|
|
1891
|
+
const out = /* @__PURE__ */ new Map();
|
|
1892
|
+
for (const id of ids) {
|
|
1893
|
+
const label = known.get(id);
|
|
1894
|
+
if (label != null) out.set(id, label);
|
|
1895
|
+
}
|
|
1896
|
+
return out;
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1260
1900
|
var pad = (n) => String(n).padStart(2, "0");
|
|
1261
1901
|
function formatDateBucket(value, granularity) {
|
|
1262
1902
|
if (value == null || value instanceof Date === false) {
|
|
1263
1903
|
if (typeof value !== "number" && typeof value !== "string") return value;
|
|
1264
1904
|
}
|
|
1905
|
+
if (granularity === "year") {
|
|
1906
|
+
const y2 = typeof value === "number" ? value : Number(String(value).trim());
|
|
1907
|
+
if (Number.isInteger(y2) && y2 >= 1e3 && y2 <= 9999) return String(y2);
|
|
1908
|
+
}
|
|
1265
1909
|
let d;
|
|
1266
1910
|
if (value instanceof Date) d = value;
|
|
1267
1911
|
else if (typeof value === "number") d = new Date(value);
|
|
@@ -1285,7 +1929,7 @@ function formatDateBucket(value, granularity) {
|
|
|
1285
1929
|
return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
|
|
1286
1930
|
}
|
|
1287
1931
|
}
|
|
1288
|
-
async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
1932
|
+
async function resolveDimensionLabels(baseObject, dims, rows, deps, resolveScope, context) {
|
|
1289
1933
|
if (!rows.length || !dims.length) return;
|
|
1290
1934
|
const fields = deps.getObjectFields(baseObject);
|
|
1291
1935
|
if (!fields) return;
|
|
@@ -1317,7 +1961,15 @@ async function resolveDimensionLabels(baseObject, dims, rows, deps) {
|
|
|
1317
1961
|
new Set(rows.map((r) => r[dim.name]).filter((v) => v != null))
|
|
1318
1962
|
);
|
|
1319
1963
|
if (ids.length === 0) continue;
|
|
1320
|
-
|
|
1964
|
+
let scope;
|
|
1965
|
+
if (resolveScope) {
|
|
1966
|
+
try {
|
|
1967
|
+
scope = await resolveScope(meta.reference);
|
|
1968
|
+
} catch {
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
const labelById = await deps.fetchRecordLabels(meta.reference, ids, scope ?? void 0, context);
|
|
1321
1973
|
if (!labelById || labelById.size === 0) continue;
|
|
1322
1974
|
for (const row of rows) {
|
|
1323
1975
|
const label = labelById.get(row[dim.name]);
|
|
@@ -1511,6 +2163,8 @@ var AnalyticsService = class {
|
|
|
1511
2163
|
constructor(config = {}) {
|
|
1512
2164
|
/** Compiled datasets by name — feeds the join allowlist (D-C) and queryDataset. */
|
|
1513
2165
|
this.datasetRegistry = /* @__PURE__ */ new Map();
|
|
2166
|
+
/** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */
|
|
2167
|
+
this.warnedNoObjectRegistry = false;
|
|
1514
2168
|
this.logger = config.logger || createLogger({ level: "info", format: "pretty" });
|
|
1515
2169
|
this.cubeRegistry = new CubeRegistry();
|
|
1516
2170
|
if (config.cubes) {
|
|
@@ -1521,6 +2175,7 @@ var AnalyticsService = class {
|
|
|
1521
2175
|
this.measureCurrency = config.measureCurrency;
|
|
1522
2176
|
this.labelResolver = config.labelResolver;
|
|
1523
2177
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2178
|
+
this.isRegisteredObject = config.isRegisteredObject;
|
|
1524
2179
|
if (config.datasets) {
|
|
1525
2180
|
for (const ds of config.datasets) {
|
|
1526
2181
|
try {
|
|
@@ -1561,10 +2216,11 @@ var AnalyticsService = class {
|
|
|
1561
2216
|
* `getReadScope(objectName)` that already knows the active tenant.
|
|
1562
2217
|
*/
|
|
1563
2218
|
async callCtx(query, context) {
|
|
1564
|
-
if (!this.readScopeProvider) return this.baseCtx;
|
|
2219
|
+
if (!this.readScopeProvider) return { ...this.baseCtx, context };
|
|
1565
2220
|
const scopes = await this.resolveReadScopes(query, context);
|
|
1566
2221
|
return {
|
|
1567
2222
|
...this.baseCtx,
|
|
2223
|
+
context,
|
|
1568
2224
|
getReadScope: (objectName) => scopes.get(objectName) ?? null
|
|
1569
2225
|
};
|
|
1570
2226
|
}
|
|
@@ -1685,9 +2341,19 @@ var AnalyticsService = class {
|
|
|
1685
2341
|
return previewResult;
|
|
1686
2342
|
}
|
|
1687
2343
|
}
|
|
2344
|
+
const provider = this.readScopeProvider;
|
|
2345
|
+
const resolveScope = provider ? (targetObject) => provider(targetObject, context) : void 0;
|
|
2346
|
+
const labelDeps = this.labelResolver ? withLabelFetchCache(this.labelResolver) : void 0;
|
|
2347
|
+
const orderLabels = labelDeps && dataset.dimensions?.length ? createOrderLabelResolver(
|
|
2348
|
+
dataset.object,
|
|
2349
|
+
dataset.dimensions.filter((d) => !!d.field).map((d) => ({ name: d.name, field: d.field })),
|
|
2350
|
+
labelDeps,
|
|
2351
|
+
resolveScope,
|
|
2352
|
+
context
|
|
2353
|
+
) : void 0;
|
|
1688
2354
|
let result;
|
|
1689
2355
|
try {
|
|
1690
|
-
result = await new DatasetExecutor(this).execute(compiled, selection, context);
|
|
2356
|
+
result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
|
|
1691
2357
|
} catch (err) {
|
|
1692
2358
|
if (isMissingSourceError(err)) {
|
|
1693
2359
|
this.logger.warn(
|
|
@@ -1723,18 +2389,20 @@ var AnalyticsService = class {
|
|
|
1723
2389
|
const rangeTz = selection.timezone ?? context?.timezone ?? "UTC";
|
|
1724
2390
|
const rangeDims = [];
|
|
1725
2391
|
for (const d of selectedDims) {
|
|
1726
|
-
if (!d.field || d.type !== "date"
|
|
2392
|
+
if (!d.field || d.type !== "date") continue;
|
|
2393
|
+
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
|
|
2394
|
+
if (!granularity) continue;
|
|
1727
2395
|
const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
|
|
1728
|
-
if (ftype === "datetime") rangeDims.push({ d, instant: true });
|
|
1729
|
-
else if (ftype === "date") rangeDims.push({ d, instant: false });
|
|
1730
|
-
else if (rangeTz === "UTC") rangeDims.push({ d, instant: false });
|
|
2396
|
+
if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
|
|
2397
|
+
else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
|
|
2398
|
+
else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
|
|
1731
2399
|
}
|
|
1732
2400
|
if (rangeDims.length && result.rows.length) {
|
|
1733
2401
|
const bound = (ymd, instant) => instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
|
|
1734
2402
|
result.drillRanges = result.rows.map((row) => {
|
|
1735
2403
|
const ranges = {};
|
|
1736
|
-
for (const { d, instant } of rangeDims) {
|
|
1737
|
-
const cal = bucketKeyToCalendarRange(row[d.name],
|
|
2404
|
+
for (const { d, granularity, instant } of rangeDims) {
|
|
2405
|
+
const cal = bucketKeyToCalendarRange(row[d.name], granularity);
|
|
1738
2406
|
if (cal) {
|
|
1739
2407
|
ranges[d.name] = { field: d.field, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
|
|
1740
2408
|
}
|
|
@@ -1743,15 +2411,20 @@ var AnalyticsService = class {
|
|
|
1743
2411
|
});
|
|
1744
2412
|
result.object = dataset.object;
|
|
1745
2413
|
}
|
|
1746
|
-
if (
|
|
1747
|
-
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2414
|
+
if (labelDeps && selectedDims.length) {
|
|
2415
|
+
const dims = selectedDims.filter((d) => !!d.field).map((d) => ({
|
|
2416
|
+
name: d.name,
|
|
2417
|
+
field: d.field,
|
|
2418
|
+
type: d.type,
|
|
2419
|
+
dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity)
|
|
2420
|
+
}));
|
|
1748
2421
|
if (dims.length) {
|
|
1749
2422
|
try {
|
|
1750
|
-
await resolveDimensionLabels(dataset.object, dims, result.rows,
|
|
2423
|
+
await resolveDimensionLabels(dataset.object, dims, result.rows, labelDeps, resolveScope, context);
|
|
1751
2424
|
for (const total of result.totals ?? []) {
|
|
1752
2425
|
const subset = dims.filter((d) => total.dimensions.includes(d.name));
|
|
1753
2426
|
if (subset.length) {
|
|
1754
|
-
await resolveDimensionLabels(dataset.object, subset, total.rows,
|
|
2427
|
+
await resolveDimensionLabels(dataset.object, subset, total.rows, labelDeps, resolveScope, context);
|
|
1755
2428
|
}
|
|
1756
2429
|
}
|
|
1757
2430
|
} catch (e) {
|
|
@@ -1839,6 +2512,7 @@ var AnalyticsService = class {
|
|
|
1839
2512
|
const name = query.cube;
|
|
1840
2513
|
let cube = this.cubeRegistry.get(name);
|
|
1841
2514
|
if (!cube) {
|
|
2515
|
+
this.assertInferableCube(name);
|
|
1842
2516
|
cube = this.inferCubeFromQuery(query);
|
|
1843
2517
|
this.cubeRegistry.register(cube);
|
|
1844
2518
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
@@ -1865,6 +2539,39 @@ var AnalyticsService = class {
|
|
|
1865
2539
|
);
|
|
1866
2540
|
}
|
|
1867
2541
|
}
|
|
2542
|
+
/**
|
|
2543
|
+
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
2544
|
+
* Cube may only be inferred into one if it is a registered object.
|
|
2545
|
+
*
|
|
2546
|
+
* Rejects with `status: 404` / `code: 'CUBE_NOT_FOUND'` so the HTTP boundary
|
|
2547
|
+
* answers "no such cube" instead of letting the name reach the driver as a
|
|
2548
|
+
* table and surfacing whatever the driver says about it. The message names
|
|
2549
|
+
* both ways the request could be made valid, because from here the two are
|
|
2550
|
+
* genuinely indistinguishable: register a Cube, or register the object.
|
|
2551
|
+
*
|
|
2552
|
+
* Skips when `isRegisteredObject` was not supplied — see the config field's
|
|
2553
|
+
* doc for why that tier is a deliberate stand-down and not a hole.
|
|
2554
|
+
*/
|
|
2555
|
+
assertInferableCube(name) {
|
|
2556
|
+
const isRegisteredObject = this.isRegisteredObject;
|
|
2557
|
+
if (!isRegisteredObject) {
|
|
2558
|
+
if (!this.warnedNoObjectRegistry) {
|
|
2559
|
+
this.warnedNoObjectRegistry = true;
|
|
2560
|
+
this.logger.warn(
|
|
2561
|
+
"[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."
|
|
2562
|
+
);
|
|
2563
|
+
}
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
if (isRegisteredObject(name)) return;
|
|
2567
|
+
const err = new Error(
|
|
2568
|
+
`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.`
|
|
2569
|
+
);
|
|
2570
|
+
err.code = "CUBE_NOT_FOUND";
|
|
2571
|
+
err.status = 404;
|
|
2572
|
+
err.cube = name;
|
|
2573
|
+
throw err;
|
|
2574
|
+
}
|
|
1868
2575
|
/** Build a minimal Cube from the fields referenced by an AnalyticsQuery. */
|
|
1869
2576
|
inferCubeFromQuery(query) {
|
|
1870
2577
|
const cubeName = query.cube;
|
|
@@ -2006,7 +2713,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2006
2713
|
'[Analytics] No "data" service registered yet at init; will retry per-query. Register ObjectQLPlugin or pass executeAggregate.'
|
|
2007
2714
|
);
|
|
2008
2715
|
}
|
|
2009
|
-
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone }) => {
|
|
2716
|
+
executeAggregate = async (objectName, { groupBy, aggregations, filter, timezone, context }) => {
|
|
2010
2717
|
const engine = tryGetDataEngine();
|
|
2011
2718
|
if (!engine) {
|
|
2012
2719
|
throw new Error(
|
|
@@ -2023,7 +2730,13 @@ var AnalyticsServicePlugin = class {
|
|
|
2023
2730
|
})),
|
|
2024
2731
|
// ADR-0053 Phase 2: thread the reference tz so date buckets resolve on
|
|
2025
2732
|
// that zone's calendar days (engine buckets in-memory when non-UTC).
|
|
2026
|
-
timezone
|
|
2733
|
+
timezone,
|
|
2734
|
+
// ADR-0021 D-C (#3602): thread the caller's identity so the engine's
|
|
2735
|
+
// middleware chain scopes the read itself. `BaseEngineOptions.context`
|
|
2736
|
+
// is `.optional()`, so nothing ever forced this bridge to pass it —
|
|
2737
|
+
// and it did not, which is how an authenticated aggregate reached the
|
|
2738
|
+
// engine with no principal and plugin-security fell open (#3597).
|
|
2739
|
+
context
|
|
2027
2740
|
});
|
|
2028
2741
|
return rows;
|
|
2029
2742
|
};
|
|
@@ -2071,6 +2784,7 @@ var AnalyticsServicePlugin = class {
|
|
|
2071
2784
|
}));
|
|
2072
2785
|
let getReadScope = this.options.getReadScope;
|
|
2073
2786
|
let autoBridgedReadScope = false;
|
|
2787
|
+
let securityPresentAtInit = false;
|
|
2074
2788
|
if (!getReadScope) {
|
|
2075
2789
|
const trySecurity = () => {
|
|
2076
2790
|
try {
|
|
@@ -2080,10 +2794,9 @@ var AnalyticsServicePlugin = class {
|
|
|
2080
2794
|
return void 0;
|
|
2081
2795
|
}
|
|
2082
2796
|
};
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
}
|
|
2797
|
+
securityPresentAtInit = !!trySecurity();
|
|
2798
|
+
getReadScope = (object, context) => trySecurity()?.getReadFilter(object, context);
|
|
2799
|
+
autoBridgedReadScope = true;
|
|
2087
2800
|
}
|
|
2088
2801
|
const relationshipResolver = (baseObject, relationshipName) => {
|
|
2089
2802
|
const engine = (() => {
|
|
@@ -2111,17 +2824,26 @@ var AnalyticsServicePlugin = class {
|
|
|
2111
2824
|
};
|
|
2112
2825
|
const labelResolver = {
|
|
2113
2826
|
getObjectFields: (objectName) => dataEngine()?.getObject?.(objectName)?.fields,
|
|
2114
|
-
fetchRecordLabels: async (targetObject, ids) => {
|
|
2827
|
+
fetchRecordLabels: async (targetObject, ids, scope, context) => {
|
|
2115
2828
|
const map = /* @__PURE__ */ new Map();
|
|
2116
2829
|
const displayField = pickDisplayField(dataEngine()?.getObject?.(targetObject)?.fields);
|
|
2117
2830
|
if (!displayField || !executeAggregate || ids.length === 0) return map;
|
|
2118
|
-
const
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
filter
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2831
|
+
const CHUNK = 500;
|
|
2832
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
2833
|
+
const idFilter = { id: { $in: ids.slice(i, i + CHUNK) } };
|
|
2834
|
+
const filter = scope ? { $and: [idFilter, scope] } : idFilter;
|
|
2835
|
+
const rows = await executeAggregate(targetObject, {
|
|
2836
|
+
groupBy: ["id", displayField],
|
|
2837
|
+
aggregations: [{ field: "id", method: "count", alias: "_c" }],
|
|
2838
|
+
filter,
|
|
2839
|
+
// #3602 second belt — `scope` above is the analytics layer's own
|
|
2840
|
+
// predicate on this per-record read; the context makes the engine's
|
|
2841
|
+
// middleware scope it as well.
|
|
2842
|
+
context
|
|
2843
|
+
});
|
|
2844
|
+
for (const r of rows) {
|
|
2845
|
+
if (r.id != null && r[displayField] != null) map.set(r.id, String(r[displayField]));
|
|
2846
|
+
}
|
|
2125
2847
|
}
|
|
2126
2848
|
return map;
|
|
2127
2849
|
}
|
|
@@ -2186,13 +2908,31 @@ var AnalyticsServicePlugin = class {
|
|
|
2186
2908
|
const obj = dataEngine()?.getObject?.(objectName);
|
|
2187
2909
|
return !!(obj && obj.external != null);
|
|
2188
2910
|
},
|
|
2911
|
+
// [#3867] Existence probe for the cube auto-inference gate. Reads the
|
|
2912
|
+
// same schema registry the data path's #3770 gate consults, through the
|
|
2913
|
+
// engine accessor this bridge already uses above — so "which objects
|
|
2914
|
+
// exist" has one answer across /data and /analytics.
|
|
2915
|
+
//
|
|
2916
|
+
// `dataEngine()` resolves lazily and may be absent entirely (analytics
|
|
2917
|
+
// installed without a data engine). Reporting `false` there would 404
|
|
2918
|
+
// every cube, so an unresolvable engine reports `true` — "cannot answer,
|
|
2919
|
+
// do not block" — mirroring the tiering #3770 took on the data path.
|
|
2920
|
+
isRegisteredObject: (name) => {
|
|
2921
|
+
const engine = dataEngine();
|
|
2922
|
+
if (!engine) return true;
|
|
2923
|
+
return engine.getObject?.(name) != null;
|
|
2924
|
+
},
|
|
2189
2925
|
draftRowsResolver
|
|
2190
2926
|
};
|
|
2191
|
-
if (autoBridgedReadScope) {
|
|
2927
|
+
if (autoBridgedReadScope && securityPresentAtInit) {
|
|
2192
2928
|
ctx.logger.info('[Analytics] Auto-bridged getReadScope \u2192 "security" service (getReadFilter)');
|
|
2929
|
+
} else if (autoBridgedReadScope) {
|
|
2930
|
+
ctx.logger.info(
|
|
2931
|
+
'[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).'
|
|
2932
|
+
);
|
|
2193
2933
|
} else if (!getReadScope) {
|
|
2194
2934
|
ctx.logger.warn(
|
|
2195
|
-
'[Analytics] No getReadScope configured and no "security" service with getReadFilter found \u2014
|
|
2935
|
+
'[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.'
|
|
2196
2936
|
);
|
|
2197
2937
|
}
|
|
2198
2938
|
if (autoBridged) {
|
|
@@ -2235,10 +2975,12 @@ export {
|
|
|
2235
2975
|
combineFilters,
|
|
2236
2976
|
compileDataset,
|
|
2237
2977
|
compileScopedFilterToSql,
|
|
2978
|
+
createOrderLabelResolver,
|
|
2238
2979
|
evaluateDerivedMeasures,
|
|
2239
2980
|
mergeByDimensions,
|
|
2240
2981
|
pickDisplayField,
|
|
2241
2982
|
resolveDimensionLabels,
|
|
2242
|
-
shiftRange
|
|
2983
|
+
shiftRange,
|
|
2984
|
+
withLabelFetchCache
|
|
2243
2985
|
};
|
|
2244
2986
|
//# sourceMappingURL=index.js.map
|