@hypequery/datasets 0.22.0 → 0.23.1

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.
Files changed (84) hide show
  1. package/dist/cache/protocol-cache-keys.d.ts.map +1 -1
  2. package/dist/cache/protocol-cache-keys.js +5 -0
  3. package/dist/cache/query-signature.d.ts.map +1 -1
  4. package/dist/cache/query-signature.js +9 -0
  5. package/dist/catalog.d.ts +8 -0
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +4 -0
  8. package/dist/constants.d.ts +12 -1
  9. package/dist/constants.d.ts.map +1 -1
  10. package/dist/constants.js +24 -0
  11. package/dist/contract.d.ts +4 -1
  12. package/dist/contract.d.ts.map +1 -1
  13. package/dist/contract.js +4 -0
  14. package/dist/dataset-compilation.d.ts +5 -0
  15. package/dist/dataset-compilation.d.ts.map +1 -1
  16. package/dist/execution-capabilities.json +1 -1
  17. package/dist/executor.d.ts.map +1 -1
  18. package/dist/executor.js +3 -0
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/protocol-adapter.d.ts.map +1 -1
  23. package/dist/protocol-adapter.js +1 -0
  24. package/dist/protocol-rehydrate.d.ts.map +1 -1
  25. package/dist/protocol-rehydrate.js +9 -2
  26. package/dist/query-builder-protocol.d.ts +7 -0
  27. package/dist/query-builder-protocol.d.ts.map +1 -1
  28. package/dist/relationship-check.d.ts +3 -2
  29. package/dist/relationship-check.d.ts.map +1 -1
  30. package/dist/relationship-check.js +8 -3
  31. package/dist/relationships.d.ts +6 -16
  32. package/dist/relationships.d.ts.map +1 -1
  33. package/dist/relationships.js +3 -12
  34. package/dist/semantic-planner.d.ts.map +1 -1
  35. package/dist/semantic-planner.js +2 -0
  36. package/dist/semantic-query-schema.d.ts +6 -0
  37. package/dist/semantic-query-schema.d.ts.map +1 -1
  38. package/dist/semantic-query-schema.js +29 -2
  39. package/dist/types.d.ts +49 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/utils/build-dataset-query-builder.d.ts.map +1 -1
  42. package/dist/utils/build-dataset-query-builder.js +3 -0
  43. package/dist/utils/calendar-shift-sql.d.ts +18 -0
  44. package/dist/utils/calendar-shift-sql.d.ts.map +1 -0
  45. package/dist/utils/calendar-shift-sql.js +21 -0
  46. package/dist/utils/compile-dataset-query.d.ts.map +1 -1
  47. package/dist/utils/compile-dataset-query.js +4 -3
  48. package/dist/utils/dataset-definition-validation.d.ts.map +1 -1
  49. package/dist/utils/dataset-definition-validation.js +52 -2
  50. package/dist/utils/dataset-derived-query.d.ts +5 -0
  51. package/dist/utils/dataset-derived-query.d.ts.map +1 -1
  52. package/dist/utils/dataset-derived-query.js +16 -0
  53. package/dist/utils/dataset-having.d.ts +21 -0
  54. package/dist/utils/dataset-having.d.ts.map +1 -0
  55. package/dist/utils/dataset-having.js +106 -0
  56. package/dist/utils/dataset-normalization.d.ts.map +1 -1
  57. package/dist/utils/dataset-normalization.js +5 -1
  58. package/dist/utils/dataset-query-validation.d.ts.map +1 -1
  59. package/dist/utils/dataset-query-validation.js +2 -0
  60. package/dist/utils/protocol-metric-expressions.d.ts +8 -0
  61. package/dist/utils/protocol-metric-expressions.d.ts.map +1 -1
  62. package/dist/utils/protocol-metric-expressions.js +23 -11
  63. package/dist/utils/relationship-builder-plan.d.ts +2 -0
  64. package/dist/utils/relationship-builder-plan.d.ts.map +1 -1
  65. package/dist/utils/relationship-builder-plan.js +16 -4
  66. package/dist/utils/relationship-definition.d.ts +6 -0
  67. package/dist/utils/relationship-definition.d.ts.map +1 -0
  68. package/dist/utils/relationship-definition.js +9 -0
  69. package/dist/utils/relationship-key-check.d.ts +4 -0
  70. package/dist/utils/relationship-key-check.d.ts.map +1 -1
  71. package/dist/utils/relationship-key-check.js +19 -7
  72. package/dist/utils/relationship-keys.d.ts +17 -0
  73. package/dist/utils/relationship-keys.d.ts.map +1 -0
  74. package/dist/utils/relationship-keys.js +63 -0
  75. package/dist/utils/relationship-measure-sql.d.ts +1 -1
  76. package/dist/utils/relationship-measure-sql.d.ts.map +1 -1
  77. package/dist/utils/relationship-measure-sql.js +1 -1
  78. package/dist/utils/semantic-query-syntax.d.ts.map +1 -1
  79. package/dist/utils/semantic-query-syntax.js +2 -3
  80. package/dist/utils/shift-measure-sql.d.ts.map +1 -1
  81. package/dist/utils/shift-measure-sql.js +10 -3
  82. package/dist/utils/time-measure-context-sql.d.ts.map +1 -1
  83. package/dist/utils/time-measure-context-sql.js +8 -2
  84. package/package.json +9 -5
@@ -0,0 +1,106 @@
1
+ import { SEMANTIC_HAVING_OPERATORS } from '../constants.js';
2
+ import { selectedTimeMeasures } from './time-query-measures.js';
3
+ const HAVING_OPERATORS = new Set(SEMANTIC_HAVING_OPERATORS);
4
+ const COMPARISONS = {
5
+ eq: '=',
6
+ neq: '!=',
7
+ gt: '>',
8
+ gte: '>=',
9
+ lt: '<',
10
+ lte: '<=',
11
+ };
12
+ function isFiniteNumber(value) {
13
+ return typeof value === 'number' && Number.isFinite(value);
14
+ }
15
+ function conditionValueError(condition) {
16
+ const { measure, operator, value } = condition;
17
+ if (operator === 'between') {
18
+ return Array.isArray(value) && value.length === 2 && value.every(isFiniteNumber)
19
+ ? undefined
20
+ : `Having "between" on "${measure}" expects a two-item array of finite numbers.`;
21
+ }
22
+ if (operator === 'in' || operator === 'notIn') {
23
+ return Array.isArray(value) && value.length > 0 && value.every(isFiniteNumber)
24
+ ? undefined
25
+ : `Having "${operator}" on "${measure}" expects a non-empty array of finite numbers.`;
26
+ }
27
+ return isFiniteNumber(value)
28
+ ? undefined
29
+ : `Having "${operator}" on "${measure}" expects a finite number.`;
30
+ }
31
+ /**
32
+ * Validates `having` conditions against the query they belong to.
33
+ *
34
+ * A condition may only reference a measure the query selects: that is the
35
+ * value the condition reads, and it keeps every rendered reference an output
36
+ * alias rather than an expression taken from input.
37
+ */
38
+ export function datasetHavingErrors(ds, query, selectedMeasures) {
39
+ if (query.having === undefined)
40
+ return [];
41
+ if (!Array.isArray(query.having))
42
+ return ['Having must be an array of conditions.'];
43
+ if (query.having.length === 0)
44
+ return [];
45
+ const errors = [];
46
+ if (selectedTimeMeasures(ds, query).size > 0) {
47
+ errors.push('Having is not supported on queries that select window or shift measures.');
48
+ }
49
+ // `maxFilters` bounds every predicate the query adds, before and after
50
+ // aggregation alike. Filters alone over the limit are reported separately.
51
+ const maxFilters = ds.limits?.maxFilters;
52
+ const filterCount = Array.isArray(query.filters) ? query.filters.length : 0;
53
+ const conditionCount = filterCount + query.having.length;
54
+ if (maxFilters && conditionCount > maxFilters && filterCount <= maxFilters) {
55
+ errors.push(`Too many filters and having conditions: ${conditionCount} (max ${maxFilters})`);
56
+ }
57
+ const selected = new Set(selectedMeasures);
58
+ for (const condition of query.having) {
59
+ if (typeof condition !== 'object' || condition === null) {
60
+ errors.push('Each having condition must be an object with measure, operator and value.');
61
+ continue;
62
+ }
63
+ const { measure, operator } = condition;
64
+ if (typeof measure !== 'string' || !selected.has(measure)) {
65
+ errors.push(`Having measure "${String(measure)}" must be one of the selected measures: ${[...selected].join(', ')}`);
66
+ continue;
67
+ }
68
+ if (typeof operator !== 'string' || !HAVING_OPERATORS.has(operator)) {
69
+ errors.push(`Unsupported having operator "${String(operator)}" on "${measure}". ` +
70
+ `Supported: ${SEMANTIC_HAVING_OPERATORS.join(', ')}`);
71
+ continue;
72
+ }
73
+ const valueError = conditionValueError(condition);
74
+ if (valueError)
75
+ errors.push(valueError);
76
+ }
77
+ return errors;
78
+ }
79
+ /**
80
+ * Renders validated `having` conditions as one parameterized predicate.
81
+ *
82
+ * `measureSql` maps a selected measure to the expression that reads its
83
+ * aggregated value in the outer query. Values are only ever bound as `?`
84
+ * parameters, appended in placeholder order.
85
+ */
86
+ export function datasetHavingSql(conditions, measureSql) {
87
+ const parameters = [];
88
+ const predicates = conditions.map((condition) => {
89
+ const column = measureSql(condition.measure);
90
+ if (condition.operator === 'between') {
91
+ parameters.push(condition.value[0], condition.value[1]);
92
+ return `${column} BETWEEN ? AND ?`;
93
+ }
94
+ if (condition.operator === 'in' || condition.operator === 'notIn') {
95
+ parameters.push(...condition.value);
96
+ const placeholders = condition.value.map(() => '?').join(', ');
97
+ return `${column} ${condition.operator === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`;
98
+ }
99
+ const comparison = COMPARISONS[condition.operator];
100
+ if (!comparison)
101
+ throw new Error(`Unsupported having operator "${String(condition.operator)}".`);
102
+ parameters.push(condition.value);
103
+ return `${column} ${comparison} ?`;
104
+ });
105
+ return { sql: predicates.join(' AND '), parameters };
106
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"dataset-normalization.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-normalization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,wBAAwB,EACxB,mBAAmB,EACnB,iBAAiB,EACjB,sBAAsB,EACtB,yBAAyB,EAC1B,MAAM,aAAa,CAAC;AAErB,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;AAC5D,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;AAE/D,wBAAgB,mBAAmB,CAAC,WAAW,SAAS,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,EACzF,MAAM,EAAE,aAAa,CAAC,WAAW,EAAE,WAAW,EAAE,gBAAgB,CAAC,GAChE,WAAW,CAEb;AAED,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,EAC/C,OAAO,CAAC,EAAE,yBAAyB,GAClC,yBAAyB,CAgB3B;AAED,wBAAgB,iBAAiB,CAAC,SAAS,SAAS,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,EAC1F,QAAQ,EAAE,SAAS,GAAG,SAAS,GAC9B,SAAS,CAEX;AAED,wBAAgB,sBAAsB,CAAC,cAAc,SAAS,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,EAClG,aAAa,EAAE,cAAc,GAAG,SAAS,EACzC,MAAM,EAAE,MAAM,GACb,cAAc,CAgBhB;AAED,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,iBAAiB,GAC5B,eAAe,CAcjB"}
1
+ {"version":3,"file":"dataset-normalization.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-normalization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,wBAAwB,EACxB,mBAAmB,EACnB,iBAAiB,EACjB,sBAAsB,EACtB,yBAAyB,EAC1B,MAAM,aAAa,CAAC;AAGrB,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;AAC5D,KAAK,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;AAE/D,wBAAgB,mBAAmB,CAAC,WAAW,SAAS,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,EACzF,MAAM,EAAE,aAAa,CAAC,WAAW,EAAE,WAAW,EAAE,gBAAgB,CAAC,GAChE,WAAW,CAEb;AAED,wBAAgB,gBAAgB,CAC9B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,EAC/C,OAAO,CAAC,EAAE,yBAAyB,GAClC,yBAAyB,CAgB3B;AAED,wBAAgB,iBAAiB,CAAC,SAAS,SAAS,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,EAC1F,QAAQ,EAAE,SAAS,GAAG,SAAS,GAC9B,SAAS,CAEX;AAED,wBAAgB,sBAAsB,CAAC,cAAc,SAAS,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,EAClG,aAAa,EAAE,cAAc,GAAG,SAAS,EACzC,MAAM,EAAE,MAAM,GACb,cAAc,CAkBhB;AAED,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,iBAAiB,GAC5B,eAAe,CAcjB"}
@@ -1,3 +1,4 @@
1
+ import { relationshipKeys } from './relationship-keys.js';
1
2
  export function normalizeDimensions(config) {
2
3
  return config.dimensions;
3
4
  }
@@ -19,7 +20,10 @@ export function normalizeMeasures(measures) {
19
20
  return (measures ?? {});
20
21
  }
21
22
  export function normalizeRelationships(relationships, source) {
22
- for (const name of Object.keys(relationships ?? {})) {
23
+ for (const [name, relationship] of Object.entries(relationships ?? {})) {
24
+ // Hand-built definitions skip the helpers, so check composite keys here.
25
+ if (relationship.keys)
26
+ relationshipKeys(relationship);
23
27
  if (name === source) {
24
28
  throw new Error(`Invalid relationship "${name}": the name matches the dataset source table, so the join ` +
25
29
  'alias would shadow the base table and produce ambiguous SQL. Rename the relationship.');
@@ -1 +1 @@
1
- {"version":3,"file":"dataset-query-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-query-validation.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAiB9E,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CAuKlB"}
1
+ {"version":3,"file":"dataset-query-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-query-validation.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAkB9E,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CAyKlB"}
@@ -9,6 +9,7 @@ import { isQualifiedField, resolveQualifiedField, } from './relationship-fields.
9
9
  import { validateQualifiedFilter, validateRelationshipTenantRuntime, } from './relationship-validation.js';
10
10
  import { segmentSelectionErrors } from './segments.js';
11
11
  import { analyzeTimeMeasureAxis } from './time-measure-axis.js';
12
+ import { datasetHavingErrors } from './dataset-having.js';
12
13
  export function validateDatasetQueryInput(ds, query, context) {
13
14
  const errors = [...semanticQuerySyntaxErrors(query), ...queryTimezoneErrors(query.timezone)];
14
15
  const dimensionNames = Object.keys(ds.dimensions);
@@ -147,6 +148,7 @@ export function validateDatasetQueryInput(ds, query, context) {
147
148
  if (ds.limits?.maxMeasures && query.measures && query.measures.length > ds.limits.maxMeasures) {
148
149
  errors.push(`Too many measures: ${query.measures.length} (max ${ds.limits.maxMeasures})`);
149
150
  }
151
+ errors.push(...datasetHavingErrors(ds, query, selectedMeasures));
150
152
  if (ds.limits?.maxFilters && query.filters && query.filters.length > ds.limits.maxFilters) {
151
153
  errors.push(`Too many filters: ${query.filters.length} (max ${ds.limits.maxFilters})`);
152
154
  }
@@ -1,7 +1,15 @@
1
1
  import { type ProtocolExpression, type ProtocolMetricDerivation } from '@hypequery/protocol';
2
2
  import type { AggregationSpec, DerivedMeasureDefinition, DerivedMetricSpec, MetricFilter } from '../types.js';
3
3
  export declare function filterExpression(filter: MetricFilter): ProtocolExpression;
4
+ /** The published form; aliases must satisfy the protocol identifier grammar. */
4
5
  export declare function derivedMeasureExpression(definition: DerivedMeasureDefinition): ProtocolExpression;
6
+ /**
7
+ * The catalog form. Catalogs describe local datasets that may never be published,
8
+ * so aliases keep their locally valid names (for example `__hypequery_value`, or
9
+ * names past the protocol's length limit) instead of failing catalog creation.
10
+ * Publishing still validates them through `derivedMeasureExpression`.
11
+ */
12
+ export declare function derivedMeasureCatalogExpression(definition: DerivedMeasureDefinition): ProtocolExpression;
5
13
  export declare function metricExpression(spec: AggregationSpec | DerivedMetricSpec): ProtocolExpression;
6
14
  /**
7
15
  * The formula in the shape it was authored in, beside the inlined form.
@@ -1 +1 @@
1
- {"version":3,"file":"protocol-metric-expressions.d.ts","sourceRoot":"","sources":["../../src/utils/protocol-metric-expressions.ts"],"names":[],"mappings":"AAAA,OAAO,EAEgB,KAAK,kBAAkB,EAAE,KAAK,wBAAwB,EAC5E,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAuC9G,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,kBAAkB,CAgBzE;AA+CD,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,wBAAwB,GAAG,kBAAkB,CAGjG;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,iBAAiB,GAAG,kBAAkB,CAQ9F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,wBAAwB,CASlF"}
1
+ {"version":3,"file":"protocol-metric-expressions.d.ts","sourceRoot":"","sources":["../../src/utils/protocol-metric-expressions.ts"],"names":[],"mappings":"AAAA,OAAO,EAEgB,KAAK,kBAAkB,EAAE,KAAK,wBAAwB,EAC5E,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAuC9G,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,GAAG,kBAAkB,CAgBzE;AAsDD,gFAAgF;AAChF,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,wBAAwB,GAAG,kBAAkB,CAEjG;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAAC,UAAU,EAAE,wBAAwB,GAAG,kBAAkB,CAExG;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,GAAG,iBAAiB,GAAG,kBAAkB,CAQ9F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,wBAAwB,CASlF"}
@@ -61,34 +61,46 @@ function aggregationExpression(spec) {
61
61
  };
62
62
  return result;
63
63
  }
64
- function semanticExpression(expression, references = {}) {
64
+ function semanticExpression(expression, references = {}, referenceName = parseProtocolQualifiedIdentifier) {
65
65
  switch (expression.kind) {
66
66
  case 'ref':
67
- return references[expression.name]
68
- ?? {
69
- kind: 'reference',
70
- name: parseProtocolQualifiedIdentifier(expression.name),
71
- };
67
+ // Own properties only: an alias such as `constructor` must stay a reference.
68
+ return Object.hasOwn(references, expression.name)
69
+ ? references[expression.name]
70
+ : { kind: 'reference', name: referenceName(expression.name) };
72
71
  case 'literal':
73
72
  return { kind: 'literal', value: canonicalValue(expression.value) };
74
73
  case 'binary':
75
74
  return {
76
75
  kind: 'binary',
77
76
  operator: expression.operator,
78
- left: semanticExpression(expression.left, references),
79
- right: semanticExpression(expression.right, references),
77
+ left: semanticExpression(expression.left, references, referenceName),
78
+ right: semanticExpression(expression.right, references, referenceName),
80
79
  };
81
80
  case 'function':
82
81
  return {
83
82
  kind: 'call',
84
83
  function: expression.name,
85
- args: expression.args.map(argument => semanticExpression(argument, references)),
84
+ args: expression.args.map(argument => semanticExpression(argument, references, referenceName)),
86
85
  };
87
86
  }
88
87
  }
89
- export function derivedMeasureExpression(definition) {
88
+ function derivedFormula(definition) {
90
89
  const aliases = Object.fromEntries(Object.keys(definition.uses).map(alias => [alias, alias]));
91
- return semanticExpression(definition.formula(aliases).expression);
90
+ return definition.formula(aliases).expression;
91
+ }
92
+ /** The published form; aliases must satisfy the protocol identifier grammar. */
93
+ export function derivedMeasureExpression(definition) {
94
+ return semanticExpression(derivedFormula(definition));
95
+ }
96
+ /**
97
+ * The catalog form. Catalogs describe local datasets that may never be published,
98
+ * so aliases keep their locally valid names (for example `__hypequery_value`, or
99
+ * names past the protocol's length limit) instead of failing catalog creation.
100
+ * Publishing still validates them through `derivedMeasureExpression`.
101
+ */
102
+ export function derivedMeasureCatalogExpression(definition) {
103
+ return semanticExpression(derivedFormula(definition), {}, name => name);
92
104
  }
93
105
  export function metricExpression(spec) {
94
106
  if (spec.__type === 'aggregation_spec')
@@ -1,3 +1,4 @@
1
+ import type { RelationshipKey } from '../types.js';
1
2
  import type { DatasetSqlDialect } from '../dataset-sql-dialect.js';
2
3
  /**
3
4
  * Query-builder-path planning for to-one relationship joins.
@@ -18,6 +19,7 @@ export interface ResolvedBuilderJoin {
18
19
  /** Physical target source table. */
19
20
  source: string;
20
21
  matchMarker?: string;
22
+ keys?: readonly RelationshipKey[];
21
23
  /** Base join column (unqualified). */
22
24
  from: string;
23
25
  /** Target join column (unqualified). */
@@ -1 +1 @@
1
- {"version":3,"file":"relationship-builder-plan.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-builder-plan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAGnE;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACZ,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAGtF,MAAM,WAAW,mBAAmB;IAClC,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,kEAAkE;IAClE,MAAM,CAAC,EAAE,eAAe,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C;AAED,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,kBAAkB,EAAE,GAAG,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CACtD;AAED,KAAK,SAAS,GAAG,IAAI,CAAC,YAAY,GAAG,WAAW,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;AAEzH;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,SAAS,EAChB,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,iBAA+C,GACvD,0BAA0B,GAAG,SAAS,CA2CxC;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,0BAA0B,GAAG,SAAS,EAC3C,MAAM,EAAE,MAAM,GACb,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,MAAM,GACX,MAAM,CAOR;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,gBAAgB,EACpB,GAAG,EAAE,0BAA0B,GAAG,SAAS,GAC1C,gBAAgB,CA4BlB"}
1
+ {"version":3,"file":"relationship-builder-plan.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-builder-plan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEnD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAGnE;;;;;;;;;GASG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,WAAW,EACZ,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAGtF,MAAM,WAAW,mBAAmB;IAClC,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;IAClC,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,kEAAkE;IAClE,MAAM,CAAC,EAAE,eAAe,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9C;AAED,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,kBAAkB,EAAE,GAAG,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CACtD;AAED,KAAK,SAAS,GAAG,IAAI,CAAC,YAAY,GAAG,WAAW,EAAE,YAAY,GAAG,SAAS,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;AAEzH;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,SAAS,EAChB,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE,iBAA+C,GACvD,0BAA0B,GAAG,SAAS,CA+CxC;AAED,mFAAmF;AACnF,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,0BAA0B,GAAG,SAAS,EAC3C,MAAM,EAAE,MAAM,GACb,MAAM,CAER;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,MAAM,GACX,MAAM,CAOR;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,gBAAgB,EACpB,GAAG,EAAE,0BAA0B,GAAG,SAAS,GAC1C,gBAAgB,CAoClB"}
@@ -1,3 +1,4 @@
1
+ import { relationshipKeys } from './relationship-keys.js';
1
2
  import { relationshipMeasureSource } from './relationship-measure-sql.js';
2
3
  import { clickhouseDatasetSqlDialect } from './clickhouse-dataset-sql-dialect.js';
3
4
  import { resolveRelationshipReference } from './relationship-measures.js';
@@ -29,14 +30,18 @@ export function buildRelationshipBuilderContext(ds, query, context, dialect = cl
29
30
  if (joinByRelationship.has(relationshipName)) {
30
31
  continue;
31
32
  }
32
- const measureNames = (query.measures ?? []).filter(name => name.startsWith(`${relationshipName}.`)).map(name => name.slice(relationshipName.length + 1));
33
- const projected = measureNames.length ? relationshipMeasureSource(target, relationship.to, measureNames, dialect) : undefined;
33
+ const measureNames = (query.measures ?? [])
34
+ .filter(name => name.startsWith(`${relationshipName}.`))
35
+ .map(name => name.slice(relationshipName.length + 1));
36
+ const keys = relationshipKeys(relationship);
37
+ const projected = measureNames.length ? relationshipMeasureSource(target, keys.map(key => key.to), measureNames, dialect) : undefined;
34
38
  joinByRelationship.set(relationshipName, {
35
39
  relationship: relationshipName,
36
40
  source: projected?.source ?? target.source,
37
41
  matchMarker: projected?.marker,
38
42
  from: relationship.from,
39
43
  to: relationship.to,
44
+ keys,
40
45
  tenant: tenantPredicate && target.tenantKey
41
46
  ? { field: target.tenantKey, ...tenantPredicate }
42
47
  : undefined,
@@ -85,13 +90,20 @@ export function applyRelationshipJoins(qb, ctx) {
85
90
  'would fan out duplicate target keys and inflate aggregates, so relationship-qualified fields ' +
86
91
  'are unavailable with this builder.');
87
92
  }
88
- qb = qb.leftAnyJoin(join.source, `${ctx.baseSource}.${join.from}`, `${join.relationship}.${join.to}`, join.relationship, join.tenant
93
+ const tenant = join.tenant
89
94
  ? {
90
95
  column: `${join.relationship}.${join.tenant.field}`,
91
96
  operator: join.tenant.operator,
92
97
  value: join.tenant.value,
93
98
  }
94
- : undefined);
99
+ : undefined;
100
+ if (join.keys && join.keys.length > 1) {
101
+ // Key-pair form; @hypequery/clickhouse >= 2.13.0.
102
+ const pairs = join.keys.map(key => [`${ctx.baseSource}.${key.from}`, `${join.relationship}.${key.to}`]);
103
+ qb = qb.leftAnyJoin(join.source, [pairs[0], ...pairs.slice(1)], join.relationship, tenant);
104
+ continue;
105
+ }
106
+ qb = qb.leftAnyJoin(join.source, `${ctx.baseSource}.${join.from}`, `${join.relationship}.${join.to}`, join.relationship, tenant);
95
107
  }
96
108
  return qb;
97
109
  }
@@ -0,0 +1,6 @@
1
+ import type { RelationshipDefinition, RelationshipKind, RelationshipJoin } from '../types.js';
2
+ export declare function createRelationship<TTarget extends {
3
+ __type: 'dataset';
4
+ name: string;
5
+ }, TKind extends RelationshipKind>(kind: TKind, target: () => TTarget, join: RelationshipJoin): RelationshipDefinition<TTarget, TKind>;
6
+ //# sourceMappingURL=relationship-definition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-definition.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-definition.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG9F,wBAAgB,kBAAkB,CAChC,OAAO,SAAS;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACnD,KAAK,SAAS,gBAAgB,EAE9B,IAAI,EAAE,KAAK,EACX,MAAM,EAAE,MAAM,OAAO,EACrB,IAAI,EAAE,gBAAgB,GACrB,sBAAsB,CAAC,OAAO,EAAE,KAAK,CAAC,CAOxC"}
@@ -0,0 +1,9 @@
1
+ import { normalizeRelationshipJoin } from './relationship-keys.js';
2
+ export function createRelationship(kind, target, join) {
3
+ return {
4
+ __type: 'relationship',
5
+ kind,
6
+ target,
7
+ ...normalizeRelationshipJoin(join),
8
+ };
9
+ }
@@ -11,6 +11,8 @@ export interface ToOneRelationshipTarget {
11
11
  target: AnyDatasetInstance;
12
12
  /** Target join column, which a to-one declaration requires to be unique. */
13
13
  column: string;
14
+ /** Full target key; absent on legacy single-key findings. */
15
+ columns?: readonly string[];
14
16
  }
15
17
  /**
16
18
  * Lists the to-one relationships to check. `hasMany` is excluded because it
@@ -27,6 +29,8 @@ export interface RelationshipKeyIssue {
27
29
  source: string;
28
30
  /** Target join column. */
29
31
  column: string;
32
+ /** Full target key; absent on legacy single-key findings. */
33
+ columns?: readonly string[];
30
34
  /** Target rows with a non-NULL key, within the checked tenant scope. Large counts are decimal strings. */
31
35
  rows: number | string;
32
36
  /** Distinct non-NULL keys, within the checked tenant scope. Large counts are decimal strings. */
@@ -1 +1 @@
1
- {"version":3,"file":"relationship-key-check.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-key-check.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAA0B,MAAM,aAAa,CAAC;AAE9E,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE3D,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,qBAAqB,CAAC;IAC5B,sBAAsB;IACtB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,kBAAkB,EACtB,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,GACvB,uBAAuB,EAAE,CAuB3B;AAED,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,qBAAqB,CAAC;IAC5B,2BAA2B;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,0GAA0G;IAC1G,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,iGAAiG;IACjG,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAchD;AAMD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,uBAAuB,EAC9B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,GACnB,oBAAoB,GAAG,SAAS,CAkBlC"}
1
+ {"version":3,"file":"relationship-key-check.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-key-check.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAA0B,MAAM,aAAa,CAAC;AAG9E,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE3D,MAAM,WAAW,uBAAuB;IACtC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,qBAAqB,CAAC;IAC5B,sBAAsB;IACtB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,kBAAkB,EACtB,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,GACvB,uBAAuB,EAAE,CA2B3B;AAED,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,qBAAqB,CAAC;IAC5B,2BAA2B;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,0BAA0B;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,0GAA0G;IAC1G,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,iGAAiG;IACjG,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAchD;AAMD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,uBAAuB,EAC9B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,GACnB,oBAAoB,GAAG,SAAS,CAmBlC"}
@@ -2,6 +2,7 @@
2
2
  * Pure helpers for `checkRelationships`: which relationships a check covers,
3
3
  * and how a key-count row becomes a finding.
4
4
  */
5
+ import { relationshipKeys } from './relationship-keys.js';
5
6
  /**
6
7
  * Lists the to-one relationships to check. `hasMany` is excluded because it
7
8
  * never joins, so its key has no uniqueness requirement. An explicit
@@ -22,12 +23,16 @@ export function listToOneRelationships(ds, only) {
22
23
  }
23
24
  return declared
24
25
  .filter(([name, relationship]) => relationship.kind !== 'hasMany' && (!only || only.includes(name)))
25
- .map(([name, relationship]) => ({
26
- relationship: name,
27
- kind: relationship.kind,
28
- target: relationship.target(),
29
- column: relationship.to,
30
- }));
26
+ .map(([name, relationship]) => {
27
+ const columns = relationshipKeys(relationship).map(key => key.to);
28
+ return {
29
+ relationship: name,
30
+ kind: relationship.kind,
31
+ target: relationship.target(),
32
+ column: relationship.to,
33
+ ...(columns.length > 1 ? { columns } : {}),
34
+ };
35
+ });
31
36
  }
32
37
  /** Reads a count without losing precision when a driver returns UInt64 as a string. */
33
38
  export function readCount(value) {
@@ -63,10 +68,17 @@ export function relationshipKeyIssue(entry, rows, distinctKeys) {
63
68
  target: entry.target.name,
64
69
  source,
65
70
  column: entry.column,
71
+ ...(entry.columns ? { columns: [...entry.columns] } : {}),
66
72
  rows: displayCount(rows),
67
73
  distinctKeys: displayCount(distinctKeys),
68
- message: `Relationship "${entry.relationship}" is declared ${entry.kind}, but "${source}.${entry.column}" ` +
74
+ message: `Relationship "${entry.relationship}" is declared ${entry.kind}, but ${describeKey(source, entry)} ` +
69
75
  `has ${rows} rows for ${distinctKeys} distinct keys. Joins pick an arbitrary matching row; ` +
70
76
  'make the key unique or declare the relationship as hasMany.',
71
77
  };
72
78
  }
79
+ /** `"customers.id"`, or `("customers.id", "customers.region")` for a composite key. */
80
+ function describeKey(source, entry) {
81
+ if (!entry.columns)
82
+ return `"${source}.${entry.column}"`;
83
+ return `(${entry.columns.map(column => `"${source}.${column}"`).join(', ')})`;
84
+ }
@@ -0,0 +1,17 @@
1
+ import type { RelationshipDefinition, RelationshipJoin, RelationshipKey } from '../types.js';
2
+ type NormalizedRelationshipJoin = Pick<RelationshipDefinition, 'from' | 'to' | 'keys'>;
3
+ /**
4
+ * Snapshot an authored join. `{ from, to }` passes through unchanged, as it
5
+ * always has; `keys` pairs are validated before they are interpolated into SQL.
6
+ */
7
+ export declare function normalizeRelationshipJoin(join: RelationshipJoin): NormalizedRelationshipJoin;
8
+ /**
9
+ * Rebuild a serialized composite key. Portable records carry `keys` only for
10
+ * composite relationships, and `from`/`to` must mirror the first pair.
11
+ */
12
+ export declare function rehydrateCompositeRelationshipJoin(from: string, to: string, keys: readonly RelationshipKey[]): NormalizedRelationshipJoin;
13
+ export declare function validateRelationshipKeys(keys: readonly RelationshipKey[]): asserts keys is readonly [RelationshipKey, ...RelationshipKey[]];
14
+ /** Return the whole equality key; legacy relationships remain single-key. */
15
+ export declare function relationshipKeys(relationship: Pick<RelationshipDefinition, 'from' | 'to' | 'keys'>): readonly RelationshipKey[];
16
+ export {};
17
+ //# sourceMappingURL=relationship-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-keys.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-keys.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAI7F,KAAK,0BAA0B,GAAG,IAAI,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC,CAAC;AAEvF;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,gBAAgB,GAAG,0BAA0B,CAgB5F;AAED;;;GAGG;AACH,wBAAgB,kCAAkC,CAChD,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,SAAS,eAAe,EAAE,GAC/B,0BAA0B,CAU5B;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,SAAS,eAAe,EAAE,GAC/B,OAAO,CAAC,IAAI,IAAI,SAAS,CAAC,eAAe,EAAE,GAAG,eAAe,EAAE,CAAC,CAYlE;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,IAAI,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAAC,GAAG,SAAS,eAAe,EAAE,CAQ/H"}
@@ -0,0 +1,63 @@
1
+ const COLUMN = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
+ /**
3
+ * Snapshot an authored join. `{ from, to }` passes through unchanged, as it
4
+ * always has; `keys` pairs are validated before they are interpolated into SQL.
5
+ */
6
+ export function normalizeRelationshipJoin(join) {
7
+ if (join.keys === undefined) {
8
+ return { from: join.from, to: join.to };
9
+ }
10
+ if (join.from !== undefined || join.to !== undefined) {
11
+ throw new Error('Relationship keys cannot be combined with from/to.');
12
+ }
13
+ const keys = join.keys;
14
+ validateRelationshipKeys(keys);
15
+ const copy = [
16
+ Object.freeze({ from: keys[0].from, to: keys[0].to }),
17
+ ...keys.slice(1).map(key => Object.freeze({ from: key.from, to: key.to })),
18
+ ];
19
+ // A single pair is the legacy relationship: contracts and catalogs carry
20
+ // `keys` only for composite relationships, however the pair was authored.
21
+ return { from: copy[0].from, to: copy[0].to, ...(copy.length > 1 ? { keys: Object.freeze(copy) } : {}) };
22
+ }
23
+ /**
24
+ * Rebuild a serialized composite key. Portable records carry `keys` only for
25
+ * composite relationships, and `from`/`to` must mirror the first pair.
26
+ */
27
+ export function rehydrateCompositeRelationshipJoin(from, to, keys) {
28
+ validateRelationshipKeys(keys);
29
+ if (keys.length < 2) {
30
+ throw new Error('Serialized relationship keys must contain at least two pairs; single-key relationships use from/to.');
31
+ }
32
+ const join = normalizeRelationshipJoin({ keys });
33
+ if (join.from !== from || join.to !== to) {
34
+ throw new Error('Relationship from/to must match the first keys pair.');
35
+ }
36
+ return join;
37
+ }
38
+ export function validateRelationshipKeys(keys) {
39
+ if (!Array.isArray(keys) || keys.length === 0)
40
+ throw new Error('Relationship keys must be a non-empty array.');
41
+ const from = new Set();
42
+ const to = new Set();
43
+ for (const key of keys) {
44
+ if (!key || typeof key.from !== 'string' || typeof key.to !== 'string' || !COLUMN.test(key.from) || !COLUMN.test(key.to)) {
45
+ throw new Error('Relationship keys must contain safe physical from/to column identifiers.');
46
+ }
47
+ if (from.has(key.from) || to.has(key.to))
48
+ throw new Error('Relationship keys must not repeat a source or target column.');
49
+ from.add(key.from);
50
+ to.add(key.to);
51
+ }
52
+ }
53
+ /** Return the whole equality key; legacy relationships remain single-key. */
54
+ export function relationshipKeys(relationship) {
55
+ const keys = relationship.keys ?? [{ from: relationship.from, to: relationship.to }];
56
+ if (!relationship.keys)
57
+ return keys;
58
+ validateRelationshipKeys(keys);
59
+ if (keys[0].from !== relationship.from || keys[0].to !== relationship.to) {
60
+ throw new Error('Relationship from/to must match the first keys pair.');
61
+ }
62
+ return keys;
63
+ }
@@ -2,7 +2,7 @@ import type { AnyDatasetInstance } from '../types.js';
2
2
  import type { RelationshipBuilderContext } from './relationship-builder-plan.js';
3
3
  import type { DatasetSqlDialect } from '../dataset-sql-dialect.js';
4
4
  /** Explicit projection avoids marker collisions and makes absent targets detectable under join_use_nulls=0. */
5
- export declare function relationshipMeasureSource(target: AnyDatasetInstance, to: string, names: readonly string[], dialect: DatasetSqlDialect): {
5
+ export declare function relationshipMeasureSource(target: AnyDatasetInstance, to: readonly string[], names: readonly string[], dialect: DatasetSqlDialect): {
6
6
  source: string;
7
7
  marker: string;
8
8
  };
@@ -1 +1 @@
1
- {"version":3,"file":"relationship-measure-sql.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-measure-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAmB,MAAM,aAAa,CAAC;AAEvE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAIjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAEnE,+GAA+G;AAC/G,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,iBAAiB;;;EAgBrI;AAED,wBAAgB,8BAA8B,CAAC,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,0BAA0B;;;;EAevH"}
1
+ {"version":3,"file":"relationship-measure-sql.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-measure-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAmB,MAAM,aAAa,CAAC;AAEvE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAIjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAEnE,+GAA+G;AAC/G,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,kBAAkB,EAAE,EAAE,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,iBAAiB;;;EAgBhJ;AAED,wBAAgB,8BAA8B,CAAC,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,0BAA0B;;;;EAevH"}
@@ -4,7 +4,7 @@ import { measureToAggregationSpec } from './dataset-normalization.js';
4
4
  import { applyFilteredAggregationExpression } from './filtered-aggregation-sql.js';
5
5
  /** Explicit projection avoids marker collisions and makes absent targets detectable under join_use_nulls=0. */
6
6
  export function relationshipMeasureSource(target, to, names, dialect) {
7
- const columns = new Set([to, ...(target.tenantKey ? [target.tenantKey] : [])]);
7
+ const columns = new Set([...to, ...(target.tenantKey ? [target.tenantKey] : [])]);
8
8
  for (const [name, dimension] of Object.entries(target.dimensions))
9
9
  if (!dimension.sql)
10
10
  columns.add(dimension.column ?? name);
@@ -1 +1 @@
1
- {"version":3,"file":"semantic-query-syntax.d.ts","sourceRoot":"","sources":["../../src/utils/semantic-query-syntax.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAI7D,oFAAoF;AACpF,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,GAAG,MAAM,EAAE,CAarF;AAED,kEAAkE;AAClE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAIzE"}
1
+ {"version":3,"file":"semantic-query-syntax.d.ts","sourceRoot":"","sources":["../../src/utils/semantic-query-syntax.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE7D,oFAAoF;AACpF,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,YAAY,GAAG,WAAW,GAAG,MAAM,EAAE,CAarF;AAED,kEAAkE;AAClE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAIzE"}
@@ -1,10 +1,9 @@
1
- import { SEMANTIC_FILTER_OPERATORS } from '../constants.js';
2
- const FILTER_OPERATORS = new Set(SEMANTIC_FILTER_OPERATORS);
1
+ import { isSemanticFilterOperator } from '../constants.js';
3
2
  /** Reject SQL structure outside the semantic contract before reaching a builder. */
4
3
  export function semanticQuerySyntaxErrors(query) {
5
4
  const errors = [];
6
5
  for (const filter of query.filters ?? []) {
7
- if (!FILTER_OPERATORS.has(filter.operator)) {
6
+ if (!isSemanticFilterOperator(filter.operator)) {
8
7
  errors.push(`Unsupported semantic filter operator "${String(filter.operator)}".`);
9
8
  }
10
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"shift-measure-sql.d.ts","sourceRoot":"","sources":["../../src/utils/shift-measure-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAI1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAI9D,sFAAsF;AACtF,wBAAgB,aAAa,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,CAAC,EAAE,MAAM;;;;;EAclG;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,aAAa,CAAC,GAAG,MAAM,CAGpG;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAOrH;AAED,+FAA+F;AAC/F,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,sBAAsB,EAAE,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,GAAG,MAAM,CAMlH;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAsBhK"}
1
+ {"version":3,"file":"shift-measure-sql.d.ts","sourceRoot":"","sources":["../../src/utils/shift-measure-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAI1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAK9D,sFAAsF;AACtF,wBAAgB,aAAa,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,CAAC,EAAE,MAAM;;;;;EAoBlG;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,CAAC,OAAO,aAAa,CAAC,GAAG,MAAM,CAGpG;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAOrH;AAED,+FAA+F;AAC/F,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,sBAAsB,EAAE,KAAK,EAAE,eAAe,CAAC,OAAO,CAAC,GAAG,MAAM,CAMlH;AAED,2FAA2F;AAC3F,wBAAgB,eAAe,CAAC,KAAK,EAAE,sBAAsB,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,CAsBhK"}
@@ -3,18 +3,25 @@ import { addTimeSql, subtractTimeSql } from './time-arithmetic-sql.js';
3
3
  import { calendarBucketGuardSql } from './time-axis-calendar-guard.js';
4
4
  import { calendarShiftAtFineGrain } from './shift-measure-grains.js';
5
5
  import { shiftWallTimeGuardSql } from './shift-wall-time-sql.js';
6
+ import { anchoredCalendarShiftSql } from './calendar-shift-sql.js';
6
7
  /** Preserve the selected portion of a bucket and both authored endpoint operators. */
7
8
  export function shiftRangeSql(shift, axis, period) {
8
9
  const first = period ?? '_hq_first';
9
10
  const last = period ?? '_hq_last';
10
11
  const end = addTimeSql(last, 1, axis.grain);
11
- const upper = subtractTimeSql(`least(${end}, _hq_upper)`, shift.interval.amount, shift.interval.unit);
12
+ const calendar = calendarShiftAtFineGrain(shift.interval, axis.grain);
12
13
  // Clamp the bucket anchor, then retain its width. Independently clamping both
13
14
  // dates would turn March 30–31 into February 28–28, dropping an entire day.
15
+ // A partial bucket keeps the matching slice of that shifted range, so both
16
+ // bounds are placed relative to the anchor rather than shifted on their own.
17
+ const shiftBound = (point, anchor) => calendar
18
+ ? anchoredCalendarShiftSql(point, anchor, shift.interval)
19
+ : subtractTimeSql(point, shift.interval.amount, shift.interval.unit);
20
+ const upper = shiftBound(`least(${end}, _hq_upper)`, last);
14
21
  const shiftedEnd = addTimeSql(subtractTimeSql(last, shift.interval.amount, shift.interval.unit), 1, axis.grain);
15
22
  return {
16
- lower: subtractTimeSql(`greatest(${first}, _hq_lower)`, shift.interval.amount, shift.interval.unit),
17
- upper: calendarShiftAtFineGrain(shift.interval, axis.grain) ? `if(_hq_upper >= ${end}, ${shiftedEnd}, ${upper})` : upper,
23
+ lower: shiftBound(`greatest(${first}, _hq_lower)`, first),
24
+ upper: calendar ? `if(_hq_upper >= ${end}, ${shiftedEnd}, ${upper})` : upper,
18
25
  lowerInclusive: period ? `(${period} > _hq_lower OR ${Number(axis.lowerInclusive)})` : String(Number(axis.lowerInclusive)),
19
26
  upperInclusive: `(_hq_upper < ${end} AND ${Number(axis.upperInclusive)})`,
20
27
  };
@@ -1 +1 @@
1
- {"version":3,"file":"time-measure-context-sql.d.ts","sourceRoot":"","sources":["../../src/utils/time-measure-context-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAM9D,8EAA8E;AAC9E,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,SAAS,wBAAwB,EAAE,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,EAAE,CAUrH;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,CAOxG"}
1
+ {"version":3,"file":"time-measure-context-sql.d.ts","sourceRoot":"","sources":["../../src/utils/time-measure-context-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAO9D,8EAA8E;AAC9E,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,SAAS,wBAAwB,EAAE,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,EAAE,CAerH;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,EAAE,IAAI,EAAE,eAAe,GAAG,MAAM,CAOxG"}
@@ -2,16 +2,22 @@ import { addTimeSql as add, subtractTimeSql as subtract } from './time-arithmeti
2
2
  import { calendarShiftAtFineGrain } from './shift-measure-grains.js';
3
3
  import { calendarBucketGuardSql } from './time-axis-calendar-guard.js';
4
4
  import { shiftWallTimeGuardSql } from './shift-wall-time-sql.js';
5
+ import { anchoredCalendarShiftSql } from './calendar-shift-sql.js';
5
6
  /** Keep output coordinates separate from each shifted evaluation interval. */
6
7
  export function timeMeasureContextCtes(contexts, axis) {
7
8
  const end = add('_hq_period', 1, axis.grain);
8
9
  const root = `_hq_context0 AS (SELECT _hq_period, _hq_period AS _hq_eval_period, ${end} AS _hq_eval_end, greatest(_hq_period, _hq_lower) AS _hq_eval_lower, least(${end}, _hq_upper) AS _hq_eval_upper, (_hq_period > _hq_lower OR ${Number(axis.lowerInclusive)}) AS _hq_lower_inclusive, (_hq_upper < ${end} AND ${Number(axis.upperInclusive)}) AS _hq_upper_inclusive FROM _hq_series CROSS JOIN _hq_bounds)`;
9
10
  return [root, ...contexts.slice(1).map(context => {
10
11
  const interval = context.interval;
12
+ const calendar = calendarShiftAtFineGrain(interval, axis.grain);
11
13
  const shift = (value) => subtract(`p.${value}`, interval.amount, interval.unit);
14
+ // Partial bounds stay inside the shifted bucket; see anchoredCalendarShiftSql.
15
+ const shiftBound = (value) => calendar
16
+ ? anchoredCalendarShiftSql(`p.${value}`, 'p._hq_eval_period', interval)
17
+ : shift(value);
12
18
  const period = shift('_hq_eval_period');
13
- const shiftedEnd = calendarShiftAtFineGrain(interval, axis.grain) ? add(period, 1, axis.grain) : shift('_hq_eval_end');
14
- return `_hq_context${context.id} AS (SELECT p._hq_period, ${period} AS _hq_eval_period, ${shiftedEnd} AS _hq_eval_end, ${shift('_hq_eval_lower')} AS _hq_eval_lower, if(p._hq_eval_upper = p._hq_eval_end, ${shiftedEnd}, ${shift('_hq_eval_upper')}) AS _hq_eval_upper, p._hq_lower_inclusive, p._hq_upper_inclusive FROM _hq_context${context.parent} AS p)`;
19
+ const shiftedEnd = calendar ? add(period, 1, axis.grain) : shift('_hq_eval_end');
20
+ return `_hq_context${context.id} AS (SELECT p._hq_period, ${period} AS _hq_eval_period, ${shiftedEnd} AS _hq_eval_end, ${shiftBound('_hq_eval_lower')} AS _hq_eval_lower, if(p._hq_eval_upper = p._hq_eval_end, ${shiftedEnd}, ${shiftBound('_hq_eval_upper')}) AS _hq_eval_upper, p._hq_lower_inclusive, p._hq_upper_inclusive FROM _hq_context${context.parent} AS p)`;
15
21
  })];
16
22
  }
17
23
  export function timeMeasureContextGuard(context, axis) {