@objectstack/service-analytics 10.3.0 → 11.1.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.d.cts CHANGED
@@ -63,8 +63,9 @@ interface CompiledDataset {
63
63
  /** The Cube the dataset compiles to (consumed by the strategy chain). */
64
64
  cube: Cube;
65
65
  /**
66
- * Relationship names declared in `include`. The join allowlist (D-C):
67
- * the NativeSQLStrategy rejects any join alias not in this set.
66
+ * Every join alias the dataset may use — each declared `include` path AND its
67
+ * intermediate prefixes (ADR-0071). The join allowlist (D-C): the
68
+ * NativeSQLStrategy rejects any join alias not in this set.
68
69
  */
69
70
  allowedRelationships: Set<string>;
70
71
  /** Derived measures, computed post-aggregation by the executor (Q1). */
@@ -75,12 +76,26 @@ interface CompiledDataset {
75
76
  measureFilters: Record<string, FilterCondition>;
76
77
  }
77
78
  /**
78
- * Resolves a relationship name on a base object to the related object/table
79
- * name, using the runtime's object graph. Optional: when omitted the compiler
80
- * trusts the declared `include` names (the NativeSQLStrategy convention assumes
81
- * the relationship name equals the related table name).
79
+ * The related object reached by traversing a relationship: its logical object
80
+ * name (used to resolve the NEXT hop in a multi-hop chain ADR-0071) and its
81
+ * physical table name (the join target).
82
82
  */
83
- type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
83
+ interface RelationshipTarget {
84
+ object: string;
85
+ table: string;
86
+ }
87
+ /**
88
+ * Resolves a relationship name on a base object to the related object/table,
89
+ * using the runtime's object graph. Optional: when omitted the compiler trusts
90
+ * the declared `include` names (the NativeSQLStrategy convention assumes the
91
+ * relationship name equals the related table name).
92
+ *
93
+ * May return a bare table-name `string` (legacy single-hop: object name is
94
+ * assumed equal to the table) or a {@link RelationshipTarget} (required to
95
+ * traverse further along a multi-hop path, where object differs from table for
96
+ * namespaced objects).
97
+ */
98
+ type RelationshipResolver = (baseObject: string, relationshipName: string) => string | RelationshipTarget | undefined;
84
99
  declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
85
100
 
86
101
  /**
@@ -554,14 +569,19 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
554
569
  * responsible for isolation — see contract note).
555
570
  */
556
571
  private applyReadScope;
572
+ /** SQL-safe join alias for a relationship path (dots → `__`); single-segment
573
+ * paths are unchanged. Mirrors the dataset compiler's `cube.joins` keying so
574
+ * alias, allowlist, and per-hop RLS all agree on one valid identifier. */
575
+ private joinAlias;
557
576
  /**
558
577
  * Resolve a dimension/measure/filter SQL expression that may reference a
559
578
  * related table via dot notation (e.g. `account.industry`).
560
579
  *
561
- * When the resolved `sql` contains a dot, treat the prefix as a lookup
562
- * field on the cube's table and synthesise a `LEFT JOIN` against the
563
- * related table. The convention (matching the auto-cube generator and
564
- * ObjectStack object schemas) is:
580
+ * A dotted `sql` is a relationship PATH (ADR-0071 multi-hop): every segment
581
+ * but the last is a to-one relationship hop, the last is the column. Each hop
582
+ * synthesises a `LEFT JOIN` aliased by its full path prefix, chained
583
+ * parent→child. The convention (matching the auto-cube generator and
584
+ * ObjectStack object schemas) for a single hop is:
565
585
  *
566
586
  * <parentTable>.<lookupField> = <lookupField>.id
567
587
  *
@@ -656,4 +676,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
656
676
  private buildFieldMeta;
657
677
  }
658
678
 
659
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
679
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
package/dist/index.d.ts CHANGED
@@ -63,8 +63,9 @@ interface CompiledDataset {
63
63
  /** The Cube the dataset compiles to (consumed by the strategy chain). */
64
64
  cube: Cube;
65
65
  /**
66
- * Relationship names declared in `include`. The join allowlist (D-C):
67
- * the NativeSQLStrategy rejects any join alias not in this set.
66
+ * Every join alias the dataset may use — each declared `include` path AND its
67
+ * intermediate prefixes (ADR-0071). The join allowlist (D-C): the
68
+ * NativeSQLStrategy rejects any join alias not in this set.
68
69
  */
69
70
  allowedRelationships: Set<string>;
70
71
  /** Derived measures, computed post-aggregation by the executor (Q1). */
@@ -75,12 +76,26 @@ interface CompiledDataset {
75
76
  measureFilters: Record<string, FilterCondition>;
76
77
  }
77
78
  /**
78
- * Resolves a relationship name on a base object to the related object/table
79
- * name, using the runtime's object graph. Optional: when omitted the compiler
80
- * trusts the declared `include` names (the NativeSQLStrategy convention assumes
81
- * the relationship name equals the related table name).
79
+ * The related object reached by traversing a relationship: its logical object
80
+ * name (used to resolve the NEXT hop in a multi-hop chain ADR-0071) and its
81
+ * physical table name (the join target).
82
82
  */
83
- type RelationshipResolver = (baseObject: string, relationshipName: string) => string | undefined;
83
+ interface RelationshipTarget {
84
+ object: string;
85
+ table: string;
86
+ }
87
+ /**
88
+ * Resolves a relationship name on a base object to the related object/table,
89
+ * using the runtime's object graph. Optional: when omitted the compiler trusts
90
+ * the declared `include` names (the NativeSQLStrategy convention assumes the
91
+ * relationship name equals the related table name).
92
+ *
93
+ * May return a bare table-name `string` (legacy single-hop: object name is
94
+ * assumed equal to the table) or a {@link RelationshipTarget} (required to
95
+ * traverse further along a multi-hop path, where object differs from table for
96
+ * namespaced objects).
97
+ */
98
+ type RelationshipResolver = (baseObject: string, relationshipName: string) => string | RelationshipTarget | undefined;
84
99
  declare function compileDataset(dataset: Dataset, resolver?: RelationshipResolver): CompiledDataset;
85
100
 
86
101
  /**
@@ -554,14 +569,19 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
554
569
  * responsible for isolation — see contract note).
555
570
  */
556
571
  private applyReadScope;
572
+ /** SQL-safe join alias for a relationship path (dots → `__`); single-segment
573
+ * paths are unchanged. Mirrors the dataset compiler's `cube.joins` keying so
574
+ * alias, allowlist, and per-hop RLS all agree on one valid identifier. */
575
+ private joinAlias;
557
576
  /**
558
577
  * Resolve a dimension/measure/filter SQL expression that may reference a
559
578
  * related table via dot notation (e.g. `account.industry`).
560
579
  *
561
- * When the resolved `sql` contains a dot, treat the prefix as a lookup
562
- * field on the cube's table and synthesise a `LEFT JOIN` against the
563
- * related table. The convention (matching the auto-cube generator and
564
- * ObjectStack object schemas) is:
580
+ * A dotted `sql` is a relationship PATH (ADR-0071 multi-hop): every segment
581
+ * but the last is a to-one relationship hop, the last is the column. Each hop
582
+ * synthesises a `LEFT JOIN` aliased by its full path prefix, chained
583
+ * parent→child. The convention (matching the auto-cube generator and
584
+ * ObjectStack object schemas) for a single hop is:
565
585
  *
566
586
  * <parentTable>.<lookupField> = <lookupField>.id
567
587
  *
@@ -656,4 +676,4 @@ declare class ObjectQLStrategy implements AnalyticsStrategy {
656
676
  private buildFieldMeta;
657
677
  }
658
678
 
659
- export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
679
+ export { AnalyticsService, type AnalyticsServiceConfig, AnalyticsServicePlugin, type AnalyticsServicePluginOptions, type CompareTo, type CompiledDataset, CubeRegistry, DatasetExecutor, type DerivedMeasureSpec, type DimensionLabelDeps, type FieldMetaLite, NativeSQLStrategy, ObjectQLStrategy, type RelationshipResolver, type RelationshipTarget, combineFilters, compileDataset, compileScopedFilterToSql, evaluateDerivedMeasures, mergeByDimensions, pickDisplayField, resolveDimensionLabels, shiftRange };
package/dist/index.js CHANGED
@@ -132,7 +132,7 @@ var MONGO_TO_CUBE_OP = {
132
132
  };
133
133
  function stringifyForCube(v) {
134
134
  if (v == null) return "";
135
- if (typeof v === "boolean") return v ? "1" : "0";
135
+ if (typeof v === "boolean") return v ? "true" : "false";
136
136
  if (v instanceof Date) return v.toISOString();
137
137
  if (typeof v === "object") return JSON.stringify(v);
138
138
  return String(v);
@@ -187,19 +187,24 @@ function normalizeAnalyticsFilters(query) {
187
187
  }
188
188
  return out;
189
189
  }
190
+ function recoverNumber(s) {
191
+ if (/^-?\d+(\.\d+)?$/.test(s)) {
192
+ const n = Number(s);
193
+ if (Number.isFinite(n)) return n;
194
+ }
195
+ return void 0;
196
+ }
190
197
  function coerceFilterValueForSql(s) {
191
198
  if (s === "true") return 1;
192
199
  if (s === "false") return 0;
193
200
  if (s === "null") return null;
194
- if (/^-?\d+$/.test(s)) {
195
- const n = Number(s);
196
- if (Number.isFinite(n)) return n;
197
- }
198
- if (/^-?\d+\.\d+$/.test(s)) {
199
- const n = Number(s);
200
- if (Number.isFinite(n)) return n;
201
- }
202
- return s;
201
+ return recoverNumber(s) ?? s;
202
+ }
203
+ function coerceFilterValueForObjectQL(s) {
204
+ if (s === "true") return true;
205
+ if (s === "false") return false;
206
+ if (s === "null") return null;
207
+ return recoverNumber(s) ?? s;
203
208
  }
204
209
 
205
210
  // src/read-scope-sql.ts
@@ -451,14 +456,21 @@ var NativeSQLStrategy = class {
451
456
  });
452
457
  whereClauses.push(`(${rendered})`);
453
458
  }
459
+ /** SQL-safe join alias for a relationship path (dots → `__`); single-segment
460
+ * paths are unchanged. Mirrors the dataset compiler's `cube.joins` keying so
461
+ * alias, allowlist, and per-hop RLS all agree on one valid identifier. */
462
+ joinAlias(path) {
463
+ return path.replace(/\./g, "__");
464
+ }
454
465
  /**
455
466
  * Resolve a dimension/measure/filter SQL expression that may reference a
456
467
  * related table via dot notation (e.g. `account.industry`).
457
468
  *
458
- * When the resolved `sql` contains a dot, treat the prefix as a lookup
459
- * field on the cube's table and synthesise a `LEFT JOIN` against the
460
- * related table. The convention (matching the auto-cube generator and
461
- * ObjectStack object schemas) is:
469
+ * A dotted `sql` is a relationship PATH (ADR-0071 multi-hop): every segment
470
+ * but the last is a to-one relationship hop, the last is the column. Each hop
471
+ * synthesises a `LEFT JOIN` aliased by its full path prefix, chained
472
+ * parent→child. The convention (matching the auto-cube generator and
473
+ * ObjectStack object schemas) for a single hop is:
462
474
  *
463
475
  * <parentTable>.<lookupField> = <lookupField>.id
464
476
  *
@@ -470,19 +482,33 @@ var NativeSQLStrategy = class {
470
482
  * Pure column references (no dot) are returned as-is.
471
483
  */
472
484
  qualifyAndRegisterJoin(rawSql, parentTable, joins, cube) {
473
- if (!rawSql.includes(".")) return rawSql;
474
- const [alias, ...rest] = rawSql.split(".");
475
- if (!alias || rest.length === 0) return rawSql;
476
- const column = rest.join(".");
477
- if (!joins.has(alias)) {
478
- const joinTable = cube?.joins?.[alias]?.name ?? alias;
479
- const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`;
480
- joins.set(
481
- alias,
482
- `LEFT JOIN ${tableRef} ON "${parentTable}"."${alias}" = "${alias}"."id"`
483
- );
485
+ if (!rawSql.includes(".")) {
486
+ const canJoin = !!cube?.joins && Object.keys(cube.joins).length > 0;
487
+ if (canJoin && /^[A-Za-z_][A-Za-z0-9_]*$/.test(rawSql)) {
488
+ return `"${parentTable}"."${rawSql}"`;
489
+ }
490
+ return rawSql;
491
+ }
492
+ const segments = rawSql.split(".");
493
+ const column = segments[segments.length - 1];
494
+ const hops = segments.slice(0, -1);
495
+ if (hops.length === 0 || !column) return rawSql;
496
+ let parentAlias = parentTable;
497
+ let prefix = "";
498
+ for (const seg of hops) {
499
+ prefix = prefix ? `${prefix}.${seg}` : seg;
500
+ const alias = this.joinAlias(prefix);
501
+ if (!joins.has(alias)) {
502
+ const joinTable = cube?.joins?.[alias]?.name ?? alias;
503
+ const tableRef = joinTable === alias ? `"${alias}"` : `"${joinTable}" "${alias}"`;
504
+ joins.set(
505
+ alias,
506
+ `LEFT JOIN ${tableRef} ON "${parentAlias}"."${seg}" = "${alias}"."id"`
507
+ );
508
+ }
509
+ parentAlias = alias;
484
510
  }
485
- return `"${alias}"."${column}"`;
511
+ return `"${parentAlias}"."${column}"`;
486
512
  }
487
513
  /**
488
514
  * Resolve a member reference (dimension, measure, or filter field) to its
@@ -568,9 +594,11 @@ var NativeSQLStrategy = class {
568
594
  const measure = dim ? void 0 : this.lookupMember(cube, member, "measure");
569
595
  const rawSql = dim?.sql ?? measure?.sql ?? (member.includes(".") ? member.split(".").slice(1).join(".") : member);
570
596
  if (rawSql.includes(".")) {
571
- const [alias, ...rest] = rawSql.split(".");
572
- const object = cube.joins?.[alias]?.name ?? alias;
573
- return { object, field: rest.join(".") };
597
+ const segments = rawSql.split(".");
598
+ const field = segments[segments.length - 1];
599
+ const relPath = segments.slice(0, -1).join(".");
600
+ const object = cube.joins?.[this.joinAlias(relPath)]?.name ?? relPath;
601
+ return { object, field };
574
602
  }
575
603
  return { object: baseTable, field: rawSql };
576
604
  }
@@ -815,8 +843,8 @@ var ObjectQLStrategy = class {
815
843
  if (operator === "set") return { $ne: null };
816
844
  if (operator === "notSet") return null;
817
845
  if (!values || values.length === 0) return void 0;
818
- const v0 = coerceFilterValueForSql(values[0]);
819
- const all = values.map(coerceFilterValueForSql);
846
+ const v0 = coerceFilterValueForObjectQL(values[0]);
847
+ const all = values.map(coerceFilterValueForObjectQL);
820
848
  switch (operator) {
821
849
  case "equals":
822
850
  return v0;
@@ -863,6 +891,9 @@ var ObjectQLStrategy = class {
863
891
  // src/dataset-compiler.ts
864
892
  var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
865
893
  function aggregateToMetricType(m) {
894
+ if (!m.aggregate) {
895
+ throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
896
+ }
866
897
  if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
867
898
  throw new Error(
868
899
  `[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: count, sum, avg, min, max, count_distinct).`
@@ -886,36 +917,56 @@ function dimensionType(d) {
886
917
  return "string";
887
918
  }
888
919
  }
889
- function relationshipPrefix(field) {
890
- const idx = field.indexOf(".");
920
+ function fieldRelationshipPath(field) {
921
+ const idx = field.lastIndexOf(".");
891
922
  return idx > 0 ? field.slice(0, idx) : null;
892
923
  }
924
+ var MAX_JOIN_HOPS = 3;
925
+ var joinAlias = (path) => path.replace(/\./g, "__");
893
926
  function compileDataset(dataset, resolver) {
894
927
  const include = dataset.include ?? [];
895
- const allowedRelationships = new Set(include);
928
+ const resolveHop = (fromObject, rel) => {
929
+ if (!resolver) return { object: rel, table: rel };
930
+ const resolved = resolver(fromObject, rel);
931
+ if (!resolved) {
932
+ throw new Error(
933
+ `[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${fromObject}".`
934
+ );
935
+ }
936
+ return typeof resolved === "string" ? { object: resolved, table: resolved } : resolved;
937
+ };
896
938
  const joins = {};
897
- for (const rel of include) {
898
- let targetTable = rel;
899
- if (resolver) {
900
- const resolved = resolver(dataset.object, rel);
901
- if (!resolved) {
902
- throw new Error(
903
- `[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${dataset.object}".`
904
- );
939
+ for (const path of include) {
940
+ const segments = path.split(".");
941
+ if (segments.length > MAX_JOIN_HOPS) {
942
+ throw new Error(
943
+ `[dataset-compiler] dataset "${dataset.name}" include path "${path}" exceeds the ${MAX_JOIN_HOPS}-hop limit (${segments.length} hops). Deeper traversal is not supported.`
944
+ );
945
+ }
946
+ let fromObject = dataset.object;
947
+ let parentAlias = dataset.object;
948
+ let prefix = "";
949
+ for (const seg of segments) {
950
+ prefix = prefix ? `${prefix}.${seg}` : seg;
951
+ const target = resolveHop(fromObject, seg);
952
+ const alias = joinAlias(prefix);
953
+ if (!joins[alias]) {
954
+ joins[alias] = {
955
+ name: target.table,
956
+ relationship: "many_to_one",
957
+ sql: `${parentAlias}.${seg} = ${prefix}.id`
958
+ };
905
959
  }
906
- targetTable = resolved;
960
+ fromObject = target.object;
961
+ parentAlias = prefix;
907
962
  }
908
- joins[rel] = {
909
- name: targetTable,
910
- relationship: "many_to_one",
911
- sql: `${dataset.object}.${rel} = ${rel}.id`
912
- };
913
963
  }
964
+ const allowedRelationships = new Set(Object.keys(joins));
914
965
  const assertDeclared = (field, ownerKind, ownerName) => {
915
- const prefix = relationshipPrefix(field);
916
- if (prefix && !allowedRelationships.has(prefix)) {
966
+ const relPath = fieldRelationshipPath(field);
967
+ if (relPath && !joins[joinAlias(relPath)]) {
917
968
  throw new Error(
918
- `[dataset-compiler] ${ownerKind} "${ownerName}" references relationship "${prefix}" via "${field}", but "${prefix}" is not declared in the dataset's \`include\`. v1 only joins along declared relationships.`
969
+ `[dataset-compiler] ${ownerKind} "${ownerName}" references relationship path "${relPath}" via "${field}", but "${relPath}" is not declared in the dataset's \`include\`. Only fields along a declared relationship path are joinable.`
919
970
  );
920
971
  }
921
972
  };