@objectstack/service-analytics 17.0.0-rc.1 → 17.0.0-rc.2

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 CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  compileScopedFilterToSql: () => compileScopedFilterToSql,
32
32
  createOrderLabelResolver: () => createOrderLabelResolver,
33
33
  evaluateDerivedMeasures: () => evaluateDerivedMeasures,
34
+ fillEmptyGroups: () => fillEmptyGroups,
34
35
  mergeByDimensions: () => mergeByDimensions,
35
36
  pickDisplayField: () => pickDisplayField,
36
37
  resolveDimensionLabels: () => resolveDimensionLabels,
@@ -40,6 +41,7 @@ __export(index_exports, {
40
41
  module.exports = __toCommonJS(index_exports);
41
42
 
42
43
  // src/analytics-service.ts
44
+ var import_data3 = require("@objectstack/spec/data");
43
45
  var import_core5 = require("@objectstack/core");
44
46
 
45
47
  // src/cube-registry.ts
@@ -1749,6 +1751,7 @@ function compileDataset(dataset, resolver) {
1749
1751
  }
1750
1752
 
1751
1753
  // src/dataset-executor.ts
1754
+ var import_data2 = require("@objectstack/spec/data");
1752
1755
  var import_core3 = require("@objectstack/core");
1753
1756
  function resolveSelectionTokens(compiled, selection, context) {
1754
1757
  const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
@@ -1770,6 +1773,12 @@ function combineFilters(a, b) {
1770
1773
  if (a && b) return { $and: [a, b] };
1771
1774
  return a ?? b;
1772
1775
  }
1776
+ function splitMeasuresByFilter(measures, measureFilters) {
1777
+ const unfiltered = [];
1778
+ const filtered = [];
1779
+ for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
1780
+ return { unfiltered, filtered };
1781
+ }
1773
1782
  function evaluateDerivedMeasures(rows, derived) {
1774
1783
  if (derived.length === 0) return rows;
1775
1784
  return rows.map((row) => {
@@ -1780,6 +1789,14 @@ function evaluateDerivedMeasures(rows, derived) {
1780
1789
  return out;
1781
1790
  });
1782
1791
  }
1792
+ function fillEmptyGroups(rows, columnAggregates) {
1793
+ for (const [column, aggregate2] of Object.entries(columnAggregates)) {
1794
+ const empty = (0, import_data2.emptyGroupValueFor)(aggregate2);
1795
+ if (empty === void 0) continue;
1796
+ for (const row of rows) if (row[column] == null) row[column] = empty;
1797
+ }
1798
+ return rows;
1799
+ }
1783
1800
  function num(v) {
1784
1801
  if (v == null) return null;
1785
1802
  const n = typeof v === "number" ? v : Number(v);
@@ -1958,11 +1975,7 @@ var DatasetExecutor = class {
1958
1975
  for (const d of selectedDerived) {
1959
1976
  for (const dep of d.of) baseMeasures.add(dep);
1960
1977
  }
1961
- const unfiltered = [];
1962
- const filtered = [];
1963
- for (const m of baseMeasures) {
1964
- (compiled.measureFilters[m] ? filtered : unfiltered).push(m);
1965
- }
1978
+ const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
1966
1979
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1967
1980
  const dimensions = selection.dimensions ?? [];
1968
1981
  const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
@@ -1973,6 +1986,76 @@ var DatasetExecutor = class {
1973
1986
  const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1974
1987
  const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1975
1988
  const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1989
+ const result = await this.runMeasurePass(compiled, selection, {
1990
+ measures: [...baseMeasures],
1991
+ dimensions,
1992
+ baseFilter,
1993
+ window: windowQuery,
1994
+ context
1995
+ });
1996
+ if (selection.compareTo) {
1997
+ const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1998
+ result.rows = mergeByDimensions(
1999
+ result.rows,
2000
+ compareRows,
2001
+ dimensions,
2002
+ [...baseMeasures].map((m) => `${m}__compare`)
2003
+ );
2004
+ for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
2005
+ }
2006
+ const fillColumns = {};
2007
+ for (const m of baseMeasures) {
2008
+ const aggregate2 = compiled.cube.measures?.[m]?.type;
2009
+ fillColumns[m] = aggregate2;
2010
+ if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
2011
+ }
2012
+ fillEmptyGroups(result.rows, fillColumns);
2013
+ result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
2014
+ for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
2015
+ let sortKeys;
2016
+ for (const key of labelOrderKeys) {
2017
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
2018
+ if (values.length === 0) continue;
2019
+ const labels = await this.orderLabels.resolveLabels(key, values);
2020
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
2021
+ }
2022
+ result.rows = applyOrdering(result.rows, order, sortKeys);
2023
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
2024
+ return result;
2025
+ }
2026
+ /**
2027
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
2028
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
2029
+ * supplementary query per filter-scoped measure, merged back by dimension key.
2030
+ *
2031
+ * **This is the executor's only implementation of "how a measure filter is
2032
+ * applied", and every window goes through it** — the current period, each
2033
+ * `totals` subset (which re-enters via `executeSelection`), and the
2034
+ * `compareTo` window. Before #4820 the comparison window had its own,
2035
+ * simpler answer: one shifted query over all base measures with only the
2036
+ * base filter, so `compiled.measureFilters` was never read on that path.
2037
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
2038
+ * under one label, in adjacent columns. Only measures carrying a filter were
2039
+ * wrong — which is what made it survive: the unfiltered ones next to them
2040
+ * compared correctly.
2041
+ *
2042
+ * The caller supplies the `selection` this pass queries under, which is how
2043
+ * the comparison window differs at all: same measures, same dimensions, same
2044
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
2045
+ * about the two passes may drift, because anything that does becomes a
2046
+ * discrepancy between two columns the reader is invited to subtract.
2047
+ *
2048
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
2049
+ * The alternative — declaring the discrepancy in the response — is not one,
2050
+ * since the two columns exist to be directly comparable.
2051
+ *
2052
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
2053
+ * selection the caller proved is a single self-sufficient query; a pass
2054
+ * that fans out must return its whole grid for the merge.
2055
+ */
2056
+ async runMeasurePass(compiled, selection, opts) {
2057
+ const { measures, dimensions, baseFilter, window, context } = opts;
2058
+ const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
1976
2059
  let result;
1977
2060
  if (unfiltered.length > 0 || filtered.length === 0) {
1978
2061
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1981,7 +2064,7 @@ var DatasetExecutor = class {
1981
2064
  where: baseFilter,
1982
2065
  selection,
1983
2066
  contextTimezone: context?.timezone,
1984
- window: windowQuery
2067
+ window
1985
2068
  }), context);
1986
2069
  } else {
1987
2070
  result = { rows: [], fields: [] };
@@ -1998,27 +2081,6 @@ var DatasetExecutor = class {
1998
2081
  result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
1999
2082
  result.fields.push({ name: m, type: "number" });
2000
2083
  }
2001
- if (selection.compareTo) {
2002
- const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
2003
- result.rows = mergeByDimensions(
2004
- result.rows,
2005
- compareRows,
2006
- dimensions,
2007
- [...baseMeasures].map((m) => `${m}__compare`)
2008
- );
2009
- for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
2010
- }
2011
- result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
2012
- for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
2013
- let sortKeys;
2014
- for (const key of labelOrderKeys) {
2015
- const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
2016
- if (values.length === 0) continue;
2017
- const labels = await this.orderLabels.resolveLabels(key, values);
2018
- if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
2019
- }
2020
- result.rows = applyOrdering(result.rows, order, sortKeys);
2021
- result.rows = applyWindow(result.rows, selection.limit, selection.offset);
2022
2084
  return result;
2023
2085
  }
2024
2086
  /**
@@ -2083,13 +2145,11 @@ var DatasetExecutor = class {
2083
2145
  const shiftedTd = (selection.timeDimensions ?? []).map(
2084
2146
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
2085
2147
  );
2086
- const sub = await this.service.query(this.buildQuery(compiled, {
2087
- measures,
2088
- dimensions,
2089
- where: baseFilter,
2090
- selection: { ...selection, timeDimensions: shiftedTd },
2091
- contextTimezone: context?.timezone
2092
- }), context);
2148
+ const sub = await this.runMeasurePass(
2149
+ compiled,
2150
+ { ...selection, timeDimensions: shiftedTd },
2151
+ { measures, dimensions, baseFilter, context }
2152
+ );
2093
2153
  return sub.rows.map((row) => {
2094
2154
  const out = {};
2095
2155
  for (const dim of dimensions) out[dim] = row[dim];
@@ -2459,6 +2519,7 @@ function isMissingSourceError(err) {
2459
2519
  msg.includes("not registered") || // framework: object not in registry
2460
2520
  msg.includes("unknown object") || msg.includes("is not a registered object");
2461
2521
  }
2522
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
2462
2523
  var DEFAULT_CAPABILITIES = {
2463
2524
  nativeSql: false,
2464
2525
  objectqlAggregate: false,
@@ -2477,10 +2538,11 @@ var AnalyticsService = class {
2477
2538
  }
2478
2539
  this.readScopeProvider = config.getReadScope;
2479
2540
  this.relationshipResolver = config.relationshipResolver;
2480
- this.measureCurrency = config.measureCurrency;
2541
+ this.sourceFieldMeta = config.sourceFieldMeta;
2481
2542
  this.labelResolver = config.labelResolver;
2482
2543
  this.draftRowsResolver = config.draftRowsResolver;
2483
2544
  this.isRegisteredObject = config.isRegisteredObject;
2545
+ this.getObjectFieldNames = config.getObjectFieldNames;
2484
2546
  if (config.datasets) {
2485
2547
  for (const ds of config.datasets) {
2486
2548
  try {
@@ -2698,7 +2760,7 @@ var AnalyticsService = class {
2698
2760
  if (!d.field || d.type !== "date") continue;
2699
2761
  const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2700
2762
  if (!granularity) continue;
2701
- const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
2763
+ const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
2702
2764
  if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2703
2765
  else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2704
2766
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
@@ -2747,14 +2809,17 @@ var AnalyticsService = class {
2747
2809
  if (f.format == null && m.format) f.format = m.format;
2748
2810
  const fc = f;
2749
2811
  const mc = m;
2812
+ const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
2750
2813
  if (fc.currency == null) {
2751
- const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
2752
2814
  const monetary = !!mc.currency || meta?.type === "currency";
2753
2815
  if (monetary) {
2754
2816
  const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
2755
2817
  if (resolved) fc.currency = resolved;
2756
2818
  }
2757
2819
  }
2820
+ if (f.percentScale == null) {
2821
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data3.percentScaleOf)(meta);
2822
+ }
2758
2823
  }
2759
2824
  }
2760
2825
  if (result.fields?.length && selectedDims.length) {
@@ -2820,6 +2885,7 @@ var AnalyticsService = class {
2820
2885
  if (!cube) {
2821
2886
  this.assertInferableCube(name);
2822
2887
  cube = this.inferCubeFromQuery(query);
2888
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2823
2889
  this.cubeRegistry.register(cube);
2824
2890
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
2825
2891
  const message = `[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`;
@@ -2839,10 +2905,82 @@ var AnalyticsService = class {
2839
2905
  ...cube,
2840
2906
  measures: { ...cube.measures, ...extraMeasures }
2841
2907
  };
2908
+ this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
2842
2909
  this.cubeRegistry.register(augmented);
2843
2910
  this.logger.debug(
2844
2911
  `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
2845
2912
  );
2913
+ } else {
2914
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2915
+ }
2916
+ }
2917
+ /**
2918
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
2919
+ * have, BEFORE the strategy compiles it into SQL.
2920
+ *
2921
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
2922
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
2923
+ * driver threw `no such column`, and the caller got
2924
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
2925
+ * error class on the wire, and nothing actionable, for what is a plain typo.
2926
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
2927
+ * naming the field since #4315/#4254; this is the analytics half of that
2928
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
2929
+ * `param`) so one mistake has one shape across both routes.
2930
+ *
2931
+ * What it checks, and what it deliberately does not:
2932
+ *
2933
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
2934
+ * `sql` is a real SQL expression has no field list to check against.
2935
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
2936
+ * Absent hook / unknown object → stand down (see the config field's doc).
2937
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
2938
+ * field, and a dotted reference (`account.industry`) resolves through a
2939
+ * join whose target this check cannot see — both pass through untouched.
2940
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
2941
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
2942
+ * declared, and a gate stricter than the engine it guards would reject
2943
+ * queries that used to work.
2944
+ */
2945
+ assertMeasureFields(query, cube, declaredMeasures) {
2946
+ const probe = this.getObjectFieldNames;
2947
+ if (!probe) return;
2948
+ const measures = query.measures ?? [];
2949
+ if (measures.length === 0) return;
2950
+ const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
2951
+ if (!object || !BARE_IDENTIFIER.test(object)) return;
2952
+ const fieldNames = probe(object);
2953
+ if (!fieldNames || fieldNames.length === 0) return;
2954
+ const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
2955
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
2956
+ const sourceFieldOf = (measure) => {
2957
+ const metric = cube.measures[stripPrefix(measure)];
2958
+ if (!metric) return null;
2959
+ if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
2960
+ const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
2961
+ if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
2962
+ return source;
2963
+ };
2964
+ const invalid = /* @__PURE__ */ new Set();
2965
+ for (const measure of measures) {
2966
+ const source = sourceFieldOf(measure);
2967
+ if (source && !known.has(source)) invalid.add(stripPrefix(measure));
2968
+ }
2969
+ if (invalid.size === 0) return;
2970
+ const usable = declaredMeasures.filter((m) => !invalid.has(m));
2971
+ for (const measure of measures) {
2972
+ const source = sourceFieldOf(measure);
2973
+ if (!source || known.has(source)) continue;
2974
+ const err = new Error(
2975
+ `Measure '${measure}' on cube '${cube.name}' aggregates field '${source}', which object '${object}' does not have. Valid measures: ${usable.join(", ") || "(none)"}. Other measures are inferred from the object's OWN fields as '<field>_sum' / '_avg' / '_min' / '_max' / '_count_distinct', so check the spelling of '${source}' \u2014 known fields: ${[...fieldNames].sort().join(", ")}.`
2976
+ );
2977
+ err.code = "INVALID_FIELD";
2978
+ err.status = 400;
2979
+ err.field = source;
2980
+ err.object = object;
2981
+ err.param = "measures";
2982
+ err.measure = measure;
2983
+ throw err;
2846
2984
  }
2847
2985
  }
2848
2986
  /**
@@ -2996,6 +3134,14 @@ var AnalyticsServicePlugin = class {
2996
3134
  this.version = "1.0.0";
2997
3135
  this.type = "standard";
2998
3136
  this.dependencies = [];
3137
+ /**
3138
+ * init() probes the `data` engine ObjectQLPlugin provides for the
3139
+ * auto-bridge — order-if-present so the probe verdict is deterministic
3140
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
3141
+ * degrades on purpose (per-query lazy resolution / explicit
3142
+ * `executeAggregate`).
3143
+ */
3144
+ this.optionalDependencies = ["com.objectstack.engine.objectql"];
2999
3145
  this.options = options;
3000
3146
  }
3001
3147
  async init(ctx) {
@@ -3219,10 +3365,12 @@ var AnalyticsServicePlugin = class {
3219
3365
  coerceTemporalFilterColumn,
3220
3366
  relationshipResolver,
3221
3367
  labelResolver,
3222
- // ADR-0053 — source-field currency metadata for the measure currency chain.
3223
- measureCurrency: (object, field) => {
3368
+ // Source-field metadata behind the display chains on result columns:
3369
+ // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
3370
+ // (`max`, which is what marks whole-percent storage — objectui#3136).
3371
+ sourceFieldMeta: (object, field) => {
3224
3372
  const f = dataEngine()?.getObject?.(object)?.fields?.[field];
3225
- return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3373
+ return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3226
3374
  },
3227
3375
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
3228
3376
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
@@ -3245,6 +3393,18 @@ var AnalyticsServicePlugin = class {
3245
3393
  if (!engine) return true;
3246
3394
  return engine.getObject?.(name) != null;
3247
3395
  },
3396
+ // [#4437] Field names for the measure source-field gate. Read from the
3397
+ // SAME schema registry `isRegisteredObject` above consults (and the data
3398
+ // path's #4315 gate reads), so "which fields exist" has one answer across
3399
+ // /data and /analytics. `undefined` — no engine, unknown object, or an
3400
+ // object with no field map (an external datasource whose columns are not
3401
+ // mirrored locally) — means "cannot answer", and the gate stands down.
3402
+ getObjectFieldNames: (objectName) => {
3403
+ const fields = dataEngine()?.getObject?.(objectName)?.fields;
3404
+ if (!fields || typeof fields !== "object") return void 0;
3405
+ const names = Object.keys(fields);
3406
+ return names.length > 0 ? names : void 0;
3407
+ },
3248
3408
  draftRowsResolver
3249
3409
  };
3250
3410
  if (autoBridgedReadScope && securityPresentAtInit) {
@@ -3301,6 +3461,7 @@ var AnalyticsServicePlugin = class {
3301
3461
  compileScopedFilterToSql,
3302
3462
  createOrderLabelResolver,
3303
3463
  evaluateDerivedMeasures,
3464
+ fillEmptyGroups,
3304
3465
  mergeByDimensions,
3305
3466
  pickDisplayField,
3306
3467
  resolveDimensionLabels,