@objectstack/service-analytics 17.0.0-rc.2 → 17.0.0-rc.4
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/CHANGELOG.md +1539 -0
- package/dist/index.cjs +1063 -167
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +322 -13
- package/dist/index.d.ts +322 -13
- package/dist/index.js +1057 -161
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -118,6 +118,14 @@ var CubeRegistry = class {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
// src/strategies/filter-normalizer.ts
|
|
121
|
+
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from "@objectstack/spec/data";
|
|
122
|
+
import { StandardErrorCode } from "@objectstack/spec/api";
|
|
123
|
+
function invalidFilterError(message) {
|
|
124
|
+
const err = new Error(message);
|
|
125
|
+
err.code = StandardErrorCode.enum.INVALID_FILTER;
|
|
126
|
+
err.status = 400;
|
|
127
|
+
return err;
|
|
128
|
+
}
|
|
121
129
|
var MONGO_TO_CUBE_OP = {
|
|
122
130
|
$eq: "equals",
|
|
123
131
|
$ne: "notEquals",
|
|
@@ -132,12 +140,21 @@ var MONGO_TO_CUBE_OP = {
|
|
|
132
140
|
$startsWith: "startsWith",
|
|
133
141
|
$endsWith: "endsWith"
|
|
134
142
|
};
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
function comparand(v) {
|
|
144
|
+
return v === void 0 ? null : v;
|
|
145
|
+
}
|
|
146
|
+
var SQL_CONST_FALSE = "1 = 0";
|
|
147
|
+
var SQL_CONST_TRUE = "1 = 1";
|
|
148
|
+
function falseNode() {
|
|
149
|
+
return { kind: "const", value: false };
|
|
150
|
+
}
|
|
151
|
+
function notOf(inner) {
|
|
152
|
+
if (!inner) return falseNode();
|
|
153
|
+
if (inner.kind === "const") return { kind: "const", value: !inner.value };
|
|
154
|
+
return { kind: "not", child: inner };
|
|
155
|
+
}
|
|
156
|
+
function isFilterObject(v) {
|
|
157
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
141
158
|
}
|
|
142
159
|
function andOf(children) {
|
|
143
160
|
if (children.length === 0) return null;
|
|
@@ -155,18 +172,23 @@ function fieldLeaves(key, raw) {
|
|
|
155
172
|
}
|
|
156
173
|
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
157
174
|
const wrapper = raw;
|
|
175
|
+
if (Object.keys(wrapper).length === 0) {
|
|
176
|
+
throw invalidFilterError(
|
|
177
|
+
`[analytics] "${key}" carries a field constraint with zero operators ({}). Refusing rather than reading it as "every row" or "no row" \u2014 #5240 ruled this shape refused on every backend.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
158
180
|
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
159
181
|
if (opKeys.length > 0) {
|
|
160
182
|
for (const opKey of opKeys) {
|
|
161
183
|
if (opKey === "$between") {
|
|
162
184
|
const v2 = wrapper[opKey];
|
|
163
185
|
if (!Array.isArray(v2) || v2.length !== 2) {
|
|
164
|
-
throw
|
|
186
|
+
throw invalidFilterError(
|
|
165
187
|
`[analytics] "$between" on "${key}" needs a two-element [min, max] array, got ${JSON.stringify(v2)}. Dropping the predicate would silently widen the query to every row.`
|
|
166
188
|
);
|
|
167
189
|
}
|
|
168
|
-
leaf("gte", [
|
|
169
|
-
leaf("lte", [
|
|
190
|
+
leaf("gte", [comparand(v2[0])]);
|
|
191
|
+
leaf("lte", [comparand(v2[1])]);
|
|
170
192
|
continue;
|
|
171
193
|
}
|
|
172
194
|
if (opKey === "$null" || opKey === "$exists") {
|
|
@@ -174,14 +196,33 @@ function fieldLeaves(key, raw) {
|
|
|
174
196
|
leaf(isNull ? "notSet" : "set", []);
|
|
175
197
|
continue;
|
|
176
198
|
}
|
|
199
|
+
if ((opKey === "$eq" || opKey === "$ne") && wrapper[opKey] === null) {
|
|
200
|
+
leaf(opKey === "$eq" ? "notSet" : "set", []);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if ((opKey === "$in" || opKey === "$nin") && Array.isArray(wrapper[opKey]) && wrapper[opKey].length === 0) {
|
|
204
|
+
out.push({ kind: "const", value: opKey === "$nin" });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
177
207
|
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
178
208
|
if (!cubeOp) {
|
|
179
|
-
throw
|
|
209
|
+
throw invalidFilterError(
|
|
180
210
|
`[analytics] Unsupported filter operator "${opKey}" on "${key}". Supported: ${Object.keys(MONGO_TO_CUBE_OP).join(", ")}, $between, $null, $exists, and the $and/$or/$not combinators. Dropping it would silently widen the query to rows the filter excludes.`
|
|
181
211
|
);
|
|
182
212
|
}
|
|
183
213
|
const v = wrapper[opKey];
|
|
184
|
-
|
|
214
|
+
const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
|
|
215
|
+
if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
|
|
216
|
+
out.push({
|
|
217
|
+
kind: "or",
|
|
218
|
+
children: [
|
|
219
|
+
{ kind: "leaf", member: key, operator: "notSet", values: [] },
|
|
220
|
+
{ kind: "leaf", member: key, operator: cubeOp, values }
|
|
221
|
+
]
|
|
222
|
+
});
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
leaf(cubeOp, values);
|
|
185
226
|
}
|
|
186
227
|
return out;
|
|
187
228
|
}
|
|
@@ -190,8 +231,10 @@ function fieldLeaves(key, raw) {
|
|
|
190
231
|
}
|
|
191
232
|
return out;
|
|
192
233
|
}
|
|
193
|
-
if (Array.isArray(raw))
|
|
194
|
-
|
|
234
|
+
if (Array.isArray(raw)) {
|
|
235
|
+
if (raw.length === 0) out.push({ kind: "const", value: false });
|
|
236
|
+
else leaf("in", raw.map(comparand));
|
|
237
|
+
} else leaf("equals", [comparand(raw)]);
|
|
195
238
|
return out;
|
|
196
239
|
}
|
|
197
240
|
function buildNode(cond) {
|
|
@@ -199,24 +242,42 @@ function buildNode(cond) {
|
|
|
199
242
|
for (const [key, raw] of Object.entries(cond)) {
|
|
200
243
|
if (raw === void 0) continue;
|
|
201
244
|
if (key === "$and" || key === "$or") {
|
|
202
|
-
if (!Array.isArray(raw)
|
|
203
|
-
throw
|
|
204
|
-
`[analytics] "${key}" requires
|
|
245
|
+
if (!Array.isArray(raw)) {
|
|
246
|
+
throw invalidFilterError(
|
|
247
|
+
`[analytics] "${key}" requires an array of filter objects, got ${JSON.stringify(raw)}. Dropping it would silently widen the query to rows the filter excludes.`
|
|
205
248
|
);
|
|
206
249
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
250
|
+
if (raw.length === 0) {
|
|
251
|
+
if (key === "$or") children.push(falseNode());
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const branches = raw.map((sub) => {
|
|
255
|
+
if (!isFilterObject(sub)) {
|
|
256
|
+
throw invalidFilterError(
|
|
257
|
+
`[analytics] "${key}" branches must be filter objects, got ${JSON.stringify(sub)}. Skipping it would silently change which rows the filter admits.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return buildNode(sub);
|
|
261
|
+
});
|
|
262
|
+
if (key === "$or" && branches.some((n) => n === null)) continue;
|
|
263
|
+
const kept = branches.filter((n) => n !== null);
|
|
264
|
+
if (kept.length === 0) continue;
|
|
265
|
+
if (key === "$and") children.push(...kept);
|
|
266
|
+
else children.push(kept.length === 1 ? kept[0] : { kind: "or", children: kept });
|
|
211
267
|
continue;
|
|
212
268
|
}
|
|
213
269
|
if (key === "$not") {
|
|
214
|
-
|
|
215
|
-
|
|
270
|
+
if (!isFilterObject(raw)) {
|
|
271
|
+
throw invalidFilterError(
|
|
272
|
+
`[analytics] "$not" requires a filter object, got ${JSON.stringify(raw)}. Dropping it would silently widen the query to rows the filter excludes.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const inner = buildNode(nullSafeNegationOperand(raw));
|
|
276
|
+
children.push(notOf(inner));
|
|
216
277
|
continue;
|
|
217
278
|
}
|
|
218
279
|
if (key.startsWith("$")) {
|
|
219
|
-
throw
|
|
280
|
+
throw invalidFilterError(
|
|
220
281
|
`[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
|
|
221
282
|
);
|
|
222
283
|
}
|
|
@@ -224,43 +285,194 @@ function buildNode(cond) {
|
|
|
224
285
|
}
|
|
225
286
|
return andOf(children);
|
|
226
287
|
}
|
|
227
|
-
function
|
|
288
|
+
function nullValueSatisfiesOperator(op, value) {
|
|
289
|
+
switch (op) {
|
|
290
|
+
// [#5332] `$eq: null` IS the null predicate — a NULL column satisfies it,
|
|
291
|
+
// and no other comparand does.
|
|
292
|
+
case "$eq":
|
|
293
|
+
return value === null;
|
|
294
|
+
// Mirror image: `$ne: null` compiles to `set` (`IS NOT NULL`), which a NULL
|
|
295
|
+
// column FAILS. Any other comparand is the two-valued JS `!==`, which an
|
|
296
|
+
// absent value passes — the arm this used to be for every comparand.
|
|
297
|
+
case "$ne":
|
|
298
|
+
return value !== null;
|
|
299
|
+
case "$null":
|
|
300
|
+
return value === true;
|
|
301
|
+
case "$exists":
|
|
302
|
+
return value === false;
|
|
303
|
+
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
304
|
+
case "$nin":
|
|
305
|
+
return true;
|
|
306
|
+
// `$notContains` is the one operator where the two JS backends disagree for
|
|
307
|
+
// a null-valued field (`driver-memory` answers false, `formula` true).
|
|
308
|
+
// `formula` is followed because `driver-sql` and `read-scope-sql` follow it,
|
|
309
|
+
// so this module casts no vote on a disagreement that is filed elsewhere.
|
|
310
|
+
case "$notContains":
|
|
311
|
+
return true;
|
|
312
|
+
default:
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function operatorIsNullTotal(op, value) {
|
|
317
|
+
switch (op) {
|
|
318
|
+
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
319
|
+
// construction, on every strategy that compiles this tree.
|
|
320
|
+
case "$null":
|
|
321
|
+
case "$exists":
|
|
322
|
+
return true;
|
|
323
|
+
// [#5332] A `null` comparand makes these null PREDICATES too — `notSet` /
|
|
324
|
+
// `set`, not comparisons — so they are total by construction and take NO
|
|
325
|
+
// guard. Left out, `{$not: {stage: {$eq: null}}}` wrapped `stage IS NOT NULL
|
|
326
|
+
// AND stage IS NULL` (an always-false conjunction) and negated it to EVERY
|
|
327
|
+
// row, for a filter meaning "stage is not empty".
|
|
328
|
+
case "$eq":
|
|
329
|
+
case "$ne":
|
|
330
|
+
return value === null;
|
|
331
|
+
// An EMPTY set compiles to a boolean CONSTANT (see `fieldLeaves`), and a
|
|
332
|
+
// constant is total. Wrapping a guard around it would only add a redundant
|
|
333
|
+
// conjunct to a predicate whose value is already decided.
|
|
334
|
+
case "$in":
|
|
335
|
+
case "$nin":
|
|
336
|
+
return Array.isArray(value) && value.length === 0;
|
|
337
|
+
default:
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function nullGuardForFieldSpec(spec) {
|
|
342
|
+
if (spec === null) return "none";
|
|
343
|
+
if (Array.isArray(spec)) return spec.length === 0 ? "none" : "requireValue";
|
|
344
|
+
if (typeof spec !== "object" || spec instanceof Date) return "requireValue";
|
|
345
|
+
const entries = Object.entries(spec);
|
|
346
|
+
if (entries.length === 0) return "none";
|
|
347
|
+
let total = true;
|
|
348
|
+
let nullSatisfies = true;
|
|
349
|
+
for (const [op, value] of entries) {
|
|
350
|
+
if (!operatorIsNullTotal(op, value)) total = false;
|
|
351
|
+
if (!nullValueSatisfiesOperator(op, value)) nullSatisfies = false;
|
|
352
|
+
}
|
|
353
|
+
if (total) return "none";
|
|
354
|
+
return nullSatisfies ? "allowNull" : "requireValue";
|
|
355
|
+
}
|
|
356
|
+
function guardFieldEntry(key, spec, out, guarded) {
|
|
357
|
+
if (isFilterObject(spec) && Object.keys(spec).length > 0 && !Object.keys(spec).some((k) => k.startsWith("$"))) {
|
|
358
|
+
for (const [nested, value] of Object.entries(spec)) {
|
|
359
|
+
guardFieldEntry(`${key}.${nested}`, value, out, guarded);
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const guard = nullGuardForFieldSpec(spec);
|
|
364
|
+
if (guard === "none") {
|
|
365
|
+
out[key] = spec;
|
|
366
|
+
} else if (guard === "requireValue") {
|
|
367
|
+
guarded.push({ [key]: { $null: false } }, { [key]: spec });
|
|
368
|
+
} else {
|
|
369
|
+
guarded.push({ $or: [{ [key]: { $null: true } }, { [key]: spec }] });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function nullSafeNegationOperand(node) {
|
|
373
|
+
const out = {};
|
|
374
|
+
const guarded = [];
|
|
375
|
+
for (const [key, value] of Object.entries(node)) {
|
|
376
|
+
if ((key === "$and" || key === "$or") && Array.isArray(value)) {
|
|
377
|
+
out[key] = value.map((element) => isFilterObject(element) ? nullSafeNegationOperand(element) : element);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (key.startsWith("$")) {
|
|
381
|
+
out[key] = value;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
guardFieldEntry(key, value, out, guarded);
|
|
385
|
+
}
|
|
386
|
+
if (guarded.length > 0) {
|
|
387
|
+
const existing = Array.isArray(out.$and) ? out.$and : [];
|
|
388
|
+
out.$and = [...existing, ...guarded];
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
function filterArrayNotLowerableError(where) {
|
|
393
|
+
return invalidFilterError(
|
|
394
|
+
`[analytics] received a 'where' array that is not a filter: ${JSON.stringify(where)}. A filter array is a comparison [field, operator, value], a logical node ["and"|"or", ...conditions], or a list of those \u2014 it is INPUT-ONLY sugar (spec 'FilterArray'), lowered to a FilterCondition by @objectstack/spec parseFilterAST() at every door, this one included (#5158/#5334). This value cannot be lowered, and an unapplied filter would have charted the UNFILTERED dataset. Recognised operators: ${[...VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
function lowerAnalyticsWhere(query) {
|
|
228
398
|
if (!query || typeof query !== "object") return null;
|
|
229
399
|
const where = query.where;
|
|
230
|
-
if (!where || typeof where !== "object"
|
|
231
|
-
|
|
400
|
+
if (!where || typeof where !== "object") return null;
|
|
401
|
+
if (Array.isArray(where)) {
|
|
402
|
+
if (where.length === 0) return null;
|
|
403
|
+
if (!isFilterAST(where)) throw filterArrayNotLowerableError(where);
|
|
404
|
+
const condition = parseFilterAST(where);
|
|
405
|
+
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
406
|
+
throw invalidFilterError(
|
|
407
|
+
`[analytics] filter array ${JSON.stringify(where)} passed isFilterAST() but parseFilterAST() lowered it to ${JSON.stringify(condition)}. Refusing rather than charting the dataset unfiltered (#5158/#5334).`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return condition;
|
|
411
|
+
}
|
|
412
|
+
return where;
|
|
413
|
+
}
|
|
414
|
+
function conjunctFieldKeys(condition) {
|
|
415
|
+
const keys = [];
|
|
416
|
+
const walk = (cond) => {
|
|
417
|
+
for (const [key, value] of Object.entries(cond)) {
|
|
418
|
+
if (key === "$and" && Array.isArray(value)) {
|
|
419
|
+
for (const child of value) {
|
|
420
|
+
if (isFilterObject(child)) walk(child);
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (key.startsWith("$")) continue;
|
|
425
|
+
keys.push(key);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
walk(condition);
|
|
429
|
+
return keys;
|
|
430
|
+
}
|
|
431
|
+
function normalizeAnalyticsFilterTree(query) {
|
|
432
|
+
const condition = lowerAnalyticsWhere(query);
|
|
433
|
+
if (!condition) return null;
|
|
434
|
+
return buildNode(condition);
|
|
232
435
|
}
|
|
233
436
|
function collectFilterLeaves(node) {
|
|
234
437
|
if (!node) return [];
|
|
235
438
|
if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
|
|
439
|
+
if (node.kind === "const") return [];
|
|
236
440
|
if (node.kind === "not") return collectFilterLeaves(node.child);
|
|
237
441
|
return node.children.flatMap(collectFilterLeaves);
|
|
238
442
|
}
|
|
239
|
-
function
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return void 0;
|
|
443
|
+
function toSqlBindValue(v) {
|
|
444
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
445
|
+
if (v instanceof Date) return v.toISOString();
|
|
446
|
+
if (v !== null && typeof v === "object") return JSON.stringify(v);
|
|
447
|
+
return v;
|
|
245
448
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return
|
|
449
|
+
|
|
450
|
+
// src/like-pattern.ts
|
|
451
|
+
var LIKE_ESCAPE_CHAR = "\\";
|
|
452
|
+
function escapeLikePattern(value) {
|
|
453
|
+
return String(value).replace(/[\\%_]/g, "\\$&");
|
|
251
454
|
}
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (s === "null") return null;
|
|
256
|
-
return recoverNumber(s) ?? s;
|
|
455
|
+
function likePattern(shape, value) {
|
|
456
|
+
const escaped = escapeLikePattern(value);
|
|
457
|
+
return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
|
|
257
458
|
}
|
|
258
459
|
|
|
259
460
|
// src/read-scope-sql.ts
|
|
260
461
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
462
|
+
var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
|
|
463
|
+
function readScopeCompileError(message) {
|
|
464
|
+
const err = new Error(message);
|
|
465
|
+
err.code = READ_SCOPE_COMPILE_FAILED;
|
|
466
|
+
err.status = 500;
|
|
467
|
+
return err;
|
|
468
|
+
}
|
|
469
|
+
var FALSE_CLAUSE = "1 = 0";
|
|
470
|
+
function isFilterNode(v) {
|
|
471
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
472
|
+
}
|
|
261
473
|
function quoteIdent(name, kind) {
|
|
262
474
|
if (typeof name !== "string" || !IDENT.test(name)) {
|
|
263
|
-
throw
|
|
475
|
+
throw readScopeCompileError(`[read-scope-sql] unsafe ${kind} identifier "${String(name)}" \u2014 refusing to build read scope (fail-closed).`);
|
|
264
476
|
}
|
|
265
477
|
return `"${name}"`;
|
|
266
478
|
}
|
|
@@ -270,25 +482,43 @@ function compileScopedFilterToSql(filter, alias) {
|
|
|
270
482
|
const sql = compileNode(filter, quotedAlias, params);
|
|
271
483
|
return { sql, params };
|
|
272
484
|
}
|
|
485
|
+
function compileSub(node, qAlias) {
|
|
486
|
+
const params = [];
|
|
487
|
+
const sql = compileNode(node, qAlias, params);
|
|
488
|
+
return { sql, params };
|
|
489
|
+
}
|
|
273
490
|
function compileNode(node, qAlias, params) {
|
|
274
|
-
if (
|
|
275
|
-
throw
|
|
491
|
+
if (!isFilterNode(node)) {
|
|
492
|
+
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
276
493
|
}
|
|
277
494
|
const clauses = [];
|
|
278
495
|
for (const [key, value] of Object.entries(node)) {
|
|
279
496
|
if (key === "$and" || key === "$or") {
|
|
280
|
-
if (!Array.isArray(value)
|
|
281
|
-
throw
|
|
497
|
+
if (!Array.isArray(value)) {
|
|
498
|
+
throw readScopeCompileError(`[read-scope-sql] "${key}" requires an array (fail-closed).`);
|
|
499
|
+
}
|
|
500
|
+
if (value.length === 0) {
|
|
501
|
+
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
502
|
+
continue;
|
|
282
503
|
}
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
504
|
+
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
505
|
+
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
506
|
+
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
507
|
+
if (kept.length === 0) continue;
|
|
508
|
+
for (const part of kept) params.push(...part.params);
|
|
285
509
|
const joiner = key === "$and" ? " AND " : " OR ";
|
|
286
|
-
clauses.push(`(${
|
|
510
|
+
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
287
511
|
} else if (key === "$not") {
|
|
288
|
-
const
|
|
289
|
-
|
|
512
|
+
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
513
|
+
const inner = compileSub(operand, qAlias);
|
|
514
|
+
if (inner.sql.length === 0) {
|
|
515
|
+
clauses.push(FALSE_CLAUSE);
|
|
516
|
+
} else {
|
|
517
|
+
params.push(...inner.params);
|
|
518
|
+
clauses.push(`NOT (${inner.sql})`);
|
|
519
|
+
}
|
|
290
520
|
} else if (key.startsWith("$")) {
|
|
291
|
-
throw
|
|
521
|
+
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
292
522
|
} else {
|
|
293
523
|
clauses.push(compileField(key, value, qAlias, params));
|
|
294
524
|
}
|
|
@@ -303,12 +533,12 @@ function compileField(field, value, qAlias, params) {
|
|
|
303
533
|
return `${col} = ?`;
|
|
304
534
|
}
|
|
305
535
|
if (Array.isArray(value)) {
|
|
306
|
-
throw
|
|
536
|
+
throw readScopeCompileError(`[read-scope-sql] bare array value for "${field}" \u2014 use { $in: [...] } (fail-closed).`);
|
|
307
537
|
}
|
|
308
538
|
const ops = value;
|
|
309
539
|
const keys = Object.keys(ops);
|
|
310
540
|
if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) {
|
|
311
|
-
throw
|
|
541
|
+
throw readScopeCompileError(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`);
|
|
312
542
|
}
|
|
313
543
|
const parts = [];
|
|
314
544
|
for (const op of keys) {
|
|
@@ -320,12 +550,20 @@ function bind(params, v) {
|
|
|
320
550
|
params.push(v);
|
|
321
551
|
return "?";
|
|
322
552
|
}
|
|
553
|
+
function bindLike(params, pattern) {
|
|
554
|
+
return `${bind(params, pattern)} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
555
|
+
}
|
|
556
|
+
function nullSafeNegative(col, test) {
|
|
557
|
+
return `(${col} IS NULL OR ${test})`;
|
|
558
|
+
}
|
|
323
559
|
function compileOperator(col, op, val, field, params) {
|
|
324
560
|
switch (op) {
|
|
325
561
|
case "$eq":
|
|
326
562
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
563
|
+
// [#5298] `$ne: null` stays `IS NOT NULL` — already total, and "has any
|
|
564
|
+
// value" is false for a row that has none. Only the comparison is guarded.
|
|
327
565
|
case "$ne":
|
|
328
|
-
return val === null ? `${col} IS NOT NULL` : `${col} <> ${bind(params, val)}
|
|
566
|
+
return val === null ? `${col} IS NOT NULL` : nullSafeNegative(col, `${col} <> ${bind(params, val)}`);
|
|
329
567
|
case "$gt":
|
|
330
568
|
return `${col} > ${bind(params, val)}`;
|
|
331
569
|
case "$gte":
|
|
@@ -335,35 +573,139 @@ function compileOperator(col, op, val, field, params) {
|
|
|
335
573
|
case "$lte":
|
|
336
574
|
return `${col} <= ${bind(params, val)}`;
|
|
337
575
|
case "$in": {
|
|
338
|
-
if (!Array.isArray(val)) throw
|
|
339
|
-
if (val.length === 0) return
|
|
576
|
+
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
577
|
+
if (val.length === 0) return FALSE_CLAUSE;
|
|
340
578
|
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
341
579
|
}
|
|
342
580
|
case "$nin": {
|
|
343
|
-
if (!Array.isArray(val)) throw
|
|
581
|
+
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
344
582
|
if (val.length === 0) return "1 = 1";
|
|
345
|
-
return `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})
|
|
583
|
+
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
346
584
|
}
|
|
347
585
|
case "$between": {
|
|
348
|
-
if (!Array.isArray(val) || val.length !== 2) throw
|
|
586
|
+
if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
349
587
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
350
588
|
}
|
|
589
|
+
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
590
|
+
// character is bound with it. See {@link bindLike}.
|
|
351
591
|
case "$contains":
|
|
352
|
-
return `${col} LIKE ${
|
|
592
|
+
return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
|
|
593
|
+
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
594
|
+
// contain" is true of a value that is not there.
|
|
353
595
|
case "$notContains":
|
|
354
|
-
return `${col} NOT LIKE ${
|
|
596
|
+
return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
|
|
355
597
|
case "$startsWith":
|
|
356
|
-
return `${col} LIKE ${
|
|
598
|
+
return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
|
|
357
599
|
case "$endsWith":
|
|
358
|
-
return `${col} LIKE ${
|
|
600
|
+
return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
|
|
359
601
|
case "$null":
|
|
360
602
|
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
361
603
|
case "$exists":
|
|
362
604
|
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
363
605
|
default:
|
|
364
|
-
throw
|
|
606
|
+
throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
365
607
|
}
|
|
366
608
|
}
|
|
609
|
+
function nullValueSatisfiesOperator2(op, value) {
|
|
610
|
+
switch (op) {
|
|
611
|
+
// `$eq: null` IS the null predicate; any other comparand is a value test.
|
|
612
|
+
case "$eq":
|
|
613
|
+
return value === null;
|
|
614
|
+
// Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
|
|
615
|
+
case "$ne":
|
|
616
|
+
return value !== null;
|
|
617
|
+
// Truthiness, matching this file's emitter (see the note above).
|
|
618
|
+
case "$null":
|
|
619
|
+
return Boolean(value);
|
|
620
|
+
case "$exists":
|
|
621
|
+
return !value;
|
|
622
|
+
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
623
|
+
case "$nin":
|
|
624
|
+
return true;
|
|
625
|
+
// `$notContains` is the one operator where the two JS backends disagree for
|
|
626
|
+
// a null-valued field (`driver-memory` answers false, `formula` true).
|
|
627
|
+
// `formula` is followed because `driver-sql` follows it, so this compiler
|
|
628
|
+
// does not cast a vote on a disagreement that is filed elsewhere.
|
|
629
|
+
case "$notContains":
|
|
630
|
+
return true;
|
|
631
|
+
default:
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function operatorIsNullTotal2(op, value) {
|
|
636
|
+
switch (op) {
|
|
637
|
+
// Compile to `IS NULL` / `IS NOT NULL` — two-valued by construction.
|
|
638
|
+
case "$null":
|
|
639
|
+
case "$exists":
|
|
640
|
+
return true;
|
|
641
|
+
// A null comparand makes these null PREDICATES too, not comparisons.
|
|
642
|
+
case "$eq":
|
|
643
|
+
case "$ne":
|
|
644
|
+
return value === null;
|
|
645
|
+
default:
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function nullGuardForFieldSpec2(spec) {
|
|
650
|
+
if (spec === null) return "none";
|
|
651
|
+
if (typeof spec !== "object" || spec instanceof Date || Array.isArray(spec)) return "requireValue";
|
|
652
|
+
const entries = Object.entries(spec);
|
|
653
|
+
if (entries.length === 0) return "none";
|
|
654
|
+
let total = true;
|
|
655
|
+
let nullSatisfies = true;
|
|
656
|
+
for (const [op, value] of entries) {
|
|
657
|
+
if (!operatorIsNullTotal2(op, value)) total = false;
|
|
658
|
+
if (!nullValueSatisfiesOperator2(op, value)) nullSatisfies = false;
|
|
659
|
+
}
|
|
660
|
+
if (total) return "none";
|
|
661
|
+
return nullSatisfies ? "allowNull" : "requireValue";
|
|
662
|
+
}
|
|
663
|
+
function nullSafeNegationOperand2(node) {
|
|
664
|
+
const out = {};
|
|
665
|
+
const guarded = [];
|
|
666
|
+
for (const [key, value] of Object.entries(node)) {
|
|
667
|
+
if ((key === "$and" || key === "$or") && Array.isArray(value)) {
|
|
668
|
+
out[key] = value.map((element) => isFilterNode(element) ? nullSafeNegationOperand2(element) : element);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (key.startsWith("$")) {
|
|
672
|
+
out[key] = value;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const guard = nullGuardForFieldSpec2(value);
|
|
676
|
+
if (guard === "none") {
|
|
677
|
+
out[key] = value;
|
|
678
|
+
} else if (guard === "requireValue") {
|
|
679
|
+
guarded.push({ [key]: { $null: false } }, { [key]: value });
|
|
680
|
+
} else {
|
|
681
|
+
guarded.push({ $or: [{ [key]: { $null: true } }, { [key]: value }] });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (guarded.length > 0) {
|
|
685
|
+
const existing = Array.isArray(out.$and) ? out.$and : [];
|
|
686
|
+
out.$and = [...existing, ...guarded];
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/dataset-refusal.ts
|
|
692
|
+
var DATASET_INVALID = "DATASET_INVALID";
|
|
693
|
+
var INVALID_FIELD = "INVALID_FIELD";
|
|
694
|
+
function datasetInvalidError(message) {
|
|
695
|
+
const err = new Error(message);
|
|
696
|
+
err.code = DATASET_INVALID;
|
|
697
|
+
err.status = 400;
|
|
698
|
+
return err;
|
|
699
|
+
}
|
|
700
|
+
function invalidMemberError(message, meta) {
|
|
701
|
+
const err = new Error(message);
|
|
702
|
+
err.code = INVALID_FIELD;
|
|
703
|
+
err.status = 400;
|
|
704
|
+
err.member = meta.member;
|
|
705
|
+
if (meta.param) err.param = meta.param;
|
|
706
|
+
if (meta.cube) err.cube = meta.cube;
|
|
707
|
+
return err;
|
|
708
|
+
}
|
|
367
709
|
|
|
368
710
|
// src/strategies/native-sql-strategy.ts
|
|
369
711
|
import { nextUtcCalendarDay } from "@objectstack/core";
|
|
@@ -467,7 +809,7 @@ var NativeSQLStrategy = class {
|
|
|
467
809
|
if (allowed) {
|
|
468
810
|
for (const alias of joins.keys()) {
|
|
469
811
|
if (!allowed.has(alias)) {
|
|
470
|
-
throw
|
|
812
|
+
throw datasetInvalidError(
|
|
471
813
|
`[NativeSQLStrategy] join "${alias}" is not backed by a declared relationship on cube "${query.cube}". v1 only joins along relationships listed in the dataset's \`include\`.`
|
|
472
814
|
);
|
|
473
815
|
}
|
|
@@ -618,8 +960,9 @@ var NativeSQLStrategy = class {
|
|
|
618
960
|
const measure = this.lookupMember(cube, member, "measure");
|
|
619
961
|
if (!measure) {
|
|
620
962
|
const declared = Object.keys(cube.measures ?? {});
|
|
621
|
-
throw
|
|
622
|
-
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
|
|
963
|
+
throw invalidMemberError(
|
|
964
|
+
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)"),
|
|
965
|
+
{ member, param: "measures", cube: cube.name }
|
|
623
966
|
);
|
|
624
967
|
}
|
|
625
968
|
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
@@ -670,16 +1013,24 @@ var NativeSQLStrategy = class {
|
|
|
670
1013
|
* driver-backed `coerceTemporalFilterValue` hook (single source of truth for
|
|
671
1014
|
* the date/datetime storage convention — see StrategyContext); when the hook
|
|
672
1015
|
* is absent, or returns the value unchanged (the field is not a temporal
|
|
673
|
-
* column, or the dialect stores it as a native timestamp), falls back to
|
|
674
|
-
*
|
|
675
|
-
*
|
|
1016
|
+
* column, or the dialect stores it as a native timestamp), falls back to
|
|
1017
|
+
* {@link toSqlBindValue} so an unbindable JS type still reaches the driver as
|
|
1018
|
+
* something it can bind.
|
|
1019
|
+
*
|
|
1020
|
+
* [#5526] `value` is `unknown`, not `string`, because a leaf now carries the
|
|
1021
|
+
* author's comparand at its own type. Both halves of this method were already
|
|
1022
|
+
* `unknown`-typed for it: the hook's contract is
|
|
1023
|
+
* `coerceTemporalFilterValue(object, field, value: unknown)` and the fallback
|
|
1024
|
+
* converts only what a driver cannot bind. What CHANGED is that a string is no
|
|
1025
|
+
* longer re-typed on the way out — the fallback used to be
|
|
1026
|
+
* `coerceFilterValueForSql`, which read `'007'` as the integer `7`.
|
|
676
1027
|
*/
|
|
677
1028
|
coerceTemporal(ctx, target, value) {
|
|
678
1029
|
if (typeof ctx.coerceTemporalFilterValue === "function") {
|
|
679
1030
|
const coerced = ctx.coerceTemporalFilterValue(target.object, target.field, value);
|
|
680
1031
|
if (coerced !== value) return coerced;
|
|
681
1032
|
}
|
|
682
|
-
return
|
|
1033
|
+
return toSqlBindValue(value);
|
|
683
1034
|
}
|
|
684
1035
|
/**
|
|
685
1036
|
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
@@ -710,9 +1061,31 @@ var NativeSQLStrategy = class {
|
|
|
710
1061
|
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
711
1062
|
* being right by construction is what keeps a future edit from making it
|
|
712
1063
|
* wrong.
|
|
1064
|
+
*
|
|
1065
|
+
* # `null` is the constant TRUE, and TRUE absorbs a disjunction (#5325)
|
|
1066
|
+
*
|
|
1067
|
+
* A `null` return means "constrains nothing", which is the boolean TRUE — the
|
|
1068
|
+
* AND identity, so it drops out of an `and`, but the OR ABSORBER, so one TRUE
|
|
1069
|
+
* disjunct makes the whole `or` TRUE. Filtering it out of an `or` narrowed the
|
|
1070
|
+
* query to the surviving branches. `NOT TRUE ≡ FALSE`, so a negation whose
|
|
1071
|
+
* operand constrains nothing compiles to the FALSE constant rather than
|
|
1072
|
+
* disappearing (which added no `WHERE` and charted every row).
|
|
1073
|
+
*
|
|
1074
|
+
* # The invariant that keeps `params` aligned
|
|
1075
|
+
*
|
|
1076
|
+
* **A call that returns `null` leaves `params` exactly as it found it.** It
|
|
1077
|
+
* has to: a value bound with no `$n` to consume it shifts every later
|
|
1078
|
+
* placeholder onto the wrong value, and a filter that binds the WRONG comparand
|
|
1079
|
+
* is worse than one that is merely too wide (#5297). Leaves decide emptiness
|
|
1080
|
+
* before they bind, and the absorbing `or` — the one place a clause that HAS
|
|
1081
|
+
* bound is discarded — truncates back to the length it started at, so the
|
|
1082
|
+
* invariant holds inductively for every node kind.
|
|
713
1083
|
*/
|
|
714
1084
|
compileFilterNode(node, cube, parentTable, joins, params, ctx) {
|
|
715
1085
|
if (!node) return null;
|
|
1086
|
+
if (node.kind === "const") {
|
|
1087
|
+
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1088
|
+
}
|
|
716
1089
|
if (node.kind === "leaf") {
|
|
717
1090
|
const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
|
|
718
1091
|
const target = this.resolveStorageTarget(cube, node.member, parentTable);
|
|
@@ -720,9 +1093,22 @@ var NativeSQLStrategy = class {
|
|
|
720
1093
|
}
|
|
721
1094
|
if (node.kind === "not") {
|
|
722
1095
|
const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
|
|
723
|
-
return inner ? `NOT (${inner})` :
|
|
1096
|
+
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
1097
|
+
}
|
|
1098
|
+
const paramBase = params.length;
|
|
1099
|
+
const joinBase = new Map(joins);
|
|
1100
|
+
const parts = [];
|
|
1101
|
+
for (const child of node.children) {
|
|
1102
|
+
const clause = this.compileFilterNode(child, cube, parentTable, joins, params, ctx);
|
|
1103
|
+
if (clause === null) {
|
|
1104
|
+
if (node.kind !== "or") continue;
|
|
1105
|
+
params.length = paramBase;
|
|
1106
|
+
joins.clear();
|
|
1107
|
+
for (const [alias, clauseSql] of joinBase) joins.set(alias, clauseSql);
|
|
1108
|
+
return null;
|
|
1109
|
+
}
|
|
1110
|
+
parts.push(clause);
|
|
724
1111
|
}
|
|
725
|
-
const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
|
|
726
1112
|
if (parts.length === 0) return null;
|
|
727
1113
|
if (parts.length === 1) return parts[0];
|
|
728
1114
|
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
@@ -740,11 +1126,11 @@ var NativeSQLStrategy = class {
|
|
|
740
1126
|
startsWith: "LIKE",
|
|
741
1127
|
endsWith: "LIKE"
|
|
742
1128
|
};
|
|
743
|
-
const
|
|
744
|
-
contains:
|
|
745
|
-
notContains:
|
|
746
|
-
startsWith:
|
|
747
|
-
endsWith:
|
|
1129
|
+
const likeShape = {
|
|
1130
|
+
contains: "contains",
|
|
1131
|
+
notContains: "contains",
|
|
1132
|
+
startsWith: "starts",
|
|
1133
|
+
endsWith: "ends"
|
|
748
1134
|
};
|
|
749
1135
|
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
750
1136
|
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
@@ -758,10 +1144,12 @@ var NativeSQLStrategy = class {
|
|
|
758
1144
|
}
|
|
759
1145
|
const sqlOp = opMap[operator];
|
|
760
1146
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
761
|
-
const
|
|
762
|
-
if (
|
|
763
|
-
params.push(
|
|
764
|
-
|
|
1147
|
+
const shape = likeShape[operator];
|
|
1148
|
+
if (shape) {
|
|
1149
|
+
params.push(likePattern(shape, values[0]));
|
|
1150
|
+
const patternRef = `$${params.length}`;
|
|
1151
|
+
params.push(LIKE_ESCAPE_CHAR);
|
|
1152
|
+
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
765
1153
|
}
|
|
766
1154
|
if (operator === "lte") {
|
|
767
1155
|
const nextDay = nextUtcCalendarDay(values[0]);
|
|
@@ -860,6 +1248,12 @@ var SCALAR_SQL_OPS = {
|
|
|
860
1248
|
lt: "<",
|
|
861
1249
|
lte: "<="
|
|
862
1250
|
};
|
|
1251
|
+
var LIKE_SQL_OPS = {
|
|
1252
|
+
contains: { sql: "LIKE", shape: "contains" },
|
|
1253
|
+
notContains: { sql: "NOT LIKE", shape: "contains" },
|
|
1254
|
+
startsWith: { sql: "LIKE", shape: "starts" },
|
|
1255
|
+
endsWith: { sql: "LIKE", shape: "ends" }
|
|
1256
|
+
};
|
|
863
1257
|
var ObjectQLStrategy = class {
|
|
864
1258
|
constructor() {
|
|
865
1259
|
this.name = "ObjectQLStrategy";
|
|
@@ -1103,6 +1497,17 @@ var ObjectQLStrategy = class {
|
|
|
1103
1497
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1104
1498
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
1105
1499
|
*
|
|
1500
|
+
* [#5716] All four refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1501
|
+
* 400, naming the member — and the MESSAGES are unchanged (they are good
|
|
1502
|
+
* diagnostics, and #5923's tests read them). Each is decided by two caller-side
|
|
1503
|
+
* facts and nothing else: a member the query named, and whether that member
|
|
1504
|
+
* resolves across a join. Neither is an internal invariant — a cube where the
|
|
1505
|
+
* member exists and a driver that could serve it are both perfectly ordinary,
|
|
1506
|
+
* which is exactly what the "run this on a native-SQL driver" half of each
|
|
1507
|
+
* message says. They are member-level rather than dataset-level (hence not
|
|
1508
|
+
* `datasetInvalidError`) because the fix is always to change or drop ONE named
|
|
1509
|
+
* member, and because they fire on `/analytics/query` where no dataset exists.
|
|
1510
|
+
*
|
|
1106
1511
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1107
1512
|
* flattens to a real column is treated as base, not cross-object.
|
|
1108
1513
|
*/
|
|
@@ -1111,18 +1516,30 @@ var ObjectQLStrategy = class {
|
|
|
1111
1516
|
for (const td of query.timeDimensions ?? []) {
|
|
1112
1517
|
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1113
1518
|
if (this.isCrossObjectField(cube, field, baseObject)) {
|
|
1114
|
-
throw
|
|
1115
|
-
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}")
|
|
1519
|
+
throw invalidMemberError(
|
|
1520
|
+
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`,
|
|
1521
|
+
{ member: td.dimension, param: "timeDimensions", cube: cube.name }
|
|
1116
1522
|
);
|
|
1117
1523
|
}
|
|
1118
1524
|
}
|
|
1119
1525
|
const nonDim = [
|
|
1120
|
-
...(query.measures ?? []).map((m) => ({
|
|
1121
|
-
|
|
1526
|
+
...(query.measures ?? []).map((m) => ({
|
|
1527
|
+
where: "measure",
|
|
1528
|
+
member: m,
|
|
1529
|
+
field: this.resolveMeasureAggregation(cube, m).field
|
|
1530
|
+
})),
|
|
1531
|
+
...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
|
|
1122
1532
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1123
1533
|
if (nonDim.length > 0) {
|
|
1124
|
-
throw
|
|
1125
|
-
`[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}
|
|
1534
|
+
throw invalidMemberError(
|
|
1535
|
+
`[Analytics] ObjectQLStrategy cannot evaluate a cross-object ${nonDim[0].where} ("${nonDim[0].field}") \u2014 the engine cannot join in an aggregate. Run this query on a native-SQL driver, or remove the cross-object ${nonDim[0].where}.`,
|
|
1536
|
+
{
|
|
1537
|
+
member: nonDim[0].member,
|
|
1538
|
+
// The two kinds share one throw, so the request key follows the kind
|
|
1539
|
+
// rather than being guessed by the reader of the message.
|
|
1540
|
+
param: nonDim[0].where === "measure" ? "measures" : "where",
|
|
1541
|
+
cube: cube.name
|
|
1542
|
+
}
|
|
1126
1543
|
);
|
|
1127
1544
|
}
|
|
1128
1545
|
const crossDims = [];
|
|
@@ -1132,8 +1549,9 @@ var ObjectQLStrategy = class {
|
|
|
1132
1549
|
const [alias, ...rest] = field.split(".");
|
|
1133
1550
|
const attr = rest.join(".");
|
|
1134
1551
|
if (attr.includes(".")) {
|
|
1135
|
-
throw
|
|
1136
|
-
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship
|
|
1552
|
+
throw invalidMemberError(
|
|
1553
|
+
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`,
|
|
1554
|
+
{ member: dim, param: "dimensions", cube: cube.name }
|
|
1137
1555
|
);
|
|
1138
1556
|
}
|
|
1139
1557
|
crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
|
|
@@ -1142,8 +1560,9 @@ var ObjectQLStrategy = class {
|
|
|
1142
1560
|
for (const m of query.measures ?? []) {
|
|
1143
1561
|
const { method } = this.resolveMeasureAggregation(cube, m);
|
|
1144
1562
|
if (!RECOMBINABLE_METHODS.has(method)) {
|
|
1145
|
-
throw
|
|
1146
|
-
`[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver
|
|
1563
|
+
throw invalidMemberError(
|
|
1564
|
+
`[Analytics] ObjectQLStrategy cannot group by a cross-object dimension with a "${method}" measure ("${m}") \u2014 its value cannot be recombined across the intermediate FK grouping. Use sum/count/min/max, or run on a native-SQL driver.`,
|
|
1565
|
+
{ member: m, param: "measures", cube: cube.name }
|
|
1147
1566
|
);
|
|
1148
1567
|
}
|
|
1149
1568
|
}
|
|
@@ -1242,10 +1661,21 @@ var ObjectQLStrategy = class {
|
|
|
1242
1661
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1243
1662
|
*
|
|
1244
1663
|
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
1245
|
-
* two previews read alike, but binds
|
|
1246
|
-
* the
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1664
|
+
* two previews read alike, but binds the comparand VERBATIM: the value shown is
|
|
1665
|
+
* the one THIS path actually hands the engine (a real boolean, not SQL's 1/0).
|
|
1666
|
+
*
|
|
1667
|
+
* [#5526] "Verbatim" is now literal. This used to bind through
|
|
1668
|
+
* `coerceFilterValueForObjectQL`, which decoded the string a `string[]` leaf
|
|
1669
|
+
* carried back into a type — so an echo could show `7` for a filter the author
|
|
1670
|
+
* wrote as `'007'`. A leaf carries the author's value at its own type, so the
|
|
1671
|
+
* echo needs no conversion at all to stay honest about execution. The LIKE
|
|
1672
|
+
* family is still the one exception, for the reason `filter.zod.ts` gives: its
|
|
1673
|
+
* comparand is declared a `string`, and what binds is the PATTERN.
|
|
1674
|
+
*
|
|
1675
|
+
* `null` means "this leaf carries no predicate" — a value-less scalar leaf,
|
|
1676
|
+
* which `execute()` and `NativeSQLStrategy` drop too. It does NOT mean "I could
|
|
1677
|
+
* not render that operator": #5333 was exactly that conflation, and an
|
|
1678
|
+
* unrenderable operator now THROWS (see the exit below).
|
|
1249
1679
|
*/
|
|
1250
1680
|
buildFilterClauseSql(col, operator, values, params) {
|
|
1251
1681
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
@@ -1253,18 +1683,25 @@ var ObjectQLStrategy = class {
|
|
|
1253
1683
|
if (!values || values.length === 0) return null;
|
|
1254
1684
|
if (operator === "in" || operator === "notIn") {
|
|
1255
1685
|
const placeholders = values.map((v) => {
|
|
1256
|
-
params.push(
|
|
1686
|
+
params.push(v);
|
|
1257
1687
|
return `$${params.length}`;
|
|
1258
1688
|
}).join(", ");
|
|
1259
1689
|
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
1260
1690
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1691
|
+
const like = LIKE_SQL_OPS[operator];
|
|
1692
|
+
if (like) {
|
|
1693
|
+
params.push(likePattern(like.shape, values[0]));
|
|
1694
|
+
const patternRef = `$${params.length}`;
|
|
1695
|
+
params.push(LIKE_ESCAPE_CHAR);
|
|
1696
|
+
return `${col} ${like.sql} ${patternRef} ESCAPE $${params.length}`;
|
|
1264
1697
|
}
|
|
1265
1698
|
const op = SCALAR_SQL_OPS[operator];
|
|
1266
|
-
if (!op)
|
|
1267
|
-
|
|
1699
|
+
if (!op) {
|
|
1700
|
+
throw new Error(
|
|
1701
|
+
`[analytics] ObjectQLStrategy cannot render display SQL for filter operator "${operator}" (on "${col}"). The analytics operator vocabulary is closed \u2014 filter-normalizer.ts refuses anything it cannot map \u2014 so this means a new operator reached the normalizer without an arm here. Add one rather than dropping the predicate: an echo without it describes a WIDER query than the one that ran (#5333).`
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
params.push(values[0]);
|
|
1268
1705
|
return `${col} ${op} $${params.length}`;
|
|
1269
1706
|
}
|
|
1270
1707
|
/**
|
|
@@ -1376,16 +1813,30 @@ var ObjectQLStrategy = class {
|
|
|
1376
1813
|
const rendered = this.filterNodeToCondition(node, cube);
|
|
1377
1814
|
if (rendered) conjuncts.push(rendered);
|
|
1378
1815
|
}
|
|
1379
|
-
/**
|
|
1816
|
+
/**
|
|
1817
|
+
* A node as a standalone `FilterCondition` the engine can consume.
|
|
1818
|
+
*
|
|
1819
|
+
* `null` = no constraint, which is the boolean TRUE — the AND identity but the
|
|
1820
|
+
* OR ABSORBER, so a `null` branch makes the whole disjunction unconstrained
|
|
1821
|
+
* instead of collapsing it to its surviving branches (#5325). FALSE is handed
|
|
1822
|
+
* to the engine as `{$not: {}}`, the spelling `driver-sql`, `formula` and
|
|
1823
|
+
* `driver-memory`'s matcher all already pin as the zero-row filter (#5134) —
|
|
1824
|
+
* this strategy invents no second one.
|
|
1825
|
+
*/
|
|
1380
1826
|
filterNodeToCondition(node, cube) {
|
|
1381
1827
|
if (!node) return null;
|
|
1828
|
+
if (node.kind === "const") {
|
|
1829
|
+
return node.value ? null : { $not: {} };
|
|
1830
|
+
}
|
|
1382
1831
|
if (node.kind === "not") {
|
|
1383
1832
|
const inner = this.filterNodeToCondition(node.child, cube);
|
|
1384
|
-
return inner ? { $not: inner } :
|
|
1833
|
+
return inner ? { $not: inner } : { $not: {} };
|
|
1385
1834
|
}
|
|
1386
1835
|
if (node.kind === "or") {
|
|
1387
|
-
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube))
|
|
1388
|
-
|
|
1836
|
+
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube));
|
|
1837
|
+
if (branches.some((c) => c === null)) return null;
|
|
1838
|
+
const kept = branches.filter((c) => !!c);
|
|
1839
|
+
return kept.length > 0 ? { $or: kept } : null;
|
|
1389
1840
|
}
|
|
1390
1841
|
const filter = {};
|
|
1391
1842
|
const conjuncts = [];
|
|
@@ -1399,9 +1850,20 @@ var ObjectQLStrategy = class {
|
|
|
1399
1850
|
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
1400
1851
|
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
1401
1852
|
* browser, so a comparand is never inlined.
|
|
1853
|
+
*
|
|
1854
|
+
* The boolean identities render too (#5325). This string exists to REPRODUCE
|
|
1855
|
+
* execution: a `{$not: {}}` filter that runs as zero rows but echoes SQL with
|
|
1856
|
+
* no `WHERE` hands whoever is debugging "why is this chart empty" a statement
|
|
1857
|
+
* that returns the whole table. Same reason the absorbed `$or` branch and the
|
|
1858
|
+
* `params` truncation below match {@link NativeSQLStrategy.compileFilterNode}
|
|
1859
|
+
* exactly — including the invariant that a `null` return leaves `params`
|
|
1860
|
+
* untouched, so no comparand is left with no placeholder to consume it.
|
|
1402
1861
|
*/
|
|
1403
1862
|
renderFilterNodeSql(node, cube, params) {
|
|
1404
1863
|
if (!node) return null;
|
|
1864
|
+
if (node.kind === "const") {
|
|
1865
|
+
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1866
|
+
}
|
|
1405
1867
|
if (node.kind === "leaf") {
|
|
1406
1868
|
return this.buildFilterClauseSql(
|
|
1407
1869
|
this.resolveFieldName(cube, node.member, "any"),
|
|
@@ -1412,9 +1874,19 @@ var ObjectQLStrategy = class {
|
|
|
1412
1874
|
}
|
|
1413
1875
|
if (node.kind === "not") {
|
|
1414
1876
|
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
1415
|
-
return inner ? `NOT (${inner})` :
|
|
1877
|
+
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
1878
|
+
}
|
|
1879
|
+
const paramBase = params.length;
|
|
1880
|
+
const parts = [];
|
|
1881
|
+
for (const child of node.children) {
|
|
1882
|
+
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
1883
|
+
if (clause === null) {
|
|
1884
|
+
if (node.kind !== "or") continue;
|
|
1885
|
+
params.length = paramBase;
|
|
1886
|
+
return null;
|
|
1887
|
+
}
|
|
1888
|
+
parts.push(clause);
|
|
1416
1889
|
}
|
|
1417
|
-
const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
|
|
1418
1890
|
if (parts.length === 0) return null;
|
|
1419
1891
|
if (parts.length === 1) return parts[0];
|
|
1420
1892
|
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
@@ -1450,9 +1922,16 @@ var ObjectQLStrategy = class {
|
|
|
1450
1922
|
* performs the same half-open translation itself because it binds into raw
|
|
1451
1923
|
* SQL, so one dashboard reads the same on every driver.
|
|
1452
1924
|
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1925
|
+
* [#5526] Bounds are forwarded at the type `dateRange` is DECLARED with —
|
|
1926
|
+
* `string` (`AnalyticsQuerySchema`'s `timeDimensions[].dateRange: string[]`) —
|
|
1927
|
+
* and nothing re-types them. They used to pass through
|
|
1928
|
+
* `coerceFilterValueForObjectQL`, whose TSDoc advertised that "an epoch-ms
|
|
1929
|
+
* bound recovers as a number"; that was a lenient CONSUMER rescuing a shape the
|
|
1930
|
+
* contract does not declare, and the same guess is what read a `'007'` filter
|
|
1931
|
+
* comparand as `7` (Prime Directive #12 — the producer or the spec is where an
|
|
1932
|
+
* epoch-ms window would have to be declared, not here). An author who wants an
|
|
1933
|
+
* instant window writes it as one; a declared `string` binds as a string. No
|
|
1934
|
+
* STORAGE coercion happens here either, deliberately: `NativeSQLStrategy` needs
|
|
1456
1935
|
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1457
1936
|
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1458
1937
|
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
@@ -1479,20 +1958,34 @@ var ObjectQLStrategy = class {
|
|
|
1479
1958
|
if (start == null) continue;
|
|
1480
1959
|
out.push({
|
|
1481
1960
|
field: this.resolveFieldName(cube, td.dimension, "dimension"),
|
|
1482
|
-
bounds: {
|
|
1483
|
-
$gte: coerceFilterValueForObjectQL(String(start)),
|
|
1484
|
-
$lte: coerceFilterValueForObjectQL(String(end))
|
|
1485
|
-
}
|
|
1961
|
+
bounds: { $gte: start, $lte: end }
|
|
1486
1962
|
});
|
|
1487
1963
|
}
|
|
1488
1964
|
return out;
|
|
1489
1965
|
}
|
|
1966
|
+
/**
|
|
1967
|
+
* One leaf as the operand the engine's `FilterCondition` expects.
|
|
1968
|
+
*
|
|
1969
|
+
* [#5526] The comparand is passed through UNCONVERTED. That is the whole of
|
|
1970
|
+
* this path's share of the fix: the engine compares against the value as
|
|
1971
|
+
* STORED, and a leaf now carries the value the author wrote, so `'007'` stays
|
|
1972
|
+
* `'007'`, `true` stays `true` and `7` stays `7` with nothing in between to
|
|
1973
|
+
* re-type them. The two `coerceFilterValueForObjectQL` calls this replaced
|
|
1974
|
+
* existed only to undo `stringifyForCube`, and undoing it required guessing.
|
|
1975
|
+
*
|
|
1976
|
+
* The four LIKE-family arms are the exception, and a contract one:
|
|
1977
|
+
* `filter.zod.ts` declares `$contains` / `$notContains` / `$startsWith` /
|
|
1978
|
+
* `$endsWith` as `z.string()`, so this PRODUCER must hand the engine a real
|
|
1979
|
+
* string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
|
|
1980
|
+
* two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
|
|
1981
|
+
* `$contains` means one thing on every face (#5567's invariant).
|
|
1982
|
+
*/
|
|
1490
1983
|
convertFilter(operator, values) {
|
|
1491
1984
|
if (operator === "set") return { $ne: null };
|
|
1492
1985
|
if (operator === "notSet") return null;
|
|
1493
1986
|
if (!values || values.length === 0) return void 0;
|
|
1494
|
-
const v0 =
|
|
1495
|
-
const all = values
|
|
1987
|
+
const v0 = values[0];
|
|
1988
|
+
const all = [...values];
|
|
1496
1989
|
switch (operator) {
|
|
1497
1990
|
case "equals":
|
|
1498
1991
|
return v0;
|
|
@@ -1506,19 +1999,42 @@ var ObjectQLStrategy = class {
|
|
|
1506
1999
|
return { $lt: v0 };
|
|
1507
2000
|
case "lte":
|
|
1508
2001
|
return { $lte: v0 };
|
|
2002
|
+
// [#5557] `contains` was `{ $regex: values[0] }` — the comparand dropped
|
|
2003
|
+
// VERBATIM into a regex position while its three siblings below already
|
|
2004
|
+
// passed as canonical spec operators. Three things were wrong with that,
|
|
2005
|
+
// and none of them waits on #4706's ruling about what `$regex` should
|
|
2006
|
+
// mean:
|
|
2007
|
+
//
|
|
2008
|
+
// 1. `$regex` is not in `filter.zod.ts`'s `FILTER_OPERATORS`, so this
|
|
2009
|
+
// was a PRODUCER emitting an operator the contract does not declare
|
|
2010
|
+
// (Prime Directive #12 — fix the producer, not the consumers).
|
|
2011
|
+
// 2. `compileScopedFilterToSql` in this very package is a
|
|
2012
|
+
// `FilterCondition` consumer and fails closed on `$regex`, so one
|
|
2013
|
+
// filter tree no longer travelled between two consumers of the same
|
|
2014
|
+
// contract sitting in the same directory.
|
|
2015
|
+
// 3. On a backend that reads `$regex` as a real regex — driver-memory's
|
|
2016
|
+
// `memory-matcher.ts` does, deliberately, for plugin-auth's adapter
|
|
2017
|
+
// — an unescaped comparand changes what the author asked for:
|
|
2018
|
+
// `a.b` also matched `axb`, and `50% (+)` did not compile at all, so
|
|
2019
|
+
// the `catch { return false }` answered zero rows in silence.
|
|
2020
|
+
// `driver-sql` meanwhile compiles `$regex` to a substring LIKE, so
|
|
2021
|
+
// the same widget returned different row sets per driver.
|
|
2022
|
+
//
|
|
2023
|
+
// `MONGO_TO_CUBE_OP` maps `$contains` → `contains` and nothing else does,
|
|
2024
|
+
// so returning `$contains` here is the round trip of the author's own key.
|
|
1509
2025
|
case "contains":
|
|
1510
|
-
return { $
|
|
2026
|
+
return { $contains: String(v0) };
|
|
1511
2027
|
// `notContains` had no arm and fell to the `default` below, which returns
|
|
1512
2028
|
// a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
|
|
1513
2029
|
// was compiled as "equals x". These three pass through as the canonical
|
|
1514
2030
|
// spec operators every driver implements directly, so an anchored match
|
|
1515
2031
|
// stays anchored rather than depending on regex dialect (#4128).
|
|
1516
2032
|
case "notContains":
|
|
1517
|
-
return { $notContains:
|
|
2033
|
+
return { $notContains: String(v0) };
|
|
1518
2034
|
case "startsWith":
|
|
1519
|
-
return { $startsWith:
|
|
2035
|
+
return { $startsWith: String(v0) };
|
|
1520
2036
|
case "endsWith":
|
|
1521
|
-
return { $endsWith:
|
|
2037
|
+
return { $endsWith: String(v0) };
|
|
1522
2038
|
case "in":
|
|
1523
2039
|
return { $in: all };
|
|
1524
2040
|
case "notIn":
|
|
@@ -1581,7 +2097,7 @@ function aggregateToMetricType(m) {
|
|
|
1581
2097
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
1582
2098
|
}
|
|
1583
2099
|
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
1584
|
-
throw
|
|
2100
|
+
throw datasetInvalidError(
|
|
1585
2101
|
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
|
|
1586
2102
|
);
|
|
1587
2103
|
}
|
|
@@ -1609,13 +2125,31 @@ function fieldRelationshipPath(field) {
|
|
|
1609
2125
|
}
|
|
1610
2126
|
var MAX_JOIN_HOPS = 3;
|
|
1611
2127
|
var joinAlias = (path) => path.replace(/\./g, "__");
|
|
1612
|
-
function compileDataset(dataset, resolver) {
|
|
2128
|
+
function compileDataset(dataset, resolver, options) {
|
|
1613
2129
|
const include = dataset.include ?? [];
|
|
2130
|
+
const declaredDatasource = (objectName) => {
|
|
2131
|
+
const declared = options?.getObjectDatasource?.(objectName);
|
|
2132
|
+
return declared && declared.toLowerCase() !== "default" ? declared : void 0;
|
|
2133
|
+
};
|
|
2134
|
+
const isExternal = (objectName) => options?.isExternalObject?.(objectName) ?? false;
|
|
2135
|
+
const baseDatasource = declaredDatasource(dataset.object);
|
|
2136
|
+
const sameDatasource = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
2137
|
+
const baseIsFederated = isExternal(dataset.object);
|
|
2138
|
+
const assertSameDatasource = (targetObject, path) => {
|
|
2139
|
+
if (!baseDatasource || baseIsFederated) return;
|
|
2140
|
+
if (isExternal(targetObject)) return;
|
|
2141
|
+
const targetDatasource = declaredDatasource(targetObject);
|
|
2142
|
+
if (!targetDatasource) return;
|
|
2143
|
+
if (sameDatasource(targetDatasource, baseDatasource)) return;
|
|
2144
|
+
throw datasetInvalidError(
|
|
2145
|
+
`[dataset-compiler] dataset "${dataset.name}" declares a JOIN that crosses datasources: its base object "${dataset.object}" is on datasource "${baseDatasource}", but the joined object "${targetObject}" \u2014 reached via the \`include\` path "${path}" \u2014 is on datasource "${targetDatasource}". A dataset JOIN cannot cross datasources: the whole dataset is executed as ONE statement on the base object's datasource, so "${targetObject}" is simply not there. Fix it by binding both objects to the same datasource, or by dropping "${path}" from the dataset's \`include\` (and every dimension/measure that references it).`
|
|
2146
|
+
);
|
|
2147
|
+
};
|
|
1614
2148
|
const resolveHop = (fromObject, rel) => {
|
|
1615
2149
|
if (!resolver) return { object: rel, table: rel };
|
|
1616
2150
|
const resolved = resolver(fromObject, rel);
|
|
1617
2151
|
if (!resolved) {
|
|
1618
|
-
throw
|
|
2152
|
+
throw datasetInvalidError(
|
|
1619
2153
|
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${fromObject}".`
|
|
1620
2154
|
);
|
|
1621
2155
|
}
|
|
@@ -1625,7 +2159,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1625
2159
|
for (const path of include) {
|
|
1626
2160
|
const segments = path.split(".");
|
|
1627
2161
|
if (segments.length > MAX_JOIN_HOPS) {
|
|
1628
|
-
throw
|
|
2162
|
+
throw datasetInvalidError(
|
|
1629
2163
|
`[dataset-compiler] dataset "${dataset.name}" include path "${path}" exceeds the ${MAX_JOIN_HOPS}-hop limit (${segments.length} hops). Deeper traversal is not supported.`
|
|
1630
2164
|
);
|
|
1631
2165
|
}
|
|
@@ -1635,6 +2169,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1635
2169
|
for (const seg of segments) {
|
|
1636
2170
|
prefix = prefix ? `${prefix}.${seg}` : seg;
|
|
1637
2171
|
const target = resolveHop(fromObject, seg);
|
|
2172
|
+
assertSameDatasource(target.object, prefix);
|
|
1638
2173
|
const alias = joinAlias(prefix);
|
|
1639
2174
|
if (!joins[alias]) {
|
|
1640
2175
|
joins[alias] = {
|
|
@@ -1651,7 +2186,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1651
2186
|
const assertDeclared = (field, ownerKind, ownerName) => {
|
|
1652
2187
|
const relPath = fieldRelationshipPath(field);
|
|
1653
2188
|
if (relPath && !joins[joinAlias(relPath)]) {
|
|
1654
|
-
throw
|
|
2189
|
+
throw datasetInvalidError(
|
|
1655
2190
|
`[dataset-compiler] ${ownerKind} "${ownerName}" references relationship path "${relPath}" via "${field}", but "${relPath}" is not declared in the dataset's \`include\`. Only fields along a declared relationship path are joinable.`
|
|
1656
2191
|
);
|
|
1657
2192
|
}
|
|
@@ -1834,7 +2369,7 @@ function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
|
1834
2369
|
]);
|
|
1835
2370
|
const unknown = Object.keys(order).filter((k) => !selectable.has(k));
|
|
1836
2371
|
if (unknown.length) {
|
|
1837
|
-
throw
|
|
2372
|
+
throw datasetInvalidError(
|
|
1838
2373
|
`[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
|
|
1839
2374
|
);
|
|
1840
2375
|
}
|
|
@@ -1851,7 +2386,9 @@ function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
|
1851
2386
|
}
|
|
1852
2387
|
function parseUTC(date) {
|
|
1853
2388
|
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
1854
|
-
if (Number.isNaN(ms))
|
|
2389
|
+
if (Number.isNaN(ms)) {
|
|
2390
|
+
throw datasetInvalidError(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
2391
|
+
}
|
|
1855
2392
|
return ms;
|
|
1856
2393
|
}
|
|
1857
2394
|
var DAY_MS = 864e5;
|
|
@@ -1863,6 +2400,30 @@ function shiftYear(date, years) {
|
|
|
1863
2400
|
d.setUTCFullYear(d.getUTCFullYear() + years);
|
|
1864
2401
|
return toISODate(d.getTime());
|
|
1865
2402
|
}
|
|
2403
|
+
function resolveCompareDimension(selection) {
|
|
2404
|
+
const cmp = selection.compareTo;
|
|
2405
|
+
const shiftable = (selection.timeDimensions ?? []).filter(
|
|
2406
|
+
(t) => t.dateRange != null
|
|
2407
|
+
);
|
|
2408
|
+
const names = shiftable.map((t) => t.dimension);
|
|
2409
|
+
if (cmp.dimension != null) {
|
|
2410
|
+
if (!names.includes(cmp.dimension)) {
|
|
2411
|
+
throw datasetInvalidError(
|
|
2412
|
+
`[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange. ` + (names.length > 0 ? `This selection dates ${names.map((n) => `"${n}"`).join(", ")} \u2014 name one of those, or omit compareTo.dimension to let the executor choose when there is only one.` : "This selection declares no timeDimension with a dateRange, so there is no window to shift; give the dimension a dateRange (a dashboard date-range filter is the usual source).")
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
return cmp.dimension;
|
|
2416
|
+
}
|
|
2417
|
+
if (names.length === 1) return names[0];
|
|
2418
|
+
if (names.length === 0) {
|
|
2419
|
+
throw datasetInvalidError(
|
|
2420
|
+
"[dataset-executor] compareTo needs a dated window to shift, but this selection declares no timeDimension with a dateRange. Give the time dimension a dateRange (a dashboard date-range filter is the usual source), or drop compareTo \u2014 a period-over-period comparison is only defined against a bounded window."
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
throw datasetInvalidError(
|
|
2424
|
+
`[dataset-executor] compareTo.dimension is ambiguous: ${names.length} time dimensions carry a dateRange (${names.map((n) => `"${n}"`).join(", ")}). Name the one to shift \u2014 compareTo: { kind: '${cmp.kind}', dimension: '${names[0]}' }.`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
1866
2427
|
function shiftRange(range, kind) {
|
|
1867
2428
|
const [start, end] = range;
|
|
1868
2429
|
if (kind === "previousYear") {
|
|
@@ -1905,7 +2466,7 @@ var DatasetExecutor = class {
|
|
|
1905
2466
|
for (const grouping of groupings) {
|
|
1906
2467
|
const unknown = grouping.filter((d) => !selected.has(d));
|
|
1907
2468
|
if (unknown.length) {
|
|
1908
|
-
throw
|
|
2469
|
+
throw datasetInvalidError(
|
|
1909
2470
|
`[dataset-executor] totals grouping [${grouping.join(", ")}] is not a subset of the selected dimensions \u2014 unknown: ${unknown.join(", ")}.`
|
|
1910
2471
|
);
|
|
1911
2472
|
}
|
|
@@ -2014,8 +2575,9 @@ var DatasetExecutor = class {
|
|
|
2014
2575
|
async runMeasurePass(compiled, selection, opts) {
|
|
2015
2576
|
const { measures, dimensions, baseFilter, window, context } = opts;
|
|
2016
2577
|
const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
|
|
2578
|
+
const primary = unfiltered.length > 0 || filtered.length === 0;
|
|
2017
2579
|
let result;
|
|
2018
|
-
if (
|
|
2580
|
+
if (primary) {
|
|
2019
2581
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
2020
2582
|
measures: unfiltered,
|
|
2021
2583
|
dimensions,
|
|
@@ -2027,7 +2589,8 @@ var DatasetExecutor = class {
|
|
|
2027
2589
|
} else {
|
|
2028
2590
|
result = { rows: [], fields: [] };
|
|
2029
2591
|
}
|
|
2030
|
-
|
|
2592
|
+
const measureNames = new Set(measures);
|
|
2593
|
+
for (const [i, m] of filtered.entries()) {
|
|
2031
2594
|
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
|
|
2032
2595
|
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
2033
2596
|
measures: [m],
|
|
@@ -2037,6 +2600,11 @@ var DatasetExecutor = class {
|
|
|
2037
2600
|
contextTimezone: context?.timezone
|
|
2038
2601
|
}), context);
|
|
2039
2602
|
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
2603
|
+
if (!primary && i === 0) {
|
|
2604
|
+
for (const f of sub.fields ?? []) {
|
|
2605
|
+
if (!measureNames.has(f.name)) result.fields.push(f);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2040
2608
|
result.fields.push({ name: m, type: "number" });
|
|
2041
2609
|
}
|
|
2042
2610
|
return result;
|
|
@@ -2066,14 +2634,17 @@ var DatasetExecutor = class {
|
|
|
2066
2634
|
if (opts.where) q.where = opts.where;
|
|
2067
2635
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
2068
2636
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2637
|
+
const groupedDims = new Set(opts.dimensions);
|
|
2069
2638
|
const granularityFor = (name) => {
|
|
2070
2639
|
const cd = compiled.cube.dimensions[name];
|
|
2071
2640
|
if (cd?.type !== "time") return void 0;
|
|
2072
2641
|
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2073
2642
|
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
2074
2643
|
};
|
|
2644
|
+
const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
|
|
2075
2645
|
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2076
2646
|
if (t.granularity) return t;
|
|
2647
|
+
if (!bucketsUnstatedEntry(t.dimension)) return t;
|
|
2077
2648
|
const granularity = granularityFor(t.dimension);
|
|
2078
2649
|
return granularity ? { ...t, granularity } : t;
|
|
2079
2650
|
});
|
|
@@ -2092,16 +2663,12 @@ var DatasetExecutor = class {
|
|
|
2092
2663
|
}
|
|
2093
2664
|
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
2094
2665
|
const cmp = selection.compareTo;
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
throw new Error(
|
|
2098
|
-
`[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange.`
|
|
2099
|
-
);
|
|
2100
|
-
}
|
|
2666
|
+
const dimension = resolveCompareDimension(selection);
|
|
2667
|
+
const td = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension);
|
|
2101
2668
|
const range = Array.isArray(td.dateRange) ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] : [td.dateRange, td.dateRange];
|
|
2102
2669
|
const shifted = shiftRange(range, cmp.kind);
|
|
2103
2670
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
2104
|
-
(t) => t.dimension ===
|
|
2671
|
+
(t) => t.dimension === dimension ? { ...t, dateRange: shifted } : t
|
|
2105
2672
|
);
|
|
2106
2673
|
const sub = await this.runMeasurePass(
|
|
2107
2674
|
compiled,
|
|
@@ -2116,8 +2683,22 @@ var DatasetExecutor = class {
|
|
|
2116
2683
|
});
|
|
2117
2684
|
}
|
|
2118
2685
|
};
|
|
2686
|
+
var NULL_DIMENSION_SEGMENT = "~";
|
|
2687
|
+
function dimensionKeyOf(row, dimensions) {
|
|
2688
|
+
let key = "";
|
|
2689
|
+
for (const d of dimensions) {
|
|
2690
|
+
const value = row[d];
|
|
2691
|
+
if (value == null) {
|
|
2692
|
+
key += NULL_DIMENSION_SEGMENT;
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
const s = String(value);
|
|
2696
|
+
key += `${s.length}:${s}`;
|
|
2697
|
+
}
|
|
2698
|
+
return key;
|
|
2699
|
+
}
|
|
2119
2700
|
function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
2120
|
-
const keyOf = (row) =>
|
|
2701
|
+
const keyOf = (row) => dimensionKeyOf(row, dimensions);
|
|
2121
2702
|
const index = /* @__PURE__ */ new Map();
|
|
2122
2703
|
for (const row of base) index.set(keyOf(row), row);
|
|
2123
2704
|
for (const row of extra) {
|
|
@@ -2469,15 +3050,71 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
2469
3050
|
}
|
|
2470
3051
|
|
|
2471
3052
|
// src/analytics-service.ts
|
|
3053
|
+
function hasDeclaredErrorEnvelope(err) {
|
|
3054
|
+
const e = err;
|
|
3055
|
+
return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
|
|
3056
|
+
}
|
|
2472
3057
|
function isMissingSourceError(err) {
|
|
2473
|
-
const
|
|
3058
|
+
const raw = String(err?.message ?? err ?? "");
|
|
3059
|
+
const msg = raw.toLowerCase();
|
|
2474
3060
|
return msg.includes("no such table") || // sqlite / libsql
|
|
2475
|
-
|
|
3061
|
+
/relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
|
|
2476
3062
|
msg.includes("doesn't exist") || // mysql ("table ... doesn't exist")
|
|
2477
3063
|
msg.includes("not registered") || // framework: object not in registry
|
|
2478
3064
|
msg.includes("unknown object") || msg.includes("is not a registered object");
|
|
2479
3065
|
}
|
|
3066
|
+
function missingSourceRelation(err) {
|
|
3067
|
+
const msg = String(err?.message ?? err ?? "");
|
|
3068
|
+
const patterns = [
|
|
3069
|
+
/no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
|
|
3070
|
+
// sqlite / libsql
|
|
3071
|
+
/relation\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+does not exist/i,
|
|
3072
|
+
// postgres
|
|
3073
|
+
/table\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+doesn't exist/i,
|
|
3074
|
+
// mysql
|
|
3075
|
+
/(?:object|table)\s+[`"']([A-Za-z0-9_$.]+)[`"']\s+is not registered/i,
|
|
3076
|
+
// framework
|
|
3077
|
+
/unknown object:?\s*[`"']?([A-Za-z0-9_$.]+)/i,
|
|
3078
|
+
/[`"']([A-Za-z0-9_$.]+)[`"']\s+is not a registered object/i
|
|
3079
|
+
];
|
|
3080
|
+
for (const re of patterns) {
|
|
3081
|
+
const m = re.exec(msg);
|
|
3082
|
+
if (m?.[1]) {
|
|
3083
|
+
const parts = m[1].split(".").filter(Boolean);
|
|
3084
|
+
const bare = parts[parts.length - 1];
|
|
3085
|
+
if (bare) return bare;
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
return void 0;
|
|
3089
|
+
}
|
|
2480
3090
|
var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
|
|
3091
|
+
function declaredMemberEntry(cube, member, kind) {
|
|
3092
|
+
const bags = kind === "dimension" ? [cube.dimensions] : [
|
|
3093
|
+
cube.dimensions,
|
|
3094
|
+
cube.measures
|
|
3095
|
+
];
|
|
3096
|
+
for (const bag of bags) {
|
|
3097
|
+
if (bag[member]) return { ...bag[member], key: member };
|
|
3098
|
+
if (member.includes(".")) {
|
|
3099
|
+
const [first, ...rest] = member.split(".");
|
|
3100
|
+
const tail = rest.join(".");
|
|
3101
|
+
if (first === cube.name && bag[tail]) return { ...bag[tail], key: tail };
|
|
3102
|
+
if (bag[tail]) return { ...bag[tail], key: tail };
|
|
3103
|
+
const flat = member.replace(/\./g, "_");
|
|
3104
|
+
if (bag[flat]) return { ...bag[flat], key: flat };
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
return void 0;
|
|
3108
|
+
}
|
|
3109
|
+
function resolveMemberSource(cube, member, kind) {
|
|
3110
|
+
const entry = declaredMemberEntry(cube, member, kind);
|
|
3111
|
+
if (entry) {
|
|
3112
|
+
const source = typeof entry.sql === "string" ? entry.sql.trim() : "";
|
|
3113
|
+
return { key: entry.key, source: source && BARE_IDENTIFIER.test(source) ? source : null };
|
|
3114
|
+
}
|
|
3115
|
+
if (member.includes(".")) return { key: member, source: null };
|
|
3116
|
+
return { key: member, source: BARE_IDENTIFIER.test(member) ? member : null };
|
|
3117
|
+
}
|
|
2481
3118
|
var DEFAULT_CAPABILITIES = {
|
|
2482
3119
|
nativeSql: false,
|
|
2483
3120
|
objectqlAggregate: false,
|
|
@@ -2501,6 +3138,8 @@ var AnalyticsService = class {
|
|
|
2501
3138
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2502
3139
|
this.isRegisteredObject = config.isRegisteredObject;
|
|
2503
3140
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3141
|
+
this.getObjectDatasource = config.getObjectDatasource;
|
|
3142
|
+
this.isExternalObject = config.isExternalObject;
|
|
2504
3143
|
if (config.datasets) {
|
|
2505
3144
|
for (const ds of config.datasets) {
|
|
2506
3145
|
try {
|
|
@@ -2637,7 +3276,10 @@ var AnalyticsService = class {
|
|
|
2637
3276
|
* compiled dataset.
|
|
2638
3277
|
*/
|
|
2639
3278
|
registerDataset(dataset) {
|
|
2640
|
-
const compiled = compileDataset(dataset, this.relationshipResolver
|
|
3279
|
+
const compiled = compileDataset(dataset, this.relationshipResolver, {
|
|
3280
|
+
getObjectDatasource: this.getObjectDatasource,
|
|
3281
|
+
isExternalObject: this.isExternalObject
|
|
3282
|
+
});
|
|
2641
3283
|
this.cubeRegistry.register(compiled.cube);
|
|
2642
3284
|
this.datasetRegistry.set(dataset.name, compiled);
|
|
2643
3285
|
return compiled;
|
|
@@ -2681,9 +3323,22 @@ var AnalyticsService = class {
|
|
|
2681
3323
|
try {
|
|
2682
3324
|
result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
|
|
2683
3325
|
} catch (err) {
|
|
3326
|
+
if (hasDeclaredErrorEnvelope(err)) throw err;
|
|
2684
3327
|
if (isMissingSourceError(err)) {
|
|
3328
|
+
const missing = missingSourceRelation(err);
|
|
3329
|
+
const detail = String(err?.message ?? err);
|
|
3330
|
+
const joined = missing && missing.toLowerCase() !== dataset.object.toLowerCase() ? missing : void 0;
|
|
3331
|
+
if (joined && (this.isRegisteredObject?.(joined) ?? true)) {
|
|
3332
|
+
const baseDs = this.getObjectDatasource?.(dataset.object);
|
|
3333
|
+
const joinedDs = this.getObjectDatasource?.(joined);
|
|
3334
|
+
const where = baseDs ? `datasource "${baseDs}"` : "the default datasource";
|
|
3335
|
+
const joinedWhere = joinedDs ? `datasource "${joinedDs}"` : "the default datasource";
|
|
3336
|
+
throw new Error(
|
|
3337
|
+
`[Analytics] dataset "${dataset.name}" cannot be executed as one statement: table "${joined}" is not on ${where}, which is where its base object "${dataset.object}" lives \u2014 "${joined}" is registered on ${joinedWhere}. A dataset JOIN cannot cross datasources. Fix it by binding both objects to the same datasource, or by dropping the cross-datasource relationship from the dataset's \`include\`/dimensions. (driver said: ${detail})`
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
2685
3340
|
this.logger.warn(
|
|
2686
|
-
`[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable (${
|
|
3341
|
+
`[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable (${detail}); returning an empty result instead of failing the widget`
|
|
2687
3342
|
);
|
|
2688
3343
|
return { rows: [], fields: [], totals: [] };
|
|
2689
3344
|
}
|
|
@@ -2780,9 +3435,15 @@ var AnalyticsService = class {
|
|
|
2780
3435
|
}
|
|
2781
3436
|
}
|
|
2782
3437
|
}
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
3438
|
+
const describableDims = [...selectedDims];
|
|
3439
|
+
for (const t of selection.timeDimensions ?? []) {
|
|
3440
|
+
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
3441
|
+
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
3442
|
+
if (d) describableDims.push(d);
|
|
3443
|
+
}
|
|
3444
|
+
if (result.fields?.length && describableDims.length) {
|
|
3445
|
+
const dimByName = new Map(describableDims.map((d) => [d.name, d]));
|
|
3446
|
+
const dimByField = new Map(describableDims.filter((d) => !!d.field).map((d) => [d.field, d]));
|
|
2786
3447
|
for (const f of result.fields) {
|
|
2787
3448
|
if (f.label != null) continue;
|
|
2788
3449
|
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
|
|
@@ -2836,6 +3497,19 @@ var AnalyticsService = class {
|
|
|
2836
3497
|
* `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
|
|
2837
3498
|
* widget translators), inject suffix-inferred Metric entries so the
|
|
2838
3499
|
* strategies pick the right aggregation function and field.
|
|
3500
|
+
*
|
|
3501
|
+
* It is also where the three SOURCE-FIELD gates run, on every path out of this
|
|
3502
|
+
* method and always BEFORE the (possibly augmented) cube is registered:
|
|
3503
|
+
* {@link assertMeasureFields} (#4437), {@link assertDimensionFields} (#5520)
|
|
3504
|
+
* and {@link assertWhereFields} (#5669) — one per request key that can carry a
|
|
3505
|
+
* field name. All three answer the same question — does the object actually
|
|
3506
|
+
* have the column this member resolves to — and all three must answer it here,
|
|
3507
|
+
* because from the strategy onwards the answer is the driver's `no such
|
|
3508
|
+
* column`.
|
|
3509
|
+
*
|
|
3510
|
+
* They run in request-key order (measures → dimensions/timeDimensions →
|
|
3511
|
+
* where), so a query that gets several wrong is answered about one at a time,
|
|
3512
|
+
* naming a real mistake either way.
|
|
2839
3513
|
*/
|
|
2840
3514
|
ensureCube(query) {
|
|
2841
3515
|
const name = query.cube;
|
|
@@ -2844,6 +3518,8 @@ var AnalyticsService = class {
|
|
|
2844
3518
|
this.assertInferableCube(name);
|
|
2845
3519
|
cube = this.inferCubeFromQuery(query);
|
|
2846
3520
|
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
3521
|
+
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
|
|
3522
|
+
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
|
|
2847
3523
|
this.cubeRegistry.register(cube);
|
|
2848
3524
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
2849
3525
|
const message = `[Analytics] No cube registered for "${name}"; auto-inferred a minimal cube (sql="${name}", measures=${Object.keys(cube.measures).join(",") || "(none)"}, dimensions=${Object.keys(cube.dimensions).join(",") || "(none)"}). Define an explicit Cube in your stack for full control.`;
|
|
@@ -2864,12 +3540,16 @@ var AnalyticsService = class {
|
|
|
2864
3540
|
measures: { ...cube.measures, ...extraMeasures }
|
|
2865
3541
|
};
|
|
2866
3542
|
this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
|
|
3543
|
+
this.assertDimensionFields(query, augmented, Object.keys(cube.dimensions));
|
|
3544
|
+
this.assertWhereFields(query, augmented, Object.keys(cube.dimensions));
|
|
2867
3545
|
this.cubeRegistry.register(augmented);
|
|
2868
3546
|
this.logger.debug(
|
|
2869
3547
|
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
2870
3548
|
);
|
|
2871
3549
|
} else {
|
|
2872
3550
|
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
3551
|
+
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
|
|
3552
|
+
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
|
|
2873
3553
|
}
|
|
2874
3554
|
}
|
|
2875
3555
|
/**
|
|
@@ -2941,6 +3621,207 @@ var AnalyticsService = class {
|
|
|
2941
3621
|
throw err;
|
|
2942
3622
|
}
|
|
2943
3623
|
}
|
|
3624
|
+
/**
|
|
3625
|
+
* [#5520] Reject a DIMENSION whose source field the backing object does not
|
|
3626
|
+
* have, BEFORE the strategy compiles it into `GROUP BY`.
|
|
3627
|
+
*
|
|
3628
|
+
* The symmetric half of {@link assertMeasureFields}. #4437 closed the measure
|
|
3629
|
+
* side and stopped there, so the identical mistake one request key over still
|
|
3630
|
+
* reached the driver:
|
|
3631
|
+
*
|
|
3632
|
+
* ```
|
|
3633
|
+
* POST /analytics/query {"cube":"crm_account","measures":["account_count"],"dimensions":["bogus_dim"]}
|
|
3634
|
+
* → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
|
|
3635
|
+
*
|
|
3636
|
+
* POST /analytics/dataset/query {"selection":{"dimensions":["bogus_dim"],…}}
|
|
3637
|
+
* → 500 {"code":"ANALYTICS_QUERY_FAILED",
|
|
3638
|
+
* "error":"SELECT bogus_dim AS \"bogus_dim\", … GROUP BY bogus_dim - no such column: bogus_dim"}
|
|
3639
|
+
* ```
|
|
3640
|
+
*
|
|
3641
|
+
* A driver error class as the caller's `error.code` is the ADR-0112 violation
|
|
3642
|
+
* #4437 named, and the dataset face additionally echoed the generated
|
|
3643
|
+
* statement — physical table and column names — back to the caller. The
|
|
3644
|
+
* envelope here is deliberately the SAME as the measure gate's
|
|
3645
|
+
* (`INVALID_FIELD`/400 + `field`/`object`/`param`), because "the query names a
|
|
3646
|
+
* field the object does not have" is ONE mistake and must have one wire shape
|
|
3647
|
+
* whichever member kind carried it.
|
|
3648
|
+
*
|
|
3649
|
+
* What it checks, and what it deliberately does not:
|
|
3650
|
+
*
|
|
3651
|
+
* - **Both dimension keys.** `query.dimensions` and `query.timeDimensions`
|
|
3652
|
+
* land in the same `cube.dimensions` bag, are resolved by the same
|
|
3653
|
+
* `lookupMember`, and produced the same 500 (a bogus time dimension became
|
|
3654
|
+
* `date_trunc('month', bogus_at)`); `param` reports which key carried it.
|
|
3655
|
+
* - **An UNDECLARED but real field stays legal.** `dimensions: ['phone']` on a
|
|
3656
|
+
* cube that never declared `phone` groups by `phone` today — the dimension
|
|
3657
|
+
* twin of measure auto-inference, and an established contract. So the
|
|
3658
|
+
* question asked is "does the OBJECT have this field", never "did the cube
|
|
3659
|
+
* declare this dimension". An undeclared member is checked against the
|
|
3660
|
+
* object under the name the strategies would use as the column (their own
|
|
3661
|
+
* `resolveDimensionSql`/`resolveFieldName` fallback: the member itself).
|
|
3662
|
+
* - Only when the cube's `sql` is a bare OBJECT NAME, only when
|
|
3663
|
+
* {@link AnalyticsServiceConfig.getObjectFieldNames} answers, and only for
|
|
3664
|
+
* sources that are BARE COLUMNS — same three stand-downs as the measure
|
|
3665
|
+
* gate, for the same reasons (no field list to check against; nothing
|
|
3666
|
+
* authoritative to consult; a dotted reference resolves through a join whose
|
|
3667
|
+
* target this gate cannot see, so it belongs to the join allowlist).
|
|
3668
|
+
* - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
|
|
3669
|
+
* the data path's `resolveQueryFields`.
|
|
3670
|
+
*
|
|
3671
|
+
* Runs after the measure gate and before the `where` gate on each `ensureCube`
|
|
3672
|
+
* path, so a query that gets several wrong is answered about its measure
|
|
3673
|
+
* first — one rejection at a time, naming a real mistake either way.
|
|
3674
|
+
*/
|
|
3675
|
+
assertDimensionFields(query, cube, declaredDimensions) {
|
|
3676
|
+
const probe = this.getObjectFieldNames;
|
|
3677
|
+
if (!probe) return;
|
|
3678
|
+
const members = [
|
|
3679
|
+
...(query.dimensions ?? []).map((member) => ({ member, param: "dimensions" })),
|
|
3680
|
+
...(query.timeDimensions ?? []).map((td) => ({ member: td.dimension, param: "timeDimensions" }))
|
|
3681
|
+
];
|
|
3682
|
+
if (members.length === 0) return;
|
|
3683
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
3684
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
3685
|
+
const fieldNames = probe(object);
|
|
3686
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
3687
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
3688
|
+
const resolve = (member) => resolveMemberSource(cube, member, "dimension");
|
|
3689
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
3690
|
+
for (const { member } of members) {
|
|
3691
|
+
const { key, source } = resolve(member);
|
|
3692
|
+
if (source && !known.has(source)) invalid.add(key);
|
|
3693
|
+
}
|
|
3694
|
+
if (invalid.size === 0) return;
|
|
3695
|
+
const usable = declaredDimensions.filter((d) => !invalid.has(d));
|
|
3696
|
+
for (const { member, param } of members) {
|
|
3697
|
+
const { source } = resolve(member);
|
|
3698
|
+
if (!source || known.has(source)) continue;
|
|
3699
|
+
const kind = param === "timeDimensions" ? "Time dimension" : "Dimension";
|
|
3700
|
+
const verb = param === "timeDimensions" ? "buckets" : "groups by";
|
|
3701
|
+
const err = new Error(
|
|
3702
|
+
`${kind} '${member}' on cube '${cube.name}' ${verb} field '${source}', which object '${object}' does not have. Valid dimensions: ${usable.join(", ") || "(none)"}. Any of the object's OWN fields may also be used as a dimension without the cube declaring it, so check the spelling of '${source}' \u2014 known fields: ${[...fieldNames].sort().join(", ")}.`
|
|
3703
|
+
);
|
|
3704
|
+
err.code = "INVALID_FIELD";
|
|
3705
|
+
err.status = 400;
|
|
3706
|
+
err.field = source;
|
|
3707
|
+
err.object = object;
|
|
3708
|
+
err.param = param;
|
|
3709
|
+
err.dimension = member;
|
|
3710
|
+
throw err;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* [#5669] Reject a `where` member whose source field the backing object does
|
|
3715
|
+
* not have, BEFORE the strategy compiles it into `WHERE`.
|
|
3716
|
+
*
|
|
3717
|
+
* The third and last param of one defect. #4437 gated `measures`, #5520 gated
|
|
3718
|
+
* `dimensions`/`timeDimensions`, and the filter face — the request key that
|
|
3719
|
+
* most often carries a hand-typed field name — had no gate at all:
|
|
3720
|
+
*
|
|
3721
|
+
* ```
|
|
3722
|
+
* POST /analytics/query {"cube":"crm_account","measures":["count"],"where":{"bogus_col":"x"}}
|
|
3723
|
+
* → SELECT COUNT(*) AS "count" FROM "crm_account" WHERE bogus_col = $1
|
|
3724
|
+
* → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
|
|
3725
|
+
* ```
|
|
3726
|
+
*
|
|
3727
|
+
* Same envelope as its two siblings (`INVALID_FIELD`/400 + `field`/`object`/
|
|
3728
|
+
* `param`), because "the query names a field the object does not have" is ONE
|
|
3729
|
+
* mistake whichever request key carried it, and the DATA route has answered it
|
|
3730
|
+
* that way since #4315/#4254 (`resolveQueryFields`).
|
|
3731
|
+
*
|
|
3732
|
+
* # Where the field names come from: the SQL producer's own reader
|
|
3733
|
+
*
|
|
3734
|
+
* The members are collected through `normalizeAnalyticsFilterTree` +
|
|
3735
|
+
* `collectFilterLeaves` — the SAME pair both strategies call to build the
|
|
3736
|
+
* predicate. This is deliberate and is the whole reason this gate is not a
|
|
3737
|
+
* second filter-tree walker: a hand-rolled walk would have to re-derive
|
|
3738
|
+
* `$and`/`$or`/`$not` recursion, `$`-prefixed operator keys, `$between`
|
|
3739
|
+
* lowering, the nested-relation dot flattening (`{owner: {region: 'NA'}}` →
|
|
3740
|
+
* member `owner.region`) and the #5334 array lowering, and every divergence
|
|
3741
|
+
* would show up as "the field the gate saw" not being "the column that reached
|
|
3742
|
+
* SQL" — in either direction (a phantom rejection, or a hole).
|
|
3743
|
+
* `collectFilterLeaves` discards structure, which is exactly right here:
|
|
3744
|
+
* whether a predicate sits under an `$or` changes nothing about whether its
|
|
3745
|
+
* column exists. (Its doc's warning — never rebuild a predicate from this list
|
|
3746
|
+
* — does not apply; this gate builds nothing.)
|
|
3747
|
+
*
|
|
3748
|
+
* # Three stand-downs at query level, plus the per-member ones
|
|
3749
|
+
*
|
|
3750
|
+
* - No {@link AnalyticsServiceConfig.getObjectFieldNames}, cube `sql` that is
|
|
3751
|
+
* not a bare object name, or a probe that cannot answer for the object — the
|
|
3752
|
+
* same three tiers as the measure and dimension gates, for the same reasons.
|
|
3753
|
+
* - A `where` the normalizer REFUSES (an unknown operator, a non-array
|
|
3754
|
+
* `$and`, an unlowerable filter array) is not judged here: this gate stands
|
|
3755
|
+
* down and lets the refusal happen where it already does. Those inputs
|
|
3756
|
+
* already answer `INVALID_FILTER`/400 from the strategy (#5352/#5367's
|
|
3757
|
+
* geography, not this gate's), and pulling them forward into `ensureCube`
|
|
3758
|
+
* would newly refuse them on the draft-preview path too, whose
|
|
3759
|
+
* `matchesWhere` never consults the normalizer at all. A field gate that
|
|
3760
|
+
* cannot read the tree has nothing to say about it.
|
|
3761
|
+
* - Per member, {@link resolveMemberSource} stands down on an expression `sql`
|
|
3762
|
+
* and on a dotted relation traversal — for the dimension gate's reasons.
|
|
3763
|
+
*
|
|
3764
|
+
* # Array `where` IS gated, and #5353's fix did not change that
|
|
3765
|
+
*
|
|
3766
|
+
* Since #5334 an array `where` is lowered by `normalizeAnalyticsFilterTree`
|
|
3767
|
+
* and compiles to the identical predicate — a measured fact,
|
|
3768
|
+
* `where: [['bogus_col','=','x']]` and `where: {bogus_col: 'x'}` both produce
|
|
3769
|
+
* `WHERE bogus_col = $1` and hand `executeAggregate` the same
|
|
3770
|
+
* `{bogus_col: 'x'}`. Gating one spelling and not the other would answer one
|
|
3771
|
+
* mistake two ways, which is the split this whole gate family exists to close.
|
|
3772
|
+
*
|
|
3773
|
+
* `inferCubeFromQuery` used to skip an array `where` when minting the ad-hoc
|
|
3774
|
+
* cube's `dimensions` — a separate question (the cube's dimension VOCABULARY,
|
|
3775
|
+
* not which columns reach the driver), fixed by #5353 by lowering before
|
|
3776
|
+
* reading keys. Because this gate reads filter LEAVES rather than
|
|
3777
|
+
* `cube.dimensions`, that fix could not change its verdicts, and measurement
|
|
3778
|
+
* confirms it did not: the array where's keys now reach `cube.dimensions`, so
|
|
3779
|
+
* {@link resolveMemberSource} takes the DECLARED-dimension branch for those
|
|
3780
|
+
* members instead of the undeclared-bare-column one — and both branches yield
|
|
3781
|
+
* the same `source` for the same member, since the minted dimension's `sql` IS
|
|
3782
|
+
* the member name. What did change is the rejection's suggestion list, in the
|
|
3783
|
+
* direction that closes the split: `Valid filter members:` now reads the same
|
|
3784
|
+
* for both spellings of one filter.
|
|
3785
|
+
*/
|
|
3786
|
+
assertWhereFields(query, cube, declaredDimensions) {
|
|
3787
|
+
const probe = this.getObjectFieldNames;
|
|
3788
|
+
if (!probe) return;
|
|
3789
|
+
const where = query.where;
|
|
3790
|
+
if (!where || typeof where !== "object") return;
|
|
3791
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
3792
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
3793
|
+
const fieldNames = probe(object);
|
|
3794
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
3795
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
3796
|
+
let members;
|
|
3797
|
+
try {
|
|
3798
|
+
members = collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((leaf) => leaf.member);
|
|
3799
|
+
} catch {
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
if (members.length === 0) return;
|
|
3803
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
3804
|
+
for (const member of members) {
|
|
3805
|
+
const { key, source } = resolveMemberSource(cube, member, "any");
|
|
3806
|
+
if (source && !known.has(source)) invalid.add(key);
|
|
3807
|
+
}
|
|
3808
|
+
if (invalid.size === 0) return;
|
|
3809
|
+
const usable = declaredDimensions.filter((d) => !invalid.has(d));
|
|
3810
|
+
for (const member of members) {
|
|
3811
|
+
const { source } = resolveMemberSource(cube, member, "any");
|
|
3812
|
+
if (!source || known.has(source)) continue;
|
|
3813
|
+
const err = new Error(
|
|
3814
|
+
`Filter member '${member}' in 'where' on cube '${cube.name}' constrains field '${source}', which object '${object}' does not have. Valid filter members: ${usable.join(", ") || "(none)"}. Any of the object's OWN fields may also be filtered on without the cube declaring it, so check the spelling of '${source}' \u2014 known fields: ${[...fieldNames].sort().join(", ")}.`
|
|
3815
|
+
);
|
|
3816
|
+
err.code = "INVALID_FIELD";
|
|
3817
|
+
err.status = 400;
|
|
3818
|
+
err.field = source;
|
|
3819
|
+
err.object = object;
|
|
3820
|
+
err.param = "where";
|
|
3821
|
+
err.member = member;
|
|
3822
|
+
throw err;
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
2944
3825
|
/**
|
|
2945
3826
|
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
2946
3827
|
* Cube may only be inferred into one if it is a registered object.
|
|
@@ -2979,29 +3860,37 @@ var AnalyticsService = class {
|
|
|
2979
3860
|
const cubeName = query.cube;
|
|
2980
3861
|
const measures = {};
|
|
2981
3862
|
const dimensions = {};
|
|
2982
|
-
const
|
|
3863
|
+
const stripCubeQualifier = (m) => {
|
|
3864
|
+
const dot = m.indexOf(".");
|
|
3865
|
+
if (dot < 0) return m;
|
|
3866
|
+
return m.slice(0, dot) === cubeName ? m.slice(dot + 1) : m;
|
|
3867
|
+
};
|
|
2983
3868
|
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
2984
3869
|
for (const m of query.measures || []) {
|
|
2985
|
-
const key =
|
|
3870
|
+
const key = m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
2986
3871
|
if (measures[key]) continue;
|
|
2987
3872
|
const inferred = inferMeasure(key);
|
|
2988
3873
|
measures[key] = inferred;
|
|
2989
3874
|
}
|
|
2990
3875
|
for (const d of query.dimensions || []) {
|
|
2991
|
-
const key =
|
|
3876
|
+
const key = stripCubeQualifier(d);
|
|
2992
3877
|
if (dimensions[key]) continue;
|
|
2993
3878
|
dimensions[key] = { name: key, label: key, type: "string", sql: key };
|
|
2994
3879
|
}
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3880
|
+
let lowered = null;
|
|
3881
|
+
try {
|
|
3882
|
+
lowered = lowerAnalyticsWhere(query);
|
|
3883
|
+
} catch {
|
|
3884
|
+
}
|
|
3885
|
+
if (lowered) {
|
|
3886
|
+
for (const key of conjunctFieldKeys(lowered)) {
|
|
3887
|
+
const minted = stripCubeQualifier(key);
|
|
3888
|
+
if (dimensions[minted] || measures[minted]) continue;
|
|
3889
|
+
dimensions[minted] = { name: minted, label: minted, type: "string", sql: minted };
|
|
3001
3890
|
}
|
|
3002
3891
|
}
|
|
3003
3892
|
for (const td of query.timeDimensions || []) {
|
|
3004
|
-
const key =
|
|
3893
|
+
const key = stripCubeQualifier(td.dimension);
|
|
3005
3894
|
if (dimensions[key]) continue;
|
|
3006
3895
|
dimensions[key] = {
|
|
3007
3896
|
name: key,
|
|
@@ -3168,7 +4057,7 @@ var AnalyticsServicePlugin = class {
|
|
|
3168
4057
|
return void 0;
|
|
3169
4058
|
}
|
|
3170
4059
|
};
|
|
3171
|
-
executeRawSql = async (
|
|
4060
|
+
executeRawSql = async (objectName, sql, params) => {
|
|
3172
4061
|
const engine = tryGetExecutor();
|
|
3173
4062
|
if (!engine || !engine.execute) {
|
|
3174
4063
|
throw new Error(
|
|
@@ -3176,7 +4065,7 @@ var AnalyticsServicePlugin = class {
|
|
|
3176
4065
|
);
|
|
3177
4066
|
}
|
|
3178
4067
|
const knexSql = sql.replace(/\$(\d+)/g, "?");
|
|
3179
|
-
const result = await engine.execute(knexSql, { args: params });
|
|
4068
|
+
const result = await engine.execute(knexSql, { args: params, object: objectName });
|
|
3180
4069
|
if (result === null || result === void 0) {
|
|
3181
4070
|
const err = new Error(
|
|
3182
4071
|
`[Analytics] The "data" engine's driver returned null for raw SQL \u2014 this driver does not support SQL execution. The query will fall back to an aggregate-based strategy when one is available.`
|
|
@@ -3330,6 +4219,12 @@ var AnalyticsServicePlugin = class {
|
|
|
3330
4219
|
const f = dataEngine()?.getObject?.(object)?.fields?.[field];
|
|
3331
4220
|
return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
3332
4221
|
},
|
|
4222
|
+
// #5033 — the datasource an object is bound to, used ONLY to name the
|
|
4223
|
+
// actual cause when a dataset's SQL references a table that is not on the
|
|
4224
|
+
// datasource the query was routed to. Undefined ⇒ the object rides the
|
|
4225
|
+
// default datasource (or the engine cannot answer), and the diagnostic
|
|
4226
|
+
// says so rather than inventing a name.
|
|
4227
|
+
getObjectDatasource: (objectName) => dataEngine()?.getObject?.(objectName)?.datasource,
|
|
3333
4228
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
3334
4229
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
3335
4230
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|
|
@@ -3351,7 +4246,8 @@ var AnalyticsServicePlugin = class {
|
|
|
3351
4246
|
if (!engine) return true;
|
|
3352
4247
|
return engine.getObject?.(name) != null;
|
|
3353
4248
|
},
|
|
3354
|
-
// [#4437] Field names for the
|
|
4249
|
+
// [#4437, #5520] Field names for the two source-field gates — measures
|
|
4250
|
+
// (#4437) and dimensions/timeDimensions (#5520). Read from the
|
|
3355
4251
|
// SAME schema registry `isRegisteredObject` above consults (and the data
|
|
3356
4252
|
// path's #4315 gate reads), so "which fields exist" has one answer across
|
|
3357
4253
|
// /data and /analytics. `undefined` — no engine, unknown object, or an
|