@hypequery/datasets 0.21.0 → 0.23.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.
Files changed (174) hide show
  1. package/README.md +6 -0
  2. package/dist/api.type-test.js +7 -0
  3. package/dist/cache/protocol-cache-keys.d.ts.map +1 -1
  4. package/dist/cache/protocol-cache-keys.js +5 -0
  5. package/dist/cache/query-signature.d.ts.map +1 -1
  6. package/dist/cache/query-signature.js +9 -0
  7. package/dist/catalog.d.ts +12 -0
  8. package/dist/catalog.d.ts.map +1 -1
  9. package/dist/catalog.js +14 -1
  10. package/dist/constants.d.ts +12 -1
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +24 -0
  13. package/dist/contract.d.ts +4 -1
  14. package/dist/contract.d.ts.map +1 -1
  15. package/dist/contract.js +4 -0
  16. package/dist/dataset-compilation.d.ts +50 -0
  17. package/dist/dataset-compilation.d.ts.map +1 -0
  18. package/dist/dataset-compilation.js +1 -0
  19. package/dist/dataset-query.d.ts +3 -3
  20. package/dist/dataset-query.d.ts.map +1 -1
  21. package/dist/dataset-query.js +7 -71
  22. package/dist/dataset-sql-dialect.d.ts +11 -0
  23. package/dist/dataset-sql-dialect.d.ts.map +1 -0
  24. package/dist/dataset-sql-dialect.js +1 -0
  25. package/dist/dataset.d.ts +3 -2
  26. package/dist/dataset.d.ts.map +1 -1
  27. package/dist/execution-capabilities.d.ts +28 -0
  28. package/dist/execution-capabilities.d.ts.map +1 -0
  29. package/dist/execution-capabilities.js +17 -0
  30. package/dist/execution-capabilities.json +13 -0
  31. package/dist/executor.d.ts +17 -38
  32. package/dist/executor.d.ts.map +1 -1
  33. package/dist/executor.js +40 -400
  34. package/dist/index.d.ts +10 -5
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -3
  37. package/dist/internal.d.ts +3 -0
  38. package/dist/internal.d.ts.map +1 -1
  39. package/dist/internal.js +2 -0
  40. package/dist/measure.d.ts +54 -17
  41. package/dist/measure.d.ts.map +1 -1
  42. package/dist/measure.js +3 -63
  43. package/dist/metric-query-engine.d.ts +29 -0
  44. package/dist/metric-query-engine.d.ts.map +1 -0
  45. package/dist/metric-query-engine.js +106 -0
  46. package/dist/portable-executor.d.ts +20 -6
  47. package/dist/portable-executor.d.ts.map +1 -1
  48. package/dist/portable-executor.js +29 -9
  49. package/dist/protocol-adapter.d.ts.map +1 -1
  50. package/dist/protocol-adapter.js +1 -0
  51. package/dist/protocol-rehydrate.d.ts.map +1 -1
  52. package/dist/protocol-rehydrate.js +9 -2
  53. package/dist/query-builder-protocol.d.ts +11 -0
  54. package/dist/query-builder-protocol.d.ts.map +1 -1
  55. package/dist/query-helpers.d.ts +10 -10
  56. package/dist/query-helpers.d.ts.map +1 -1
  57. package/dist/query-helpers.js +1 -3
  58. package/dist/query-planner.d.ts +7 -3
  59. package/dist/query-planner.d.ts.map +1 -1
  60. package/dist/query-planner.js +10 -12
  61. package/dist/relationship-check.d.ts +3 -2
  62. package/dist/relationship-check.d.ts.map +1 -1
  63. package/dist/relationship-check.js +8 -3
  64. package/dist/relationships.d.ts +6 -16
  65. package/dist/relationships.d.ts.map +1 -1
  66. package/dist/relationships.js +3 -12
  67. package/dist/semantic-planner.d.ts.map +1 -1
  68. package/dist/semantic-planner.js +4 -0
  69. package/dist/semantic-query-schema.d.ts +6 -0
  70. package/dist/semantic-query-schema.d.ts.map +1 -1
  71. package/dist/semantic-query-schema.js +31 -4
  72. package/dist/types.d.ts +67 -11
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/utils/agent-catalog-projection.d.ts +1 -0
  75. package/dist/utils/agent-catalog-projection.d.ts.map +1 -1
  76. package/dist/utils/agent-catalog-projection.js +2 -1
  77. package/dist/utils/base-measure-definition.d.ts +23 -0
  78. package/dist/utils/base-measure-definition.d.ts.map +1 -0
  79. package/dist/utils/base-measure-definition.js +65 -0
  80. package/dist/utils/build-dataset-query-builder.d.ts +5 -0
  81. package/dist/utils/build-dataset-query-builder.d.ts.map +1 -0
  82. package/dist/utils/build-dataset-query-builder.js +65 -0
  83. package/dist/utils/calendar-shift-sql.d.ts +18 -0
  84. package/dist/utils/calendar-shift-sql.d.ts.map +1 -0
  85. package/dist/utils/calendar-shift-sql.js +21 -0
  86. package/dist/utils/clickhouse-dataset-sql-dialect.d.ts +4 -0
  87. package/dist/utils/clickhouse-dataset-sql-dialect.d.ts.map +1 -0
  88. package/dist/utils/clickhouse-dataset-sql-dialect.js +6 -0
  89. package/dist/utils/compile-dataset-query.d.ts +10 -0
  90. package/dist/utils/compile-dataset-query.d.ts.map +1 -0
  91. package/dist/utils/compile-dataset-query.js +72 -0
  92. package/dist/utils/composite-time-measure-sql.d.ts.map +1 -1
  93. package/dist/utils/composite-time-measure-sql.js +2 -1
  94. package/dist/utils/dataset-definition-validation.d.ts.map +1 -1
  95. package/dist/utils/dataset-definition-validation.js +52 -2
  96. package/dist/utils/dataset-derived-query.d.ts +6 -2
  97. package/dist/utils/dataset-derived-query.d.ts.map +1 -1
  98. package/dist/utils/dataset-derived-query.js +28 -26
  99. package/dist/utils/dataset-filter-types.d.ts +28 -0
  100. package/dist/utils/dataset-filter-types.d.ts.map +1 -0
  101. package/dist/utils/dataset-filter-types.js +1 -0
  102. package/dist/utils/dataset-having.d.ts +21 -0
  103. package/dist/utils/dataset-having.d.ts.map +1 -0
  104. package/dist/utils/dataset-having.js +106 -0
  105. package/dist/utils/dataset-normalization.d.ts.map +1 -1
  106. package/dist/utils/dataset-normalization.js +5 -1
  107. package/dist/utils/dataset-query-validation.d.ts.map +1 -1
  108. package/dist/utils/dataset-query-validation.js +17 -3
  109. package/dist/utils/dataset-sql-dialect.d.ts +5 -0
  110. package/dist/utils/dataset-sql-dialect.d.ts.map +1 -0
  111. package/dist/utils/dataset-sql-dialect.js +5 -0
  112. package/dist/utils/dataset-target.d.ts +3 -0
  113. package/dist/utils/dataset-target.d.ts.map +1 -0
  114. package/dist/utils/dataset-target.js +3 -0
  115. package/dist/utils/filter-definition.d.ts +3 -0
  116. package/dist/utils/filter-definition.d.ts.map +1 -0
  117. package/dist/utils/filter-definition.js +3 -0
  118. package/dist/utils/metric-derived-query.d.ts +8 -0
  119. package/dist/utils/metric-derived-query.d.ts.map +1 -0
  120. package/dist/utils/metric-derived-query.js +106 -0
  121. package/dist/utils/metric-query-builder.d.ts +5 -0
  122. package/dist/utils/metric-query-builder.d.ts.map +1 -0
  123. package/dist/utils/metric-query-builder.js +50 -0
  124. package/dist/utils/metric-query-result.d.ts +10 -0
  125. package/dist/utils/metric-query-result.d.ts.map +1 -0
  126. package/dist/utils/metric-query-result.js +18 -0
  127. package/dist/utils/metric-query-validation.d.ts +6 -0
  128. package/dist/utils/metric-query-validation.d.ts.map +1 -0
  129. package/dist/utils/metric-query-validation.js +116 -0
  130. package/dist/utils/protocol-metric-expressions.d.ts +8 -0
  131. package/dist/utils/protocol-metric-expressions.d.ts.map +1 -1
  132. package/dist/utils/protocol-metric-expressions.js +23 -11
  133. package/dist/utils/relationship-builder-plan.d.ts +6 -2
  134. package/dist/utils/relationship-builder-plan.d.ts.map +1 -1
  135. package/dist/utils/relationship-builder-plan.js +24 -15
  136. package/dist/utils/relationship-definition.d.ts +6 -0
  137. package/dist/utils/relationship-definition.d.ts.map +1 -0
  138. package/dist/utils/relationship-definition.js +9 -0
  139. package/dist/utils/relationship-key-check.d.ts +4 -0
  140. package/dist/utils/relationship-key-check.d.ts.map +1 -1
  141. package/dist/utils/relationship-key-check.js +19 -7
  142. package/dist/utils/relationship-keys.d.ts +17 -0
  143. package/dist/utils/relationship-keys.d.ts.map +1 -0
  144. package/dist/utils/relationship-keys.js +63 -0
  145. package/dist/utils/relationship-measure-sql.d.ts +14 -0
  146. package/dist/utils/relationship-measure-sql.d.ts.map +1 -0
  147. package/dist/utils/relationship-measure-sql.js +44 -0
  148. package/dist/utils/relationship-measure-types.d.ts +36 -0
  149. package/dist/utils/relationship-measure-types.d.ts.map +1 -0
  150. package/dist/utils/relationship-measure-types.js +1 -0
  151. package/dist/utils/relationship-measures.d.ts +23 -0
  152. package/dist/utils/relationship-measures.d.ts.map +1 -0
  153. package/dist/utils/relationship-measures.js +62 -0
  154. package/dist/utils/relationship-validation.d.ts +1 -1
  155. package/dist/utils/relationship-validation.d.ts.map +1 -1
  156. package/dist/utils/relationship-validation.js +3 -5
  157. package/dist/utils/semantic-query-syntax.d.ts +6 -0
  158. package/dist/utils/semantic-query-syntax.d.ts.map +1 -0
  159. package/dist/utils/semantic-query-syntax.js +24 -0
  160. package/dist/utils/shift-measure-sql.d.ts.map +1 -1
  161. package/dist/utils/shift-measure-sql.js +10 -3
  162. package/dist/utils/snapshot-execution-context.d.ts +4 -0
  163. package/dist/utils/snapshot-execution-context.d.ts.map +1 -0
  164. package/dist/utils/snapshot-execution-context.js +16 -0
  165. package/dist/utils/time-measure-context-sql.d.ts.map +1 -1
  166. package/dist/utils/time-measure-context-sql.js +8 -2
  167. package/dist/utils/time-measure-dataset-sql.d.ts.map +1 -1
  168. package/dist/utils/time-measure-dataset-sql.js +3 -2
  169. package/dist/utils/time-measure-result-sql.d.ts.map +1 -1
  170. package/dist/utils/time-measure-result-sql.js +2 -1
  171. package/dist/utils/time-measure-source-sql.d.ts.map +1 -1
  172. package/dist/utils/time-measure-source-sql.js +8 -4
  173. package/dist/validation.js +1 -1
  174. package/package.json +10 -5
@@ -0,0 +1,106 @@
1
+ import { semanticQuerySyntaxErrors, semanticOrderDirection } from './semantic-query-syntax.js';
2
+ /** Metric SQL compilation; factory selection and execution belong to the client. */
3
+ import { resolveDatasetSqlDialect } from './dataset-sql-dialect.js';
4
+ import { applyAggregationSpec, buildDimensionSelectionPlan, resolveDimensionExpression, resolveFilterField, resolveTenantFilterColumn, } from '../query-planner.js';
5
+ import { queryTimeFilterSql } from './query-timezone.js';
6
+ import { isTenantScopedFilter } from './metric-handle.js';
7
+ import { getRuntimeTenantPredicate } from './tenant-runtime.js';
8
+ import { applyRelationshipJoins, buildRelationshipBuilderContext, qualifyBaseColumn, } from './relationship-builder-plan.js';
9
+ import { segmentFilters } from './segments.js';
10
+ import { validateSQLIdentifier } from '../sql-utils.js';
11
+ import { isQualifiedField } from './relationship-fields.js';
12
+ import { validateDerivedCteGrouping } from './derived-cte-validation.js';
13
+ export function buildDerivedMetricSql(ref, spec, query, grain, builderFactory, context) {
14
+ const syntaxErrors = semanticQuerySyntaxErrors(query);
15
+ if (syntaxErrors.length)
16
+ throw new Error(syntaxErrors.join('; '));
17
+ const dialect = resolveDatasetSqlDialect(builderFactory);
18
+ const ds = ref.dataset;
19
+ const joinCtx = buildRelationshipBuilderContext(ds, query, context);
20
+ // Build the CTE inner query using the builder
21
+ let cteBuilder = builderFactory.table(ds.source);
22
+ cteBuilder = applyRelationshipJoins(cteBuilder, joinCtx);
23
+ const { selectParts, groupByParts } = buildDimensionSelectionPlan(ds, query.dimensions ?? [], grain, joinCtx, query.timezone, dialect);
24
+ if (selectParts.length > 0) {
25
+ cteBuilder = cteBuilder.select(selectParts);
26
+ }
27
+ // Base aggregations
28
+ const refAliases = {};
29
+ const aggregateAliases = [];
30
+ for (const [alias, baseMetric] of Object.entries(spec.uses)) {
31
+ const baseSpec = baseMetric.spec;
32
+ if (baseSpec.__type !== 'aggregation_spec') {
33
+ throw new Error(`Derived metric "${ref.name}" references non-base metric "${alias}".`);
34
+ }
35
+ cteBuilder = applyAggregationSpec(cteBuilder, ds, baseSpec, alias, joinCtx);
36
+ refAliases[alias] = alias;
37
+ aggregateAliases.push(alias);
38
+ }
39
+ if (groupByParts.length > 0) {
40
+ cteBuilder = cteBuilder.groupBy(groupByParts);
41
+ }
42
+ // Filters on CTE
43
+ const tenantColumn = resolveTenantFilterColumn(ds, context);
44
+ const tenantPredicate = getRuntimeTenantPredicate(context);
45
+ if (tenantPredicate && tenantColumn) {
46
+ cteBuilder = cteBuilder.where(qualifyBaseColumn(joinCtx, tenantColumn), tenantPredicate.operator, tenantPredicate.value);
47
+ }
48
+ for (const filter of query.filters ?? []) {
49
+ if (isTenantScopedFilter(ds, filter, context)) {
50
+ throw new Error(`Cannot filter on tenant field "${filter.field}" when runtime tenancy enforcement is active.`);
51
+ }
52
+ const resolvedField = resolveFilterField(ds, filter.field, joinCtx);
53
+ cteBuilder = cteBuilder.where(queryTimeFilterSql(ds, filter.field, resolvedField, query.timezone), filter.operator, filter.value);
54
+ }
55
+ for (const filter of segmentFilters(ds, query.segments)) {
56
+ cteBuilder = cteBuilder.where(resolveDimensionExpression(ds, filter.field, joinCtx), filter.operator, filter.value);
57
+ }
58
+ const { sql: cteSql, parameters: cteParams } = cteBuilder.toSQLWithParams();
59
+ const groupingErrors = validateDerivedCteGrouping(cteSql, aggregateAliases, groupByParts);
60
+ if (groupingErrors.length > 0) {
61
+ throw new Error(groupingErrors.join('; '));
62
+ }
63
+ // Outer query: trivial SELECT from the CTE — stays as string concat
64
+ // because table('base') would fail schema typing
65
+ const outerSelectParts = [];
66
+ if (grain)
67
+ outerSelectParts.push('period');
68
+ for (const dim of query.dimensions ?? []) {
69
+ // Joined dimensions surface from the CTE under their quoted qualified alias.
70
+ if (joinCtx && isQualifiedField(dim)) {
71
+ outerSelectParts.push(dialect.quoteIdentifier(dim));
72
+ continue;
73
+ }
74
+ validateSQLIdentifier(dim, 'dimension name');
75
+ outerSelectParts.push(dim);
76
+ }
77
+ const formulaExpr = spec.formula(refAliases);
78
+ validateSQLIdentifier(ref.name, 'metric name');
79
+ outerSelectParts.push(`${formulaExpr.toSQL()} AS ${ref.name}`);
80
+ let sql = `WITH base AS (${cteSql}) SELECT ${outerSelectParts.join(', ')} FROM base`;
81
+ // ORDER BY
82
+ if (query.orderBy && query.orderBy.length > 0) {
83
+ const orderParts = query.orderBy.map(o => {
84
+ if (joinCtx && isQualifiedField(o.field)) {
85
+ return `${dialect.quoteIdentifier(o.field)} ${semanticOrderDirection(o.direction)}`;
86
+ }
87
+ validateSQLIdentifier(o.field, 'order by field');
88
+ return `${o.field} ${semanticOrderDirection(o.direction)}`;
89
+ });
90
+ sql += ` ORDER BY ${orderParts.join(', ')}`;
91
+ }
92
+ else if (grain) {
93
+ sql += ' ORDER BY period';
94
+ }
95
+ if (query.limit != null) {
96
+ // Ensure limit is a safe integer
97
+ const limit = Math.floor(Math.abs(query.limit));
98
+ sql += ` LIMIT ${limit}`;
99
+ }
100
+ if (query.offset != null) {
101
+ // Ensure offset is a safe integer
102
+ const offset = Math.floor(Math.abs(query.offset));
103
+ sql += ` OFFSET ${offset}`;
104
+ }
105
+ return { sql, params: cteParams };
106
+ }
@@ -0,0 +1,5 @@
1
+ import type { MetricRef, MetricQuery, ExecutionContext, TimeGrain } from '../types.js';
2
+ import type { QueryBuilderFactoryLike, QueryBuilderLike } from '../query-builder-protocol.js';
3
+ import type { AggregationSpec, AnyDatasetInstance } from '../types.js';
4
+ export declare function buildMetricQueryBuilder(ref: MetricRef, spec: AggregationSpec, ds: AnyDatasetInstance, query: MetricQuery, grain: TimeGrain | undefined, builderFactory: QueryBuilderFactoryLike, context?: ExecutionContext): QueryBuilderLike;
5
+ //# sourceMappingURL=metric-query-builder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metric-query-builder.d.ts","sourceRoot":"","sources":["../../src/utils/metric-query-builder.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,KAAK,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAkB9F,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEvE,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,eAAe,EACrB,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,WAAW,EAClB,KAAK,EAAE,SAAS,GAAG,SAAS,EAC5B,cAAc,EAAE,uBAAuB,EACvC,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CAwDlB"}
@@ -0,0 +1,50 @@
1
+ import { semanticQuerySyntaxErrors } from './semantic-query-syntax.js';
2
+ /** Metric SQL compilation; factory selection and execution belong to the client. */
3
+ import { resolveDatasetSqlDialect } from './dataset-sql-dialect.js';
4
+ import { applyAggregationSpec, appendOrderLimitOffset, buildDimensionSelectionPlan, resolveDimensionExpression, resolveFilterField, resolveTenantFilterColumn, } from '../query-planner.js';
5
+ import { queryTimeFilterSql } from './query-timezone.js';
6
+ import { isTenantScopedFilter } from './metric-handle.js';
7
+ import { getRuntimeTenantPredicate } from './tenant-runtime.js';
8
+ import { applyRelationshipJoins, buildRelationshipBuilderContext, qualifyBaseColumn, } from './relationship-builder-plan.js';
9
+ import { segmentFilters } from './segments.js';
10
+ export function buildMetricQueryBuilder(ref, spec, ds, query, grain, builderFactory, context) {
11
+ const syntaxErrors = semanticQuerySyntaxErrors(query);
12
+ if (syntaxErrors.length)
13
+ throw new Error(syntaxErrors.join('; '));
14
+ const dialect = resolveDatasetSqlDialect(builderFactory);
15
+ const joinCtx = buildRelationshipBuilderContext(ds, query, context);
16
+ let qb = builderFactory.table(ds.source);
17
+ qb = applyRelationshipJoins(qb, joinCtx);
18
+ const { selectParts, groupByParts } = buildDimensionSelectionPlan(ds, query.dimensions ?? [], grain, joinCtx, query.timezone, dialect);
19
+ if (selectParts.length > 0) {
20
+ qb = qb.select(selectParts);
21
+ }
22
+ // Aggregation (appends to select, auto-sets groupBy on non-agg columns)
23
+ qb = applyAggregationSpec(qb, ds, spec, ref.name, joinCtx);
24
+ // Explicit groupBy (ensures period + dims are grouped even if aggregation auto-groupBy misses them)
25
+ if (groupByParts.length > 0) {
26
+ qb = qb.groupBy(groupByParts);
27
+ }
28
+ // Tenant auto-injection
29
+ const tenantColumn = resolveTenantFilterColumn(ds, context);
30
+ const tenantPredicate = getRuntimeTenantPredicate(context);
31
+ if (tenantPredicate && tenantColumn) {
32
+ qb = qb.where(qualifyBaseColumn(joinCtx, tenantColumn), tenantPredicate.operator, tenantPredicate.value);
33
+ }
34
+ // User filters
35
+ for (const filter of query.filters ?? []) {
36
+ if (isTenantScopedFilter(ds, filter, context)) {
37
+ throw new Error(`Cannot filter on tenant field "${filter.field}" when runtime tenancy enforcement is active.`);
38
+ }
39
+ const resolvedField = resolveFilterField(ds, filter.field, joinCtx);
40
+ qb = qb.where(queryTimeFilterSql(ds, filter.field, resolvedField, query.timezone), filter.operator, filter.value);
41
+ }
42
+ // Segments: author-defined, so they resolve dimensions directly rather
43
+ // than through the caller-facing filter allow-list.
44
+ for (const filter of segmentFilters(ds, query.segments)) {
45
+ qb = qb.where(resolveDimensionExpression(ds, filter.field, joinCtx), filter.operator, filter.value);
46
+ }
47
+ // Order, limit, offset
48
+ qb = appendOrderLimitOffset(qb, query.orderBy, grain, query.limit, query.offset, joinCtx, dialect);
49
+ return qb;
50
+ }
@@ -0,0 +1,10 @@
1
+ import type { ExecutionContext, MetricQuery, MetricResult } from '../types.js';
2
+ /** Keep base and derived metric execution on the same result contract. */
3
+ export declare function toMetricQueryResult<T>(rows: T[], options: {
4
+ metric: string;
5
+ query: MetricQuery;
6
+ sql: string;
7
+ timingMs: number;
8
+ context?: ExecutionContext;
9
+ }): MetricResult<T>;
10
+ //# sourceMappingURL=metric-query-result.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metric-query-result.d.ts","sourceRoot":"","sources":["../../src/utils/metric-query-result.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAK/E,0EAA0E;AAC1E,wBAAgB,mBAAmB,CAAC,CAAC,EACnC,IAAI,EAAE,CAAC,EAAE,EACT,OAAO,EAAE;IACP,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B,GACA,YAAY,CAAC,CAAC,CAAC,CAajB"}
@@ -0,0 +1,18 @@
1
+ import { applyPagination } from './pagination.js';
2
+ import { serializeSemanticMeasureValues } from './semantic-result-serialization.js';
3
+ import { getRuntimeTenantId } from './tenant-runtime.js';
4
+ /** Keep base and derived metric execution on the same result contract. */
5
+ export function toMetricQueryResult(rows, options) {
6
+ const { data, pagination } = applyPagination(rows, options.query.limit, options.query.offset);
7
+ const serializedData = serializeSemanticMeasureValues(data, [options.metric]);
8
+ return {
9
+ data: serializedData,
10
+ meta: {
11
+ sql: options.sql,
12
+ timingMs: options.timingMs,
13
+ tenant: getRuntimeTenantId(options.context),
14
+ rowCount: serializedData.length,
15
+ pagination,
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,6 @@
1
+ /** Semantic metric validation; independent of SQL compilation and execution. */
2
+ import type { MetricQuery, ExecutionContext } from '../types.js';
3
+ import { type ValidationResult } from '../validation.js';
4
+ import { type MetricHandle } from './metric-handle.js';
5
+ export declare function validateMetricQueryInput(metric: MetricHandle, query: MetricQuery, context?: ExecutionContext): ValidationResult;
6
+ //# sourceMappingURL=metric-query-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metric-query-validation.d.ts","sourceRoot":"","sources":["../../src/utils/metric-query-validation.ts"],"names":[],"mappings":"AACA,gFAAgF;AAChF,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAI9E,OAAO,EAAsD,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAM3G,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,WAAW,EAClB,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CAgIlB"}
@@ -0,0 +1,116 @@
1
+ import { semanticQuerySyntaxErrors } from './semantic-query-syntax.js';
2
+ import { validateFilterValue } from '../validation.js';
3
+ import { queryTimezoneErrors } from './query-timezone.js';
4
+ import { protocolMetricCapabilityErrors } from './protocol-metric-capabilities.js';
5
+ import { unsupportedTimeGrainError } from './dataset-time-grains.js';
6
+ import { getMetricGrain, getMetricRef, isTenantScopedFilter } from './metric-handle.js';
7
+ import { validateTenantRuntime } from './tenant-runtime.js';
8
+ import { isQualifiedField, resolveQualifiedField } from './relationship-fields.js';
9
+ import { validateQualifiedFilter, validateRelationshipTenantRuntime } from './relationship-validation.js';
10
+ import { segmentSelectionErrors } from './segments.js';
11
+ export function validateMetricQueryInput(metric, query, context) {
12
+ const errors = [...semanticQuerySyntaxErrors(query), ...protocolMetricCapabilityErrors(metric, query), ...queryTimezoneErrors(query.timezone)];
13
+ const ref = getMetricRef(metric);
14
+ const ds = ref.dataset;
15
+ const dimensionNames = Object.keys(ds.dimensions);
16
+ const filterNames = Object.keys(ds.filters);
17
+ const grain = getMetricGrain(metric, query);
18
+ const orderableFields = new Set([
19
+ ...(query.dimensions ?? []),
20
+ ref.name,
21
+ ...(grain ? ['period'] : []),
22
+ ]);
23
+ const tenantRuntimeError = validateTenantRuntime(ds, context);
24
+ if (tenantRuntimeError) {
25
+ errors.push(tenantRuntimeError);
26
+ }
27
+ const relationshipTenantError = validateRelationshipTenantRuntime(ds, query, context);
28
+ if (relationshipTenantError) {
29
+ errors.push(relationshipTenantError);
30
+ }
31
+ if (metric.__type === 'grained_metric_ref' && query.by && query.by !== metric.grain) {
32
+ errors.push(`Metric "${ref.name}" is already grained by "${metric.grain}" and cannot be queried with by="${query.by}".`);
33
+ }
34
+ // Validate dimensions
35
+ for (const dim of query.dimensions ?? []) {
36
+ if (isQualifiedField(dim)) {
37
+ const resolution = resolveQualifiedField(ds, dim);
38
+ if (resolution?.error) {
39
+ errors.push(resolution.error);
40
+ }
41
+ continue;
42
+ }
43
+ if (!dimensionNames.includes(dim)) {
44
+ errors.push(`Unknown dimension "${dim}". Available: ${dimensionNames.join(', ')}`);
45
+ }
46
+ }
47
+ // Validate filters
48
+ for (const filter of query.filters ?? []) {
49
+ if (isQualifiedField(filter.field)) {
50
+ const filterError = validateQualifiedFilter(ds, filter, context);
51
+ if (filterError) {
52
+ errors.push(filterError);
53
+ }
54
+ continue;
55
+ }
56
+ if (!filterNames.includes(filter.field)) {
57
+ errors.push(`Unknown filter field "${filter.field}". Available: ${filterNames.join(', ')}`);
58
+ continue;
59
+ }
60
+ const filterDefinition = ds.filters[filter.field];
61
+ if (filterDefinition?.operators && !filterDefinition.operators.includes(filter.operator)) {
62
+ errors.push(`Filter "${filter.field}" does not allow operator "${filter.operator}". Allowed: ${filterDefinition.operators.join(', ')}`);
63
+ continue;
64
+ }
65
+ const resolvedField = ds.filters[filter.field]?.field ?? filter.field;
66
+ if (isTenantScopedFilter(ds, filter, context)) {
67
+ errors.push(`Cannot filter on tenant field "${filter.field}" when runtime tenancy enforcement is active.`);
68
+ continue;
69
+ }
70
+ const fieldType = ds.dimensions[resolvedField]?.fieldType;
71
+ if (fieldType) {
72
+ const filterError = validateFilterValue(filter, fieldType);
73
+ if (filterError) {
74
+ errors.push(filterError);
75
+ }
76
+ }
77
+ }
78
+ // Validate order by fields against the metric output shape
79
+ for (const order of query.orderBy ?? []) {
80
+ if (!orderableFields.has(order.field)) {
81
+ errors.push(`Unknown orderBy field "${order.field}". Available: ${Array.from(orderableFields).join(', ')}`);
82
+ }
83
+ }
84
+ // Validate grain requires timeKey
85
+ if (query.by && !ds.timeKey) {
86
+ errors.push(`Cannot use "by" grain — dataset "${ds.name}" has no timeKey.`);
87
+ }
88
+ // Validate grain is one the planner can bucket on
89
+ errors.push(...segmentSelectionErrors(ds, query.segments));
90
+ const grainError = query.by && ds.timeKey ? unsupportedTimeGrainError(ds, query.by) : undefined;
91
+ if (grainError) {
92
+ errors.push(grainError);
93
+ }
94
+ if (query.limit != null && (!Number.isInteger(query.limit) || query.limit < 0)) {
95
+ errors.push(`Invalid limit: expected a non-negative integer.`);
96
+ }
97
+ if (query.offset != null && (!Number.isInteger(query.offset) || query.offset < 0)) {
98
+ errors.push(`Invalid offset: expected a non-negative integer.`);
99
+ }
100
+ // Validate limits
101
+ if (ds.limits) {
102
+ if (ds.limits.maxDimensions && (query.dimensions?.length ?? 0) > ds.limits.maxDimensions) {
103
+ errors.push(`Too many dimensions (${query.dimensions?.length}). Max: ${ds.limits.maxDimensions}`);
104
+ }
105
+ if (ds.limits.maxMeasures && 1 > ds.limits.maxMeasures) {
106
+ errors.push(`Too many measures (1). Max: ${ds.limits.maxMeasures}`);
107
+ }
108
+ if (ds.limits.maxFilters && (query.filters?.length ?? 0) > ds.limits.maxFilters) {
109
+ errors.push(`Too many filters (${query.filters?.length}). Max: ${ds.limits.maxFilters}`);
110
+ }
111
+ if (ds.limits.maxResultSize && query.limit != null && query.limit > ds.limits.maxResultSize) {
112
+ errors.push(`Too many results requested (${query.limit}). Max: ${ds.limits.maxResultSize}`);
113
+ }
114
+ }
115
+ return { valid: errors.length === 0, errors };
116
+ }
@@ -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,5 @@
1
+ import type { RelationshipKey } from '../types.js';
2
+ import type { DatasetSqlDialect } from '../dataset-sql-dialect.js';
1
3
  /**
2
4
  * Query-builder-path planning for to-one relationship joins.
3
5
  *
@@ -16,6 +18,8 @@ export interface ResolvedBuilderJoin {
16
18
  relationship: string;
17
19
  /** Physical target source table. */
18
20
  source: string;
21
+ matchMarker?: string;
22
+ keys?: readonly RelationshipKey[];
19
23
  /** Base join column (unqualified). */
20
24
  from: string;
21
25
  /** Target join column (unqualified). */
@@ -30,13 +34,13 @@ export interface RelationshipBuilderContext {
30
34
  joins: ResolvedBuilderJoin[];
31
35
  joinByRelationship: Map<string, ResolvedBuilderJoin>;
32
36
  }
33
- type QueryLike = Pick<DatasetQuery & MetricQuery, 'dimensions' | 'filters' | 'orderBy'>;
37
+ type QueryLike = Pick<DatasetQuery & MetricQuery, 'dimensions' | 'filters' | 'orderBy'> & Pick<DatasetQuery, 'measures'>;
34
38
  /**
35
39
  * Builds the join context for a query, or returns undefined when the query
36
40
  * references no relationship-qualified fields. Validation runs first, so any
37
41
  * qualified name that fails to resolve here is skipped defensively.
38
42
  */
39
- export declare function buildRelationshipBuilderContext(ds: AnyDatasetInstance, query: QueryLike, context?: ExecutionContext): RelationshipBuilderContext | undefined;
43
+ export declare function buildRelationshipBuilderContext(ds: AnyDatasetInstance, query: QueryLike, context?: ExecutionContext, dialect?: DatasetSqlDialect): RelationshipBuilderContext | undefined;
40
44
  /** Qualifies a base physical column with the base source when joins are active. */
41
45
  export declare function qualifyBaseColumn(ctx: RelationshipBuilderContext | undefined, column: string): string;
42
46
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"relationship-builder-plan.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-builder-plan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;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,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,CAAC;AAExF;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,SAAS,EAChB,OAAO,CAAC,EAAE,gBAAgB,GACzB,0BAA0B,GAAG,SAAS,CAuCxC;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,13 +1,7 @@
1
- /**
2
- * Query-builder-path planning for to-one relationship joins.
3
- *
4
- * When a dataset/metric query references relationship-qualified fields
5
- * (`<relationship>.<field>`), the builder path aliases the joined target with
6
- * the relationship name, table-qualifies base columns with the base source
7
- * name, and aliases joined selections back to their quoted qualified name. When
8
- * no qualified field is referenced, no context is produced and the builder path
9
- * behaves exactly as before.
10
- */
1
+ import { relationshipKeys } from './relationship-keys.js';
2
+ import { relationshipMeasureSource } from './relationship-measure-sql.js';
3
+ import { clickhouseDatasetSqlDialect } from './clickhouse-dataset-sql-dialect.js';
4
+ import { resolveRelationshipReference } from './relationship-measures.js';
11
5
  import { getRuntimeTenantPredicate } from './tenant-runtime.js';
12
6
  import { isQualifiedField, resolveQualifiedField } from './relationship-fields.js';
13
7
  /**
@@ -15,9 +9,10 @@ import { isQualifiedField, resolveQualifiedField } from './relationship-fields.j
15
9
  * references no relationship-qualified fields. Validation runs first, so any
16
10
  * qualified name that fails to resolve here is skipped defensively.
17
11
  */
18
- export function buildRelationshipBuilderContext(ds, query, context) {
12
+ export function buildRelationshipBuilderContext(ds, query, context, dialect = clickhouseDatasetSqlDialect) {
19
13
  const referenced = [
20
14
  ...(query.dimensions ?? []),
15
+ ...(query.measures ?? []),
21
16
  ...(query.filters ?? []).map((filter) => filter.field),
22
17
  ...(query.orderBy ?? []).map((order) => order.field),
23
18
  ].filter(isQualifiedField);
@@ -27,7 +22,7 @@ export function buildRelationshipBuilderContext(ds, query, context) {
27
22
  const tenantPredicate = getRuntimeTenantPredicate(context);
28
23
  const joinByRelationship = new Map();
29
24
  for (const name of referenced) {
30
- const resolution = resolveQualifiedField(ds, name);
25
+ const resolution = resolveRelationshipReference(ds, name, query.measures);
31
26
  if (!resolution || !resolution.resolved) {
32
27
  continue;
33
28
  }
@@ -35,11 +30,18 @@ export function buildRelationshipBuilderContext(ds, query, context) {
35
30
  if (joinByRelationship.has(relationshipName)) {
36
31
  continue;
37
32
  }
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;
38
38
  joinByRelationship.set(relationshipName, {
39
39
  relationship: relationshipName,
40
- source: target.source,
40
+ source: projected?.source ?? target.source,
41
+ matchMarker: projected?.marker,
41
42
  from: relationship.from,
42
43
  to: relationship.to,
44
+ keys,
43
45
  tenant: tenantPredicate && target.tenantKey
44
46
  ? { field: target.tenantKey, ...tenantPredicate }
45
47
  : undefined,
@@ -88,13 +90,20 @@ export function applyRelationshipJoins(qb, ctx) {
88
90
  'would fan out duplicate target keys and inflate aggregates, so relationship-qualified fields ' +
89
91
  'are unavailable with this builder.');
90
92
  }
91
- qb = qb.leftAnyJoin(join.source, `${ctx.baseSource}.${join.from}`, `${join.relationship}.${join.to}`, join.relationship, join.tenant
93
+ const tenant = join.tenant
92
94
  ? {
93
95
  column: `${join.relationship}.${join.tenant.field}`,
94
96
  operator: join.tenant.operator,
95
97
  value: join.tenant.value,
96
98
  }
97
- : 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);
98
107
  }
99
108
  return qb;
100
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
+ }