@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.
- package/dist/aggregations.d.ts +12 -0
- package/dist/aggregations.d.ts.map +1 -1
- package/dist/aggregations.js +32 -0
- package/dist/api.type-test.js +6 -0
- package/dist/catalog.d.ts +8 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +18 -6
- package/dist/contract.d.ts +7 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +5 -1
- package/dist/dataset-query.d.ts.map +1 -1
- package/dist/dataset-query.js +8 -5
- package/dist/dataset.d.ts +4 -3
- package/dist/dataset.d.ts.map +1 -1
- package/dist/dataset.js +5 -4
- package/dist/executor.d.ts +7 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +39 -10
- package/dist/field.d.ts +4 -4
- package/dist/field.d.ts.map +1 -1
- package/dist/in-memory-backend.d.ts +5 -0
- package/dist/in-memory-backend.d.ts.map +1 -1
- package/dist/in-memory-backend.js +118 -2
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/measure.d.ts +20 -0
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +51 -0
- package/dist/query-builder-protocol.d.ts +22 -0
- package/dist/query-builder-protocol.d.ts.map +1 -1
- package/dist/query-planner.d.ts +7 -6
- package/dist/query-planner.d.ts.map +1 -1
- package/dist/query-planner.js +98 -37
- package/dist/relationships.d.ts +9 -9
- package/dist/relationships.d.ts.map +1 -1
- package/dist/semantic-plan.d.ts +54 -9
- package/dist/semantic-plan.d.ts.map +1 -1
- package/dist/semantic-plan.js +9 -0
- package/dist/semantic-planner.d.ts.map +1 -1
- package/dist/semantic-planner.js +73 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +29 -18
- package/dist/types.d.ts +38 -13
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/dataset-normalization.d.ts +1 -1
- package/dist/utils/dataset-normalization.d.ts.map +1 -1
- package/dist/utils/dataset-normalization.js +13 -1
- package/dist/utils/dataset-query-validation.d.ts.map +1 -1
- package/dist/utils/dataset-query-validation.js +50 -12
- package/dist/utils/dataset-validation.d.ts.map +1 -1
- package/dist/utils/dataset-validation.js +21 -1
- package/dist/utils/filtered-aggregation-sql.d.ts +2 -1
- package/dist/utils/filtered-aggregation-sql.d.ts.map +1 -1
- package/dist/utils/filtered-aggregation-sql.js +13 -4
- package/dist/utils/relationship-builder-plan.d.ts +56 -0
- package/dist/utils/relationship-builder-plan.d.ts.map +1 -0
- package/dist/utils/relationship-builder-plan.js +92 -0
- package/dist/utils/relationship-fields.d.ts +62 -0
- package/dist/utils/relationship-fields.d.ts.map +1 -0
- package/dist/utils/relationship-fields.js +98 -0
- package/dist/utils/relationship-validation.d.ts +15 -0
- package/dist/utils/relationship-validation.d.ts.map +1 -0
- package/dist/utils/relationship-validation.js +29 -0
- 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;
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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.
|
|
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;
|
|
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
|
|
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;
|
|
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
|
+
}
|