@hypequery/datasets 0.8.0 → 0.10.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 (65) hide show
  1. package/dist/aggregations.d.ts +12 -0
  2. package/dist/aggregations.d.ts.map +1 -1
  3. package/dist/aggregations.js +32 -0
  4. package/dist/api.type-test.js +6 -0
  5. package/dist/catalog.d.ts +8 -1
  6. package/dist/catalog.d.ts.map +1 -1
  7. package/dist/catalog.js +18 -6
  8. package/dist/contract.d.ts +7 -1
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js +5 -1
  11. package/dist/dataset-query.d.ts.map +1 -1
  12. package/dist/dataset-query.js +8 -5
  13. package/dist/dataset.d.ts +4 -3
  14. package/dist/dataset.d.ts.map +1 -1
  15. package/dist/dataset.js +5 -4
  16. package/dist/executor.d.ts +7 -1
  17. package/dist/executor.d.ts.map +1 -1
  18. package/dist/executor.js +39 -10
  19. package/dist/field.d.ts +4 -4
  20. package/dist/field.d.ts.map +1 -1
  21. package/dist/in-memory-backend.d.ts +5 -0
  22. package/dist/in-memory-backend.d.ts.map +1 -1
  23. package/dist/in-memory-backend.js +118 -2
  24. package/dist/index.d.ts +4 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -2
  27. package/dist/measure.d.ts +20 -0
  28. package/dist/measure.d.ts.map +1 -1
  29. package/dist/measure.js +51 -0
  30. package/dist/query-builder-protocol.d.ts +22 -0
  31. package/dist/query-builder-protocol.d.ts.map +1 -1
  32. package/dist/query-planner.d.ts +7 -6
  33. package/dist/query-planner.d.ts.map +1 -1
  34. package/dist/query-planner.js +98 -37
  35. package/dist/relationships.d.ts +9 -9
  36. package/dist/relationships.d.ts.map +1 -1
  37. package/dist/semantic-plan.d.ts +54 -9
  38. package/dist/semantic-plan.d.ts.map +1 -1
  39. package/dist/semantic-plan.js +9 -0
  40. package/dist/semantic-planner.d.ts.map +1 -1
  41. package/dist/semantic-planner.js +73 -5
  42. package/dist/tools.d.ts.map +1 -1
  43. package/dist/tools.js +29 -18
  44. package/dist/types.d.ts +38 -13
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/utils/dataset-normalization.d.ts +1 -1
  47. package/dist/utils/dataset-normalization.d.ts.map +1 -1
  48. package/dist/utils/dataset-normalization.js +13 -1
  49. package/dist/utils/dataset-query-validation.d.ts.map +1 -1
  50. package/dist/utils/dataset-query-validation.js +50 -12
  51. package/dist/utils/dataset-validation.d.ts.map +1 -1
  52. package/dist/utils/dataset-validation.js +21 -1
  53. package/dist/utils/filtered-aggregation-sql.d.ts +2 -1
  54. package/dist/utils/filtered-aggregation-sql.d.ts.map +1 -1
  55. package/dist/utils/filtered-aggregation-sql.js +13 -4
  56. package/dist/utils/relationship-builder-plan.d.ts +56 -0
  57. package/dist/utils/relationship-builder-plan.d.ts.map +1 -0
  58. package/dist/utils/relationship-builder-plan.js +92 -0
  59. package/dist/utils/relationship-fields.d.ts +62 -0
  60. package/dist/utils/relationship-fields.d.ts.map +1 -0
  61. package/dist/utils/relationship-fields.js +98 -0
  62. package/dist/utils/relationship-validation.d.ts +15 -0
  63. package/dist/utils/relationship-validation.d.ts.map +1 -0
  64. package/dist/utils/relationship-validation.js +29 -0
  65. package/package.json +1 -1
@@ -18,7 +18,17 @@ export function normalizeFilters(dimensions, filters) {
18
18
  export function normalizeMeasures(measures) {
19
19
  return (measures ?? {});
20
20
  }
21
- export function normalizeRelationships(relationships) {
21
+ export function normalizeRelationships(relationships, source) {
22
+ for (const name of Object.keys(relationships ?? {})) {
23
+ if (name === source) {
24
+ throw new Error(`Invalid relationship "${name}": the name matches the dataset source table, so the join ` +
25
+ 'alias would shadow the base table and produce ambiguous SQL. Rename the relationship.');
26
+ }
27
+ if (name.includes('.')) {
28
+ throw new Error(`Invalid relationship "${name}": relationship names cannot contain "." because qualified ` +
29
+ 'fields are addressed as "<relationship>.<dimension>".');
30
+ }
31
+ }
22
32
  return (relationships ?? {});
23
33
  }
24
34
  export function measureToAggregationSpec(measureName, definition) {
@@ -29,6 +39,8 @@ export function measureToAggregationSpec(measureName, definition) {
29
39
  __type: 'aggregation_spec',
30
40
  aggregation: definition.aggregation,
31
41
  field: definition.field,
42
+ argField: definition.argField,
43
+ level: definition.level,
32
44
  sql: definition.sql,
33
45
  filters: definition.filters,
34
46
  };
@@ -1 +1 @@
1
- {"version":3,"file":"dataset-query-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-query-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAO9E,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CAmHlB"}
1
+ {"version":3,"file":"dataset-query-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-query-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EACjB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAuB,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAY9E,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,gBAAgB,GACzB,gBAAgB,CA0JlB"}
@@ -1,6 +1,8 @@
1
1
  import { validateFilterValue } from '../validation.js';
2
2
  import { SUPPORTED_TIME_GRAINS, isSupportedTimeGrain } from '../constants.js';
3
3
  import { getRuntimeTenantPredicate, validateTenantRuntime, } from './tenant-runtime.js';
4
+ import { isQualifiedField, resolveQualifiedField, } from './relationship-fields.js';
5
+ import { validateQualifiedFilter } from './relationship-validation.js';
4
6
  export function validateDatasetQueryInput(ds, query, context) {
5
7
  const errors = [];
6
8
  const dimensionNames = Object.keys(ds.dimensions);
@@ -21,23 +23,43 @@ export function validateDatasetQueryInput(ds, query, context) {
21
23
  errors.push(`Dataset "${ds.name}" query must select at least one dimension or measure.`);
22
24
  }
23
25
  if (query.dimensions) {
24
- const invalid = query.dimensions.filter(dimension => !dimensionNames.includes(dimension));
25
- if (invalid.length > 0) {
26
- errors.push(`Unknown dimensions: ${invalid.join(', ')}. Available: ${dimensionNames.join(', ')}`);
26
+ for (const dimension of query.dimensions) {
27
+ if (isQualifiedField(dimension)) {
28
+ const resolution = resolveQualifiedField(ds, dimension);
29
+ if (resolution?.error) {
30
+ errors.push(resolution.error);
31
+ }
32
+ continue;
33
+ }
34
+ if (!dimensionNames.includes(dimension)) {
35
+ errors.push(`Unknown dimensions: ${dimension}. Available: ${dimensionNames.join(', ')}`);
36
+ }
27
37
  }
28
38
  }
29
39
  if (query.measures) {
30
- const invalid = query.measures.filter(measure => !measureNames.includes(measure));
31
- if (invalid.length > 0) {
32
- errors.push(`Unknown measures: ${invalid.join(', ')}. Available: ${measureNames.join(', ')}`);
40
+ for (const measure of query.measures) {
41
+ if (isQualifiedField(measure)) {
42
+ errors.push(`Measure "${measure}" is relationship-qualified. Measures can only be defined on the base dataset "${ds.name}", not traversed through relationships.`);
43
+ continue;
44
+ }
45
+ if (!measureNames.includes(measure)) {
46
+ errors.push(`Unknown measures: ${measure}. Available: ${measureNames.join(', ')}`);
47
+ }
33
48
  }
34
49
  }
35
50
  if (query.filters) {
36
- const invalid = query.filters.filter(filter => !filterNames.includes(filter.field));
37
- if (invalid.length > 0) {
38
- errors.push(`Unknown filter fields: ${invalid.map(filter => filter.field).join(', ')}. Available: ${filterNames.join(', ')}`);
39
- }
40
51
  for (const filter of query.filters) {
52
+ if (isQualifiedField(filter.field)) {
53
+ const filterError = validateQualifiedFilter(ds, filter, context);
54
+ if (filterError) {
55
+ errors.push(filterError);
56
+ }
57
+ continue;
58
+ }
59
+ if (!filterNames.includes(filter.field)) {
60
+ errors.push(`Unknown filter fields: ${filter.field}. Available: ${filterNames.join(', ')}`);
61
+ continue;
62
+ }
41
63
  const filterDefinition = ds.filters[filter.field];
42
64
  if (filterDefinition?.operators && !filterDefinition.operators.includes(filter.operator)) {
43
65
  errors.push(`Filter "${filter.field}" does not allow operator "${filter.operator}". Allowed: ${filterDefinition.operators.join(', ')}`);
@@ -63,9 +85,25 @@ export function validateDatasetQueryInput(ds, query, context) {
63
85
  }
64
86
  }
65
87
  if (query.orderBy) {
66
- const invalid = query.orderBy.filter(order => !orderableFields.has(order.field));
88
+ const invalid = [];
89
+ for (const order of query.orderBy) {
90
+ if (isQualifiedField(order.field)) {
91
+ const resolution = resolveQualifiedField(ds, order.field);
92
+ if (resolution?.error) {
93
+ errors.push(resolution.error);
94
+ continue;
95
+ }
96
+ // A resolvable qualified field is only orderable when it is also
97
+ // selected as a dimension (same rule as unqualified fields). Otherwise
98
+ // the sort silently no-ops in-memory and breaks the SQL alias, so fall
99
+ // through to the orderableFields check rather than accepting it.
100
+ }
101
+ if (!orderableFields.has(order.field)) {
102
+ invalid.push(order.field);
103
+ }
104
+ }
67
105
  if (invalid.length > 0) {
68
- errors.push(`Unknown orderBy fields: ${invalid.map(order => order.field).join(', ')}. Available: ${Array.from(orderableFields).join(', ')}`);
106
+ errors.push(`Unknown orderBy fields: ${invalid.join(', ')}. Available: ${Array.from(orderableFields).join(', ')}`);
69
107
  }
70
108
  }
71
109
  if (query.by && !ds.timeKey) {
@@ -1 +1 @@
1
- {"version":3,"file":"dataset-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAKrB,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAE,GACvC,IAAI,CAwCN;AAED,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,mBAAmB,CAAC,MAAM,CAAC,GAClC,IAAI,CAmBN"}
1
+ {"version":3,"file":"dataset-validation.d.ts","sourceRoot":"","sources":["../../src/utils/dataset-validation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EACpB,MAAM,aAAa,CAAC;AAYrB,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,eAAe,EACrB,OAAO,CAAC,EAAE;IAAE,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAAE,GACvC,IAAI,CA0DN;AAED,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,kBAAkB,EACtB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,mBAAmB,CAAC,MAAM,CAAC,GAClC,IAAI,CAmBN"}
@@ -1,12 +1,32 @@
1
1
  import { validateFilterValue } from '../validation.js';
2
+ import { validatePercentileLevel } from '../measure.js';
2
3
  const NUMERIC_FIELD_TYPES = new Set(['number']);
4
+ // Aggregations whose output tracks the input field's type, so a metric
5
+ // (contract valueType: 'number') needs a numeric input dimension.
6
+ const NUMERIC_INPUT_AGGREGATIONS = new Set([
7
+ 'sum', 'avg', 'percentile', 'stddev', 'variance', 'argMax', 'argMin',
8
+ ]);
3
9
  export function validateBaseMetric(ds, metricName, spec, options) {
10
+ if (spec.aggregation === 'argMax' || spec.aggregation === 'argMin') {
11
+ if (typeof spec.argField !== 'string' || spec.argField.trim().length === 0) {
12
+ throw new Error(`Invalid metric "${metricName}": ${spec.aggregation}() requires a non-empty argField ("by" column).`);
13
+ }
14
+ if (spec.filters?.length) {
15
+ throw new Error(`Invalid metric "${metricName}": measure filters are not supported on ${spec.aggregation} aggregations.`);
16
+ }
17
+ }
18
+ if (spec.aggregation === 'percentile') {
19
+ if (spec.level == null) {
20
+ throw new Error(`Invalid metric "${metricName}": percentile() requires a level.`);
21
+ }
22
+ validatePercentileLevel(spec.level);
23
+ }
4
24
  const dimension = ds.dimensions[spec.field];
5
25
  if (!dimension && !options?.allowHiddenField) {
6
26
  throw new Error(`Invalid metric "${metricName}": dimension "${spec.field}" does not exist on dataset "${ds.name}".`);
7
27
  }
8
28
  if (dimension &&
9
- (spec.aggregation === 'sum' || spec.aggregation === 'avg') &&
29
+ NUMERIC_INPUT_AGGREGATIONS.has(spec.aggregation) &&
10
30
  !NUMERIC_FIELD_TYPES.has(dimension.fieldType)) {
11
31
  throw new Error(`Invalid metric "${metricName}": ${spec.aggregation}() requires a numeric dimension, but "${spec.field}" is ${dimension.fieldType}.`);
12
32
  }
@@ -1,5 +1,6 @@
1
1
  import type { AggregationSpec, AnyDatasetInstance } from '../types.js';
2
+ import type { RelationshipBuilderContext } from './relationship-builder-plan.js';
2
3
  type DatasetShape = AnyDatasetInstance;
3
- export declare function applyFilteredAggregationExpression(ds: DatasetShape, spec: AggregationSpec, fieldOrExpr: string): string;
4
+ export declare function applyFilteredAggregationExpression(ds: DatasetShape, spec: AggregationSpec, fieldOrExpr: string, joinCtx?: RelationshipBuilderContext): string;
4
5
  export {};
5
6
  //# sourceMappingURL=filtered-aggregation-sql.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"filtered-aggregation-sql.d.ts","sourceRoot":"","sources":["../../src/utils/filtered-aggregation-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAgB,MAAM,aAAa,CAAC;AAGrF,KAAK,YAAY,GAAG,kBAAkB,CAAC;AAiEvC,wBAAgB,kCAAkC,CAChD,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,eAAe,EACrB,WAAW,EAAE,MAAM,GAClB,MAAM,CAsBR"}
1
+ {"version":3,"file":"filtered-aggregation-sql.d.ts","sourceRoot":"","sources":["../../src/utils/filtered-aggregation-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAgB,MAAM,aAAa,CAAC;AAErF,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAEjF,KAAK,YAAY,GAAG,kBAAkB,CAAC;AAkEvC,wBAAgB,kCAAkC,CAChD,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,eAAe,EACrB,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,0BAA0B,GACnC,MAAM,CA+BR"}
@@ -17,8 +17,8 @@ function renderMeasureFilterLiteral(value) {
17
17
  }
18
18
  throw new Error(`Unsupported literal type in measure filter: ${typeof value}`);
19
19
  }
20
- function renderMeasureFilterCondition(ds, filter) {
21
- const field = resolveFilterField(ds, filter.field);
20
+ function renderMeasureFilterCondition(ds, filter, joinCtx) {
21
+ const field = resolveFilterField(ds, filter.field, joinCtx);
22
22
  switch (filter.operator) {
23
23
  case 'eq':
24
24
  return `${field} = ${renderMeasureFilterLiteral(filter.value)}`;
@@ -50,23 +50,32 @@ function renderMeasureFilterCondition(ds, filter) {
50
50
  }
51
51
  }
52
52
  }
53
- export function applyFilteredAggregationExpression(ds, spec, fieldOrExpr) {
53
+ export function applyFilteredAggregationExpression(ds, spec, fieldOrExpr, joinCtx) {
54
54
  if (!spec.filters?.length) {
55
55
  return fieldOrExpr;
56
56
  }
57
57
  const combinedCondition = spec.filters
58
- .map(filter => renderMeasureFilterCondition(ds, filter))
58
+ .map(filter => renderMeasureFilterCondition(ds, filter, joinCtx))
59
59
  .map(condition => `(${condition})`)
60
60
  .join(' AND ');
61
61
  switch (spec.aggregation) {
62
62
  case 'sum':
63
63
  return `if(${combinedCondition}, ${fieldOrExpr}, 0)`;
64
+ // Aggregate functions skip NULLs, so the NULL fallback excludes
65
+ // non-matching rows from percentile/stddev/variance too.
64
66
  case 'count':
65
67
  case 'countDistinct':
66
68
  case 'avg':
67
69
  case 'min':
68
70
  case 'max':
71
+ case 'percentile':
72
+ case 'stddev':
73
+ case 'variance':
69
74
  return `if(${combinedCondition}, ${fieldOrExpr}, NULL)`;
75
+ case 'argMax':
76
+ case 'argMin':
77
+ // NULL handling under argMax/argMin varies across ClickHouse versions.
78
+ throw new Error(`Measure filters are not supported on ${spec.aggregation} aggregations.`);
70
79
  default:
71
80
  return fieldOrExpr;
72
81
  }
@@ -0,0 +1,56 @@
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
+ */
11
+ import type { AnyDatasetInstance, DatasetQuery, ExecutionContext, MetricQuery } from '../types.js';
12
+ import type { QueryBuilderLike } from '../query-builder-protocol.js';
13
+ import { type TenantPredicate } from './tenant-runtime.js';
14
+ export interface ResolvedBuilderJoin {
15
+ /** Relationship name, used as the joined table's SQL alias. */
16
+ relationship: string;
17
+ /** Physical target source table. */
18
+ source: string;
19
+ /** Base join column (unqualified). */
20
+ from: string;
21
+ /** Target join column (unqualified). */
22
+ to: string;
23
+ /** Tenant predicate applied to the joined target, when active. */
24
+ tenant?: TenantPredicate & {
25
+ field: string;
26
+ };
27
+ }
28
+ export interface RelationshipBuilderContext {
29
+ baseSource: string;
30
+ joins: ResolvedBuilderJoin[];
31
+ joinByRelationship: Map<string, ResolvedBuilderJoin>;
32
+ }
33
+ type QueryLike = Pick<DatasetQuery & MetricQuery, 'dimensions' | 'filters' | 'orderBy'>;
34
+ /**
35
+ * Builds the join context for a query, or returns undefined when the query
36
+ * references no relationship-qualified fields. Validation runs first, so any
37
+ * qualified name that fails to resolve here is skipped defensively.
38
+ */
39
+ export declare function buildRelationshipBuilderContext(ds: AnyDatasetInstance, query: QueryLike, context?: ExecutionContext): RelationshipBuilderContext | undefined;
40
+ /** Qualifies a base physical column with the base source when joins are active. */
41
+ export declare function qualifyBaseColumn(ctx: RelationshipBuilderContext | undefined, column: string): string;
42
+ /**
43
+ * Resolves a relationship-qualified field to its SQL column reference
44
+ * (`<relationship>.<targetColumn>`). Throws if the name cannot be resolved,
45
+ * which should be unreachable after validation.
46
+ */
47
+ export declare function resolveQualifiedColumn(ds: AnyDatasetInstance, name: string): string;
48
+ /**
49
+ * Applies each relationship LEFT JOIN to the builder and, when runtime tenancy
50
+ * is active on a target, scopes the joined rows with the tenant predicate.
51
+ * Builders that support single-match joins (`leftAnyJoin`, e.g. ClickHouse
52
+ * `LEFT ANY JOIN`) get them so duplicate target keys cannot fan out aggregates.
53
+ */
54
+ export declare function applyRelationshipJoins(qb: QueryBuilderLike, ctx: RelationshipBuilderContext | undefined): QueryBuilderLike;
55
+ export {};
56
+ //# sourceMappingURL=relationship-builder-plan.d.ts.map
@@ -0,0 +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;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,gBAAgB,EACpB,GAAG,EAAE,0BAA0B,GAAG,SAAS,GAC1C,gBAAgB,CAqBlB"}
@@ -0,0 +1,92 @@
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
+ */
11
+ import { getRuntimeTenantPredicate } from './tenant-runtime.js';
12
+ import { isQualifiedField, resolveQualifiedField } from './relationship-fields.js';
13
+ /**
14
+ * Builds the join context for a query, or returns undefined when the query
15
+ * references no relationship-qualified fields. Validation runs first, so any
16
+ * qualified name that fails to resolve here is skipped defensively.
17
+ */
18
+ export function buildRelationshipBuilderContext(ds, query, context) {
19
+ const referenced = [
20
+ ...(query.dimensions ?? []),
21
+ ...(query.filters ?? []).map((filter) => filter.field),
22
+ ...(query.orderBy ?? []).map((order) => order.field),
23
+ ].filter(isQualifiedField);
24
+ if (referenced.length === 0) {
25
+ return undefined;
26
+ }
27
+ const tenantPredicate = getRuntimeTenantPredicate(context);
28
+ const joinByRelationship = new Map();
29
+ for (const name of referenced) {
30
+ const resolution = resolveQualifiedField(ds, name);
31
+ if (!resolution || !resolution.resolved) {
32
+ continue;
33
+ }
34
+ const { relationshipName, relationship, target } = resolution.resolved;
35
+ if (joinByRelationship.has(relationshipName)) {
36
+ continue;
37
+ }
38
+ joinByRelationship.set(relationshipName, {
39
+ relationship: relationshipName,
40
+ source: target.source,
41
+ from: relationship.from,
42
+ to: relationship.to,
43
+ tenant: tenantPredicate && target.tenantKey
44
+ ? { field: target.tenantKey, ...tenantPredicate }
45
+ : undefined,
46
+ });
47
+ }
48
+ return {
49
+ baseSource: ds.source,
50
+ joins: Array.from(joinByRelationship.values()),
51
+ joinByRelationship,
52
+ };
53
+ }
54
+ /** Qualifies a base physical column with the base source when joins are active. */
55
+ export function qualifyBaseColumn(ctx, column) {
56
+ return ctx ? `${ctx.baseSource}.${column}` : column;
57
+ }
58
+ /**
59
+ * Resolves a relationship-qualified field to its SQL column reference
60
+ * (`<relationship>.<targetColumn>`). Throws if the name cannot be resolved,
61
+ * which should be unreachable after validation.
62
+ */
63
+ export function resolveQualifiedColumn(ds, name) {
64
+ const resolution = resolveQualifiedField(ds, name);
65
+ if (!resolution || !resolution.resolved) {
66
+ throw new Error(resolution?.error ?? `Cannot resolve relationship field "${name}".`);
67
+ }
68
+ const { relationshipName, targetColumn } = resolution.resolved;
69
+ return `${relationshipName}.${targetColumn}`;
70
+ }
71
+ /**
72
+ * Applies each relationship LEFT JOIN to the builder and, when runtime tenancy
73
+ * is active on a target, scopes the joined rows with the tenant predicate.
74
+ * Builders that support single-match joins (`leftAnyJoin`, e.g. ClickHouse
75
+ * `LEFT ANY JOIN`) get them so duplicate target keys cannot fan out aggregates.
76
+ */
77
+ export function applyRelationshipJoins(qb, ctx) {
78
+ if (!ctx) {
79
+ return qb;
80
+ }
81
+ for (const join of ctx.joins) {
82
+ const applyJoin = qb.leftAnyJoin?.bind(qb) ?? qb.leftJoin.bind(qb);
83
+ qb = applyJoin(join.source, `${ctx.baseSource}.${join.from}`, `${join.relationship}.${join.to}`, join.relationship, join.tenant
84
+ ? {
85
+ column: `${join.relationship}.${join.tenant.field}`,
86
+ operator: join.tenant.operator,
87
+ value: join.tenant.value,
88
+ }
89
+ : undefined);
90
+ }
91
+ return qb;
92
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Resolution helpers for relationship-qualified field names (`relationship.field`).
3
+ *
4
+ * v1 supports one hop over to-one relationships (`belongsTo`, `hasOne`) only,
5
+ * addressing dimensions declared on the target dataset. `hasMany` stays
6
+ * metadata-only to avoid fan-out corruption of aggregates. See
7
+ * `plans/relationship-aware-semantics-design.md`.
8
+ */
9
+ import type { AnyDatasetInstance, DimensionDefinition, RelationshipDefinition } from '../types.js';
10
+ /**
11
+ * Enumerates the queryable qualified field names (`<name>.<dimension>`) a
12
+ * relationship contributes, applying the same rules `resolveQualifiedField`
13
+ * enforces at query time: `hasMany` contributes nothing and SQL-backed target
14
+ * dimensions are excluded. Keep the two in sync — the catalog, contract, and
15
+ * generated input schemas all advertise exactly this list.
16
+ */
17
+ export declare function listQueryableRelationshipFields(name: string, relationship: RelationshipDefinition): string[];
18
+ export interface ParsedQualifiedField {
19
+ /** The relationship name (prefix before the first dot). */
20
+ relationship: string;
21
+ /** Everything after the first dot — the target field, possibly multi-hop. */
22
+ field: string;
23
+ }
24
+ /** True when a field name is relationship-qualified (contains a dot). */
25
+ export declare function isQualifiedField(name: string): boolean;
26
+ /**
27
+ * Splits a qualified name into its relationship prefix and remaining field.
28
+ * Returns null when `name` is not qualified (no dot), so callers can treat it
29
+ * as a local field.
30
+ */
31
+ export declare function parseQualifiedField(name: string): ParsedQualifiedField | null;
32
+ export interface ResolvedQualifiedField {
33
+ /** Relationship name, also used as the SQL table alias / qualified prefix. */
34
+ relationshipName: string;
35
+ relationship: RelationshipDefinition;
36
+ /** The resolved target dataset instance. */
37
+ target: AnyDatasetInstance;
38
+ /** Target dimension name (the allowlist key on the target). */
39
+ targetDimensionName: string;
40
+ targetDimension: DimensionDefinition;
41
+ /** Physical column backing the target dimension. */
42
+ targetColumn: string;
43
+ /** The qualified name exactly as referenced, e.g. `customer.country`. */
44
+ qualifiedName: string;
45
+ }
46
+ export type QualifiedFieldResolution = {
47
+ resolved: ResolvedQualifiedField;
48
+ error?: undefined;
49
+ } | {
50
+ resolved?: undefined;
51
+ error: string;
52
+ };
53
+ /**
54
+ * Resolves a relationship-qualified field name against a base dataset.
55
+ *
56
+ * Returns null when `name` is not qualified (caller handles it as a local
57
+ * field). Otherwise returns either the resolved to-one target dimension or an
58
+ * actionable validation error (unknown relationship, multi-hop, `hasMany`,
59
+ * unknown target dimension, or SQL-backed target dimension).
60
+ */
61
+ export declare function resolveQualifiedField(ds: AnyDatasetInstance, name: string): QualifiedFieldResolution | null;
62
+ //# sourceMappingURL=relationship-fields.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-fields.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-fields.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,sBAAsB,GACnC,MAAM,EAAE,CAQV;AAED,MAAM,WAAW,oBAAoB;IACnC,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,yEAAyE;AACzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAM7E;AAED,MAAM,WAAW,sBAAsB;IACrC,8EAA8E;IAC9E,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,sBAAsB,CAAC;IACrC,4CAA4C;IAC5C,MAAM,EAAE,kBAAkB,CAAC;IAC3B,+DAA+D;IAC/D,mBAAmB,EAAE,MAAM,CAAC;IAC5B,eAAe,EAAE,mBAAmB,CAAC;IACrC,oDAAoD;IACpD,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,wBAAwB,GAChC;IAAE,QAAQ,EAAE,sBAAsB,CAAC;IAAC,KAAK,CAAC,EAAE,SAAS,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5C;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,MAAM,GACX,wBAAwB,GAAG,IAAI,CAuDjC"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Resolution helpers for relationship-qualified field names (`relationship.field`).
3
+ *
4
+ * v1 supports one hop over to-one relationships (`belongsTo`, `hasOne`) only,
5
+ * addressing dimensions declared on the target dataset. `hasMany` stays
6
+ * metadata-only to avoid fan-out corruption of aggregates. See
7
+ * `plans/relationship-aware-semantics-design.md`.
8
+ */
9
+ /**
10
+ * Enumerates the queryable qualified field names (`<name>.<dimension>`) a
11
+ * relationship contributes, applying the same rules `resolveQualifiedField`
12
+ * enforces at query time: `hasMany` contributes nothing and SQL-backed target
13
+ * dimensions are excluded. Keep the two in sync — the catalog, contract, and
14
+ * generated input schemas all advertise exactly this list.
15
+ */
16
+ export function listQueryableRelationshipFields(name, relationship) {
17
+ if (relationship.kind === 'hasMany') {
18
+ return [];
19
+ }
20
+ const target = relationship.target();
21
+ return Object.entries(target?.dimensions ?? {})
22
+ .filter(([, dimension]) => !dimension.sql)
23
+ .map(([field]) => `${name}.${field}`);
24
+ }
25
+ /** True when a field name is relationship-qualified (contains a dot). */
26
+ export function isQualifiedField(name) {
27
+ return name.includes('.');
28
+ }
29
+ /**
30
+ * Splits a qualified name into its relationship prefix and remaining field.
31
+ * Returns null when `name` is not qualified (no dot), so callers can treat it
32
+ * as a local field.
33
+ */
34
+ export function parseQualifiedField(name) {
35
+ const dot = name.indexOf('.');
36
+ if (dot === -1) {
37
+ return null;
38
+ }
39
+ return { relationship: name.slice(0, dot), field: name.slice(dot + 1) };
40
+ }
41
+ /**
42
+ * Resolves a relationship-qualified field name against a base dataset.
43
+ *
44
+ * Returns null when `name` is not qualified (caller handles it as a local
45
+ * field). Otherwise returns either the resolved to-one target dimension or an
46
+ * actionable validation error (unknown relationship, multi-hop, `hasMany`,
47
+ * unknown target dimension, or SQL-backed target dimension).
48
+ */
49
+ export function resolveQualifiedField(ds, name) {
50
+ const parsed = parseQualifiedField(name);
51
+ if (!parsed) {
52
+ return null;
53
+ }
54
+ const { relationship: relationshipName, field } = parsed;
55
+ const relationship = ds.relationships[relationshipName];
56
+ if (!relationship) {
57
+ const available = Object.keys(ds.relationships);
58
+ return {
59
+ error: available.length > 0
60
+ ? `Unknown relationship "${relationshipName}" in field "${name}" on dataset "${ds.name}". Available relationships: ${available.join(', ')}.`
61
+ : `Unknown field "${name}": dataset "${ds.name}" declares no relationships.`,
62
+ };
63
+ }
64
+ if (field.includes('.')) {
65
+ return {
66
+ error: `Multi-hop relationship path "${name}" is not supported. Only a single hop is allowed (e.g. "${relationshipName}.<field>").`,
67
+ };
68
+ }
69
+ if (relationship.kind === 'hasMany') {
70
+ return {
71
+ error: `Relationship "${relationshipName}" on dataset "${ds.name}" is a hasMany (to-many) relationship and is not queryable, because joining it would fan out and corrupt aggregates. It remains available as metadata only.`,
72
+ };
73
+ }
74
+ const target = relationship.target();
75
+ const targetDimension = target.dimensions[field];
76
+ if (!targetDimension) {
77
+ const available = Object.keys(target.dimensions);
78
+ return {
79
+ error: `Unknown dimension "${field}" on relationship "${relationshipName}" (target dataset "${target.name}"). Available: ${available.join(', ')}.`,
80
+ };
81
+ }
82
+ if (targetDimension.sql) {
83
+ return {
84
+ error: `Dimension "${name}" is SQL-backed on target dataset "${target.name}"; SQL-backed dimensions are not yet queryable through relationships.`,
85
+ };
86
+ }
87
+ return {
88
+ resolved: {
89
+ relationshipName,
90
+ relationship,
91
+ target,
92
+ targetDimensionName: field,
93
+ targetDimension,
94
+ targetColumn: targetDimension.column ?? field,
95
+ qualifiedName: name,
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared validation for relationship-qualified filters, used by both the
3
+ * dataset-query and metric-query validators.
4
+ */
5
+ import type { AnyDatasetInstance, ExecutionContext, MetricFilter } from '../types.js';
6
+ /**
7
+ * Validates a relationship-qualified filter (`relationship.field`). Returns an
8
+ * error message string, or null when the filter is valid.
9
+ *
10
+ * Enforces the same tenant rule the base dataset uses: when runtime tenancy is
11
+ * active and the joined target declares a `tenantKey`, an explicit filter on
12
+ * that column is rejected (the planner injects the tenant predicate instead).
13
+ */
14
+ export declare function validateQualifiedFilter(ds: AnyDatasetInstance, filter: MetricFilter, context?: ExecutionContext): string | null;
15
+ //# sourceMappingURL=relationship-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relationship-validation.d.ts","sourceRoot":"","sources":["../../src/utils/relationship-validation.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,gBAAgB,EAChB,YAAY,EACb,MAAM,aAAa,CAAC;AAKrB;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,kBAAkB,EACtB,MAAM,EAAE,YAAY,EACpB,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,GAAG,IAAI,CAgBf"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared validation for relationship-qualified filters, used by both the
3
+ * dataset-query and metric-query validators.
4
+ */
5
+ import { validateFilterValue } from '../validation.js';
6
+ import { getRuntimeTenantPredicate } from './tenant-runtime.js';
7
+ import { resolveQualifiedField } from './relationship-fields.js';
8
+ /**
9
+ * Validates a relationship-qualified filter (`relationship.field`). Returns an
10
+ * error message string, or null when the filter is valid.
11
+ *
12
+ * Enforces the same tenant rule the base dataset uses: when runtime tenancy is
13
+ * active and the joined target declares a `tenantKey`, an explicit filter on
14
+ * that column is rejected (the planner injects the tenant predicate instead).
15
+ */
16
+ export function validateQualifiedFilter(ds, filter, context) {
17
+ const resolution = resolveQualifiedField(ds, filter.field);
18
+ if (!resolution) {
19
+ return null;
20
+ }
21
+ if (!resolution.resolved) {
22
+ return resolution.error;
23
+ }
24
+ const { target, targetColumn, targetDimension } = resolution.resolved;
25
+ if (getRuntimeTenantPredicate(context) && target.tenantKey && targetColumn === target.tenantKey) {
26
+ return `Cannot filter on tenant field "${filter.field}" when runtime tenancy enforcement is active.`;
27
+ }
28
+ return validateFilterValue(filter, targetDimension.fieldType);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/datasets",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Semantic layer for defining datasets, metrics, and semantic queries",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",