@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.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/analytics-service.ts
2
+ import { percentScaleOf } from "@objectstack/spec/data";
2
3
  import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from "@objectstack/core";
3
4
 
4
5
  // src/cube-registry.ts
@@ -1708,6 +1709,7 @@ function compileDataset(dataset, resolver) {
1708
1709
  }
1709
1710
 
1710
1711
  // src/dataset-executor.ts
1712
+ import { emptyGroupValueFor } from "@objectstack/spec/data";
1711
1713
  import { filterTokenContextFrom, resolveFilterTokens } from "@objectstack/core";
1712
1714
  function resolveSelectionTokens(compiled, selection, context) {
1713
1715
  const tokenCtx = filterTokenContextFrom(context, /* @__PURE__ */ new Date());
@@ -1729,6 +1731,12 @@ function combineFilters(a, b) {
1729
1731
  if (a && b) return { $and: [a, b] };
1730
1732
  return a ?? b;
1731
1733
  }
1734
+ function splitMeasuresByFilter(measures, measureFilters) {
1735
+ const unfiltered = [];
1736
+ const filtered = [];
1737
+ for (const m of measures) (measureFilters[m] ? filtered : unfiltered).push(m);
1738
+ return { unfiltered, filtered };
1739
+ }
1732
1740
  function evaluateDerivedMeasures(rows, derived) {
1733
1741
  if (derived.length === 0) return rows;
1734
1742
  return rows.map((row) => {
@@ -1739,6 +1747,14 @@ function evaluateDerivedMeasures(rows, derived) {
1739
1747
  return out;
1740
1748
  });
1741
1749
  }
1750
+ function fillEmptyGroups(rows, columnAggregates) {
1751
+ for (const [column, aggregate2] of Object.entries(columnAggregates)) {
1752
+ const empty = emptyGroupValueFor(aggregate2);
1753
+ if (empty === void 0) continue;
1754
+ for (const row of rows) if (row[column] == null) row[column] = empty;
1755
+ }
1756
+ return rows;
1757
+ }
1742
1758
  function num(v) {
1743
1759
  if (v == null) return null;
1744
1760
  const n = typeof v === "number" ? v : Number(v);
@@ -1917,11 +1933,7 @@ var DatasetExecutor = class {
1917
1933
  for (const d of selectedDerived) {
1918
1934
  for (const dep of d.of) baseMeasures.add(dep);
1919
1935
  }
1920
- const unfiltered = [];
1921
- const filtered = [];
1922
- for (const m of baseMeasures) {
1923
- (compiled.measureFilters[m] ? filtered : unfiltered).push(m);
1924
- }
1936
+ const { unfiltered, filtered } = splitMeasuresByFilter(baseMeasures, compiled.measureFilters);
1925
1937
  const baseFilter = combineFilters(compiled.filter, selection.runtimeFilter);
1926
1938
  const dimensions = selection.dimensions ?? [];
1927
1939
  const order = resolveOrdering(selection, dimensions, this.timeDimensionsOf(compiled, dimensions));
@@ -1932,6 +1944,76 @@ var DatasetExecutor = class {
1932
1944
  const pushDownKeys = /* @__PURE__ */ new Set([...dimensions, ...unfiltered]);
1933
1945
  const canPushDownWindow = singleQuery && labelOrderKeys.length === 0 && Object.keys(order ?? {}).every((k) => pushDownKeys.has(k));
1934
1946
  const windowQuery = canPushDownWindow ? { order, limit: selection.limit, offset: selection.offset } : void 0;
1947
+ const result = await this.runMeasurePass(compiled, selection, {
1948
+ measures: [...baseMeasures],
1949
+ dimensions,
1950
+ baseFilter,
1951
+ window: windowQuery,
1952
+ context
1953
+ });
1954
+ if (selection.compareTo) {
1955
+ const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1956
+ result.rows = mergeByDimensions(
1957
+ result.rows,
1958
+ compareRows,
1959
+ dimensions,
1960
+ [...baseMeasures].map((m) => `${m}__compare`)
1961
+ );
1962
+ for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1963
+ }
1964
+ const fillColumns = {};
1965
+ for (const m of baseMeasures) {
1966
+ const aggregate2 = compiled.cube.measures?.[m]?.type;
1967
+ fillColumns[m] = aggregate2;
1968
+ if (selection.compareTo) fillColumns[`${m}__compare`] = aggregate2;
1969
+ }
1970
+ fillEmptyGroups(result.rows, fillColumns);
1971
+ result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1972
+ for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1973
+ let sortKeys;
1974
+ for (const key of labelOrderKeys) {
1975
+ const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1976
+ if (values.length === 0) continue;
1977
+ const labels = await this.orderLabels.resolveLabels(key, values);
1978
+ if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1979
+ }
1980
+ result.rows = applyOrdering(result.rows, order, sortKeys);
1981
+ result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1982
+ return result;
1983
+ }
1984
+ /**
1985
+ * Run ONE grouped pass over a set of base measures, honouring each measure's
1986
+ * own scoped `filter`: the unfiltered measures in a single query, plus one
1987
+ * supplementary query per filter-scoped measure, merged back by dimension key.
1988
+ *
1989
+ * **This is the executor's only implementation of "how a measure filter is
1990
+ * applied", and every window goes through it** — the current period, each
1991
+ * `totals` subset (which re-enters via `executeSelection`), and the
1992
+ * `compareTo` window. Before #4820 the comparison window had its own,
1993
+ * simpler answer: one shifted query over all base measures with only the
1994
+ * base filter, so `compiled.measureFilters` was never read on that path.
1995
+ * `won_count` counted won deals and `won_count__compare` counted every deal,
1996
+ * under one label, in adjacent columns. Only measures carrying a filter were
1997
+ * wrong — which is what made it survive: the unfiltered ones next to them
1998
+ * compared correctly.
1999
+ *
2000
+ * The caller supplies the `selection` this pass queries under, which is how
2001
+ * the comparison window differs at all: same measures, same dimensions, same
2002
+ * filters — a `timeDimensions` shifted by {@link shiftRange}. Nothing else
2003
+ * about the two passes may drift, because anything that does becomes a
2004
+ * discrepancy between two columns the reader is invited to subtract.
2005
+ *
2006
+ * Cost: one extra query per filter-scoped measure when `compareTo` is set.
2007
+ * The alternative — declaring the discrepancy in the response — is not one,
2008
+ * since the two columns exist to be directly comparable.
2009
+ *
2010
+ * @param window - Ordering/window to push into the SQL. Only ever set for a
2011
+ * selection the caller proved is a single self-sufficient query; a pass
2012
+ * that fans out must return its whole grid for the merge.
2013
+ */
2014
+ async runMeasurePass(compiled, selection, opts) {
2015
+ const { measures, dimensions, baseFilter, window, context } = opts;
2016
+ const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
1935
2017
  let result;
1936
2018
  if (unfiltered.length > 0 || filtered.length === 0) {
1937
2019
  result = await this.service.query(this.buildQuery(compiled, {
@@ -1940,7 +2022,7 @@ var DatasetExecutor = class {
1940
2022
  where: baseFilter,
1941
2023
  selection,
1942
2024
  contextTimezone: context?.timezone,
1943
- window: windowQuery
2025
+ window
1944
2026
  }), context);
1945
2027
  } else {
1946
2028
  result = { rows: [], fields: [] };
@@ -1957,27 +2039,6 @@ var DatasetExecutor = class {
1957
2039
  result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
1958
2040
  result.fields.push({ name: m, type: "number" });
1959
2041
  }
1960
- if (selection.compareTo) {
1961
- const compareRows = await this.runCompare(compiled, selection, [...baseMeasures], dimensions, baseFilter, context);
1962
- result.rows = mergeByDimensions(
1963
- result.rows,
1964
- compareRows,
1965
- dimensions,
1966
- [...baseMeasures].map((m) => `${m}__compare`)
1967
- );
1968
- for (const m of baseMeasures) result.fields.push({ name: `${m}__compare`, type: "number" });
1969
- }
1970
- result.rows = evaluateDerivedMeasures(result.rows, selectedDerived);
1971
- for (const d of selectedDerived) result.fields.push({ name: d.name, type: "number" });
1972
- let sortKeys;
1973
- for (const key of labelOrderKeys) {
1974
- const values = [...new Set(result.rows.map((r) => r[key]).filter((v) => v != null))];
1975
- if (values.length === 0) continue;
1976
- const labels = await this.orderLabels.resolveLabels(key, values);
1977
- if (labels && labels.size > 0) (sortKeys ?? (sortKeys = {}))[key] = labels;
1978
- }
1979
- result.rows = applyOrdering(result.rows, order, sortKeys);
1980
- result.rows = applyWindow(result.rows, selection.limit, selection.offset);
1981
2042
  return result;
1982
2043
  }
1983
2044
  /**
@@ -2042,13 +2103,11 @@ var DatasetExecutor = class {
2042
2103
  const shiftedTd = (selection.timeDimensions ?? []).map(
2043
2104
  (t) => t.dimension === cmp.dimension ? { ...t, dateRange: shifted } : t
2044
2105
  );
2045
- const sub = await this.service.query(this.buildQuery(compiled, {
2046
- measures,
2047
- dimensions,
2048
- where: baseFilter,
2049
- selection: { ...selection, timeDimensions: shiftedTd },
2050
- contextTimezone: context?.timezone
2051
- }), context);
2106
+ const sub = await this.runMeasurePass(
2107
+ compiled,
2108
+ { ...selection, timeDimensions: shiftedTd },
2109
+ { measures, dimensions, baseFilter, context }
2110
+ );
2052
2111
  return sub.rows.map((row) => {
2053
2112
  const out = {};
2054
2113
  for (const dim of dimensions) out[dim] = row[dim];
@@ -2418,6 +2477,7 @@ function isMissingSourceError(err) {
2418
2477
  msg.includes("not registered") || // framework: object not in registry
2419
2478
  msg.includes("unknown object") || msg.includes("is not a registered object");
2420
2479
  }
2480
+ var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
2421
2481
  var DEFAULT_CAPABILITIES = {
2422
2482
  nativeSql: false,
2423
2483
  objectqlAggregate: false,
@@ -2436,10 +2496,11 @@ var AnalyticsService = class {
2436
2496
  }
2437
2497
  this.readScopeProvider = config.getReadScope;
2438
2498
  this.relationshipResolver = config.relationshipResolver;
2439
- this.measureCurrency = config.measureCurrency;
2499
+ this.sourceFieldMeta = config.sourceFieldMeta;
2440
2500
  this.labelResolver = config.labelResolver;
2441
2501
  this.draftRowsResolver = config.draftRowsResolver;
2442
2502
  this.isRegisteredObject = config.isRegisteredObject;
2503
+ this.getObjectFieldNames = config.getObjectFieldNames;
2443
2504
  if (config.datasets) {
2444
2505
  for (const ds of config.datasets) {
2445
2506
  try {
@@ -2657,7 +2718,7 @@ var AnalyticsService = class {
2657
2718
  if (!d.field || d.type !== "date") continue;
2658
2719
  const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
2659
2720
  if (!granularity) continue;
2660
- const ftype = this.measureCurrency?.(dataset.object, d.field)?.type;
2721
+ const ftype = this.sourceFieldMeta?.(dataset.object, d.field)?.type;
2661
2722
  if (ftype === "datetime") rangeDims.push({ d, granularity, instant: true });
2662
2723
  else if (ftype === "date") rangeDims.push({ d, granularity, instant: false });
2663
2724
  else if (rangeTz === "UTC") rangeDims.push({ d, granularity, instant: false });
@@ -2706,14 +2767,17 @@ var AnalyticsService = class {
2706
2767
  if (f.format == null && m.format) f.format = m.format;
2707
2768
  const fc = f;
2708
2769
  const mc = m;
2770
+ const meta = m.field ? this.sourceFieldMeta?.(dataset.object, m.field) : void 0;
2709
2771
  if (fc.currency == null) {
2710
- const meta = m.field ? this.measureCurrency?.(dataset.object, m.field) : void 0;
2711
2772
  const monetary = !!mc.currency || meta?.type === "currency";
2712
2773
  if (monetary) {
2713
2774
  const resolved = mc.currency ?? meta?.defaultCurrency ?? context?.currency;
2714
2775
  if (resolved) fc.currency = resolved;
2715
2776
  }
2716
2777
  }
2778
+ if (f.percentScale == null) {
2779
+ f.percentScale = m.derived?.op === "ratio" ? "fraction" : percentScaleOf(meta);
2780
+ }
2717
2781
  }
2718
2782
  }
2719
2783
  if (result.fields?.length && selectedDims.length) {
@@ -2779,6 +2843,7 @@ var AnalyticsService = class {
2779
2843
  if (!cube) {
2780
2844
  this.assertInferableCube(name);
2781
2845
  cube = this.inferCubeFromQuery(query);
2846
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2782
2847
  this.cubeRegistry.register(cube);
2783
2848
  const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
2784
2849
  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.`;
@@ -2798,10 +2863,82 @@ var AnalyticsService = class {
2798
2863
  ...cube,
2799
2864
  measures: { ...cube.measures, ...extraMeasures }
2800
2865
  };
2866
+ this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
2801
2867
  this.cubeRegistry.register(augmented);
2802
2868
  this.logger.debug(
2803
2869
  `[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
2804
2870
  );
2871
+ } else {
2872
+ this.assertMeasureFields(query, cube, Object.keys(cube.measures));
2873
+ }
2874
+ }
2875
+ /**
2876
+ * [#4437] Reject a measure whose SOURCE FIELD the backing object does not
2877
+ * have, BEFORE the strategy compiles it into SQL.
2878
+ *
2879
+ * `inferMeasure` maps a suffix convention onto a field name and has no way to
2880
+ * know whether that field exists: `ghost_sum` happily became `SUM(ghost)`, the
2881
+ * driver threw `no such column`, and the caller got
2882
+ * `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver
2883
+ * error class on the wire, and nothing actionable, for what is a plain typo.
2884
+ * The DATA route has refused the same mistake with a `400 INVALID_FIELD`
2885
+ * naming the field since #4315/#4254; this is the analytics half of that
2886
+ * answer, and it is deliberately the SAME envelope (`code`/`field`/`object`/
2887
+ * `param`) so one mistake has one shape across both routes.
2888
+ *
2889
+ * What it checks, and what it deliberately does not:
2890
+ *
2891
+ * - Only when the cube's `sql` is a bare OBJECT NAME. An authored cube whose
2892
+ * `sql` is a real SQL expression has no field list to check against.
2893
+ * - Only when {@link AnalyticsServiceConfig.getObjectFieldNames} answers.
2894
+ * Absent hook / unknown object → stand down (see the config field's doc).
2895
+ * - Only measures whose source is a BARE COLUMN. `count(*)` has no source
2896
+ * field, and a dotted reference (`account.industry`) resolves through a
2897
+ * join whose target this check cannot see — both pass through untouched.
2898
+ * - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
2899
+ * the data path's `resolveQueryFields`: they are engine-assigned rather than
2900
+ * declared, and a gate stricter than the engine it guards would reject
2901
+ * queries that used to work.
2902
+ */
2903
+ assertMeasureFields(query, cube, declaredMeasures) {
2904
+ const probe = this.getObjectFieldNames;
2905
+ if (!probe) return;
2906
+ const measures = query.measures ?? [];
2907
+ if (measures.length === 0) return;
2908
+ const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
2909
+ if (!object || !BARE_IDENTIFIER.test(object)) return;
2910
+ const fieldNames = probe(object);
2911
+ if (!fieldNames || fieldNames.length === 0) return;
2912
+ const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
2913
+ const stripPrefix = (m) => m.includes(".") ? m.split(".").slice(1).join(".") : m;
2914
+ const sourceFieldOf = (measure) => {
2915
+ const metric = cube.measures[stripPrefix(measure)];
2916
+ if (!metric) return null;
2917
+ if (metric.type === "count" && (metric.sql === "*" || metric.sql == null)) return null;
2918
+ const source = typeof metric.sql === "string" ? metric.sql.trim() : "";
2919
+ if (!source || source === "*" || !BARE_IDENTIFIER.test(source)) return null;
2920
+ return source;
2921
+ };
2922
+ const invalid = /* @__PURE__ */ new Set();
2923
+ for (const measure of measures) {
2924
+ const source = sourceFieldOf(measure);
2925
+ if (source && !known.has(source)) invalid.add(stripPrefix(measure));
2926
+ }
2927
+ if (invalid.size === 0) return;
2928
+ const usable = declaredMeasures.filter((m) => !invalid.has(m));
2929
+ for (const measure of measures) {
2930
+ const source = sourceFieldOf(measure);
2931
+ if (!source || known.has(source)) continue;
2932
+ const err = new Error(
2933
+ `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(", ")}.`
2934
+ );
2935
+ err.code = "INVALID_FIELD";
2936
+ err.status = 400;
2937
+ err.field = source;
2938
+ err.object = object;
2939
+ err.param = "measures";
2940
+ err.measure = measure;
2941
+ throw err;
2805
2942
  }
2806
2943
  }
2807
2944
  /**
@@ -2955,6 +3092,14 @@ var AnalyticsServicePlugin = class {
2955
3092
  this.version = "1.0.0";
2956
3093
  this.type = "standard";
2957
3094
  this.dependencies = [];
3095
+ /**
3096
+ * init() probes the `data` engine ObjectQLPlugin provides for the
3097
+ * auto-bridge — order-if-present so the probe verdict is deterministic
3098
+ * (ADR-0116, #4471). Soft, not hard: without an engine the plugin
3099
+ * degrades on purpose (per-query lazy resolution / explicit
3100
+ * `executeAggregate`).
3101
+ */
3102
+ this.optionalDependencies = ["com.objectstack.engine.objectql"];
2958
3103
  this.options = options;
2959
3104
  }
2960
3105
  async init(ctx) {
@@ -3178,10 +3323,12 @@ var AnalyticsServicePlugin = class {
3178
3323
  coerceTemporalFilterColumn,
3179
3324
  relationshipResolver,
3180
3325
  labelResolver,
3181
- // ADR-0053 — source-field currency metadata for the measure currency chain.
3182
- measureCurrency: (object, field) => {
3326
+ // Source-field metadata behind the display chains on result columns:
3327
+ // ADR-0053 currency (`currencyConfig.defaultCurrency`) and percent scale
3328
+ // (`max`, which is what marks whole-percent storage — objectui#3136).
3329
+ sourceFieldMeta: (object, field) => {
3183
3330
  const f = dataEngine()?.getObject?.(object)?.fields?.[field];
3184
- return f ? { type: f.type, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3331
+ return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
3185
3332
  },
3186
3333
  // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
3187
3334
  // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
@@ -3204,6 +3351,18 @@ var AnalyticsServicePlugin = class {
3204
3351
  if (!engine) return true;
3205
3352
  return engine.getObject?.(name) != null;
3206
3353
  },
3354
+ // [#4437] Field names for the measure source-field gate. Read from the
3355
+ // SAME schema registry `isRegisteredObject` above consults (and the data
3356
+ // path's #4315 gate reads), so "which fields exist" has one answer across
3357
+ // /data and /analytics. `undefined` — no engine, unknown object, or an
3358
+ // object with no field map (an external datasource whose columns are not
3359
+ // mirrored locally) — means "cannot answer", and the gate stands down.
3360
+ getObjectFieldNames: (objectName) => {
3361
+ const fields = dataEngine()?.getObject?.(objectName)?.fields;
3362
+ if (!fields || typeof fields !== "object") return void 0;
3363
+ const names = Object.keys(fields);
3364
+ return names.length > 0 ? names : void 0;
3365
+ },
3207
3366
  draftRowsResolver
3208
3367
  };
3209
3368
  if (autoBridgedReadScope && securityPresentAtInit) {
@@ -3259,6 +3418,7 @@ export {
3259
3418
  compileScopedFilterToSql,
3260
3419
  createOrderLabelResolver,
3261
3420
  evaluateDerivedMeasures,
3421
+ fillEmptyGroups,
3262
3422
  mergeByDimensions,
3263
3423
  pickDisplayField,
3264
3424
  resolveDimensionLabels,