@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.cjs
CHANGED
|
@@ -41,7 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
module.exports = __toCommonJS(index_exports);
|
|
42
42
|
|
|
43
43
|
// src/analytics-service.ts
|
|
44
|
-
var
|
|
44
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
45
45
|
var import_core5 = require("@objectstack/core");
|
|
46
46
|
|
|
47
47
|
// src/cube-registry.ts
|
|
@@ -160,6 +160,14 @@ var CubeRegistry = class {
|
|
|
160
160
|
};
|
|
161
161
|
|
|
162
162
|
// src/strategies/filter-normalizer.ts
|
|
163
|
+
var import_data = require("@objectstack/spec/data");
|
|
164
|
+
var import_api = require("@objectstack/spec/api");
|
|
165
|
+
function invalidFilterError(message) {
|
|
166
|
+
const err = new Error(message);
|
|
167
|
+
err.code = import_api.StandardErrorCode.enum.INVALID_FILTER;
|
|
168
|
+
err.status = 400;
|
|
169
|
+
return err;
|
|
170
|
+
}
|
|
163
171
|
var MONGO_TO_CUBE_OP = {
|
|
164
172
|
$eq: "equals",
|
|
165
173
|
$ne: "notEquals",
|
|
@@ -174,12 +182,21 @@ var MONGO_TO_CUBE_OP = {
|
|
|
174
182
|
$startsWith: "startsWith",
|
|
175
183
|
$endsWith: "endsWith"
|
|
176
184
|
};
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
function comparand(v) {
|
|
186
|
+
return v === void 0 ? null : v;
|
|
187
|
+
}
|
|
188
|
+
var SQL_CONST_FALSE = "1 = 0";
|
|
189
|
+
var SQL_CONST_TRUE = "1 = 1";
|
|
190
|
+
function falseNode() {
|
|
191
|
+
return { kind: "const", value: false };
|
|
192
|
+
}
|
|
193
|
+
function notOf(inner) {
|
|
194
|
+
if (!inner) return falseNode();
|
|
195
|
+
if (inner.kind === "const") return { kind: "const", value: !inner.value };
|
|
196
|
+
return { kind: "not", child: inner };
|
|
197
|
+
}
|
|
198
|
+
function isFilterObject(v) {
|
|
199
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
183
200
|
}
|
|
184
201
|
function andOf(children) {
|
|
185
202
|
if (children.length === 0) return null;
|
|
@@ -197,18 +214,23 @@ function fieldLeaves(key, raw) {
|
|
|
197
214
|
}
|
|
198
215
|
if (typeof raw === "object" && !Array.isArray(raw) && !(raw instanceof Date)) {
|
|
199
216
|
const wrapper = raw;
|
|
217
|
+
if (Object.keys(wrapper).length === 0) {
|
|
218
|
+
throw invalidFilterError(
|
|
219
|
+
`[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.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
200
222
|
const opKeys = Object.keys(wrapper).filter((k) => k.startsWith("$"));
|
|
201
223
|
if (opKeys.length > 0) {
|
|
202
224
|
for (const opKey of opKeys) {
|
|
203
225
|
if (opKey === "$between") {
|
|
204
226
|
const v2 = wrapper[opKey];
|
|
205
227
|
if (!Array.isArray(v2) || v2.length !== 2) {
|
|
206
|
-
throw
|
|
228
|
+
throw invalidFilterError(
|
|
207
229
|
`[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.`
|
|
208
230
|
);
|
|
209
231
|
}
|
|
210
|
-
leaf("gte", [
|
|
211
|
-
leaf("lte", [
|
|
232
|
+
leaf("gte", [comparand(v2[0])]);
|
|
233
|
+
leaf("lte", [comparand(v2[1])]);
|
|
212
234
|
continue;
|
|
213
235
|
}
|
|
214
236
|
if (opKey === "$null" || opKey === "$exists") {
|
|
@@ -216,14 +238,33 @@ function fieldLeaves(key, raw) {
|
|
|
216
238
|
leaf(isNull ? "notSet" : "set", []);
|
|
217
239
|
continue;
|
|
218
240
|
}
|
|
241
|
+
if ((opKey === "$eq" || opKey === "$ne") && wrapper[opKey] === null) {
|
|
242
|
+
leaf(opKey === "$eq" ? "notSet" : "set", []);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if ((opKey === "$in" || opKey === "$nin") && Array.isArray(wrapper[opKey]) && wrapper[opKey].length === 0) {
|
|
246
|
+
out.push({ kind: "const", value: opKey === "$nin" });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
219
249
|
const cubeOp = MONGO_TO_CUBE_OP[opKey];
|
|
220
250
|
if (!cubeOp) {
|
|
221
|
-
throw
|
|
251
|
+
throw invalidFilterError(
|
|
222
252
|
`[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.`
|
|
223
253
|
);
|
|
224
254
|
}
|
|
225
255
|
const v = wrapper[opKey];
|
|
226
|
-
|
|
256
|
+
const values = Array.isArray(v) ? v.map(comparand) : [comparand(v)];
|
|
257
|
+
if (nullValueSatisfiesOperator(opKey, v) && !operatorIsNullTotal(opKey, v)) {
|
|
258
|
+
out.push({
|
|
259
|
+
kind: "or",
|
|
260
|
+
children: [
|
|
261
|
+
{ kind: "leaf", member: key, operator: "notSet", values: [] },
|
|
262
|
+
{ kind: "leaf", member: key, operator: cubeOp, values }
|
|
263
|
+
]
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
leaf(cubeOp, values);
|
|
227
268
|
}
|
|
228
269
|
return out;
|
|
229
270
|
}
|
|
@@ -232,8 +273,10 @@ function fieldLeaves(key, raw) {
|
|
|
232
273
|
}
|
|
233
274
|
return out;
|
|
234
275
|
}
|
|
235
|
-
if (Array.isArray(raw))
|
|
236
|
-
|
|
276
|
+
if (Array.isArray(raw)) {
|
|
277
|
+
if (raw.length === 0) out.push({ kind: "const", value: false });
|
|
278
|
+
else leaf("in", raw.map(comparand));
|
|
279
|
+
} else leaf("equals", [comparand(raw)]);
|
|
237
280
|
return out;
|
|
238
281
|
}
|
|
239
282
|
function buildNode(cond) {
|
|
@@ -241,24 +284,42 @@ function buildNode(cond) {
|
|
|
241
284
|
for (const [key, raw] of Object.entries(cond)) {
|
|
242
285
|
if (raw === void 0) continue;
|
|
243
286
|
if (key === "$and" || key === "$or") {
|
|
244
|
-
if (!Array.isArray(raw)
|
|
245
|
-
throw
|
|
246
|
-
`[analytics] "${key}" requires
|
|
287
|
+
if (!Array.isArray(raw)) {
|
|
288
|
+
throw invalidFilterError(
|
|
289
|
+
`[analytics] "${key}" requires an array of filter objects, got ${JSON.stringify(raw)}. Dropping it would silently widen the query to rows the filter excludes.`
|
|
247
290
|
);
|
|
248
291
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
292
|
+
if (raw.length === 0) {
|
|
293
|
+
if (key === "$or") children.push(falseNode());
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const branches = raw.map((sub) => {
|
|
297
|
+
if (!isFilterObject(sub)) {
|
|
298
|
+
throw invalidFilterError(
|
|
299
|
+
`[analytics] "${key}" branches must be filter objects, got ${JSON.stringify(sub)}. Skipping it would silently change which rows the filter admits.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return buildNode(sub);
|
|
303
|
+
});
|
|
304
|
+
if (key === "$or" && branches.some((n) => n === null)) continue;
|
|
305
|
+
const kept = branches.filter((n) => n !== null);
|
|
306
|
+
if (kept.length === 0) continue;
|
|
307
|
+
if (key === "$and") children.push(...kept);
|
|
308
|
+
else children.push(kept.length === 1 ? kept[0] : { kind: "or", children: kept });
|
|
253
309
|
continue;
|
|
254
310
|
}
|
|
255
311
|
if (key === "$not") {
|
|
256
|
-
|
|
257
|
-
|
|
312
|
+
if (!isFilterObject(raw)) {
|
|
313
|
+
throw invalidFilterError(
|
|
314
|
+
`[analytics] "$not" requires a filter object, got ${JSON.stringify(raw)}. Dropping it would silently widen the query to rows the filter excludes.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const inner = buildNode(nullSafeNegationOperand(raw));
|
|
318
|
+
children.push(notOf(inner));
|
|
258
319
|
continue;
|
|
259
320
|
}
|
|
260
321
|
if (key.startsWith("$")) {
|
|
261
|
-
throw
|
|
322
|
+
throw invalidFilterError(
|
|
262
323
|
`[analytics] Unsupported top-level filter operator "${key}". Dropping it would silently widen the query to rows the filter excludes.`
|
|
263
324
|
);
|
|
264
325
|
}
|
|
@@ -266,43 +327,194 @@ function buildNode(cond) {
|
|
|
266
327
|
}
|
|
267
328
|
return andOf(children);
|
|
268
329
|
}
|
|
269
|
-
function
|
|
330
|
+
function nullValueSatisfiesOperator(op, value) {
|
|
331
|
+
switch (op) {
|
|
332
|
+
// [#5332] `$eq: null` IS the null predicate — a NULL column satisfies it,
|
|
333
|
+
// and no other comparand does.
|
|
334
|
+
case "$eq":
|
|
335
|
+
return value === null;
|
|
336
|
+
// Mirror image: `$ne: null` compiles to `set` (`IS NOT NULL`), which a NULL
|
|
337
|
+
// column FAILS. Any other comparand is the two-valued JS `!==`, which an
|
|
338
|
+
// absent value passes — the arm this used to be for every comparand.
|
|
339
|
+
case "$ne":
|
|
340
|
+
return value !== null;
|
|
341
|
+
case "$null":
|
|
342
|
+
return value === true;
|
|
343
|
+
case "$exists":
|
|
344
|
+
return value === false;
|
|
345
|
+
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
346
|
+
case "$nin":
|
|
347
|
+
return true;
|
|
348
|
+
// `$notContains` is the one operator where the two JS backends disagree for
|
|
349
|
+
// a null-valued field (`driver-memory` answers false, `formula` true).
|
|
350
|
+
// `formula` is followed because `driver-sql` and `read-scope-sql` follow it,
|
|
351
|
+
// so this module casts no vote on a disagreement that is filed elsewhere.
|
|
352
|
+
case "$notContains":
|
|
353
|
+
return true;
|
|
354
|
+
default:
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function operatorIsNullTotal(op, value) {
|
|
359
|
+
switch (op) {
|
|
360
|
+
// Compile to `set` / `notSet` — `IS NULL` / `IS NOT NULL`, two-valued by
|
|
361
|
+
// construction, on every strategy that compiles this tree.
|
|
362
|
+
case "$null":
|
|
363
|
+
case "$exists":
|
|
364
|
+
return true;
|
|
365
|
+
// [#5332] A `null` comparand makes these null PREDICATES too — `notSet` /
|
|
366
|
+
// `set`, not comparisons — so they are total by construction and take NO
|
|
367
|
+
// guard. Left out, `{$not: {stage: {$eq: null}}}` wrapped `stage IS NOT NULL
|
|
368
|
+
// AND stage IS NULL` (an always-false conjunction) and negated it to EVERY
|
|
369
|
+
// row, for a filter meaning "stage is not empty".
|
|
370
|
+
case "$eq":
|
|
371
|
+
case "$ne":
|
|
372
|
+
return value === null;
|
|
373
|
+
// An EMPTY set compiles to a boolean CONSTANT (see `fieldLeaves`), and a
|
|
374
|
+
// constant is total. Wrapping a guard around it would only add a redundant
|
|
375
|
+
// conjunct to a predicate whose value is already decided.
|
|
376
|
+
case "$in":
|
|
377
|
+
case "$nin":
|
|
378
|
+
return Array.isArray(value) && value.length === 0;
|
|
379
|
+
default:
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
function nullGuardForFieldSpec(spec) {
|
|
384
|
+
if (spec === null) return "none";
|
|
385
|
+
if (Array.isArray(spec)) return spec.length === 0 ? "none" : "requireValue";
|
|
386
|
+
if (typeof spec !== "object" || spec instanceof Date) return "requireValue";
|
|
387
|
+
const entries = Object.entries(spec);
|
|
388
|
+
if (entries.length === 0) return "none";
|
|
389
|
+
let total = true;
|
|
390
|
+
let nullSatisfies = true;
|
|
391
|
+
for (const [op, value] of entries) {
|
|
392
|
+
if (!operatorIsNullTotal(op, value)) total = false;
|
|
393
|
+
if (!nullValueSatisfiesOperator(op, value)) nullSatisfies = false;
|
|
394
|
+
}
|
|
395
|
+
if (total) return "none";
|
|
396
|
+
return nullSatisfies ? "allowNull" : "requireValue";
|
|
397
|
+
}
|
|
398
|
+
function guardFieldEntry(key, spec, out, guarded) {
|
|
399
|
+
if (isFilterObject(spec) && Object.keys(spec).length > 0 && !Object.keys(spec).some((k) => k.startsWith("$"))) {
|
|
400
|
+
for (const [nested, value] of Object.entries(spec)) {
|
|
401
|
+
guardFieldEntry(`${key}.${nested}`, value, out, guarded);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const guard = nullGuardForFieldSpec(spec);
|
|
406
|
+
if (guard === "none") {
|
|
407
|
+
out[key] = spec;
|
|
408
|
+
} else if (guard === "requireValue") {
|
|
409
|
+
guarded.push({ [key]: { $null: false } }, { [key]: spec });
|
|
410
|
+
} else {
|
|
411
|
+
guarded.push({ $or: [{ [key]: { $null: true } }, { [key]: spec }] });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function nullSafeNegationOperand(node) {
|
|
415
|
+
const out = {};
|
|
416
|
+
const guarded = [];
|
|
417
|
+
for (const [key, value] of Object.entries(node)) {
|
|
418
|
+
if ((key === "$and" || key === "$or") && Array.isArray(value)) {
|
|
419
|
+
out[key] = value.map((element) => isFilterObject(element) ? nullSafeNegationOperand(element) : element);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (key.startsWith("$")) {
|
|
423
|
+
out[key] = value;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
guardFieldEntry(key, value, out, guarded);
|
|
427
|
+
}
|
|
428
|
+
if (guarded.length > 0) {
|
|
429
|
+
const existing = Array.isArray(out.$and) ? out.$and : [];
|
|
430
|
+
out.$and = [...existing, ...guarded];
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
function filterArrayNotLowerableError(where) {
|
|
435
|
+
return invalidFilterError(
|
|
436
|
+
`[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: ${[...import_data.VALID_AST_OPERATORS].sort().join(", ")}. Infix joins ([condA, "or", condB]) are NOT one of the shapes \u2014 write the prefix form ["or", condA, condB].`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
function lowerAnalyticsWhere(query) {
|
|
270
440
|
if (!query || typeof query !== "object") return null;
|
|
271
441
|
const where = query.where;
|
|
272
|
-
if (!where || typeof where !== "object"
|
|
273
|
-
|
|
442
|
+
if (!where || typeof where !== "object") return null;
|
|
443
|
+
if (Array.isArray(where)) {
|
|
444
|
+
if (where.length === 0) return null;
|
|
445
|
+
if (!(0, import_data.isFilterAST)(where)) throw filterArrayNotLowerableError(where);
|
|
446
|
+
const condition = (0, import_data.parseFilterAST)(where);
|
|
447
|
+
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
448
|
+
throw invalidFilterError(
|
|
449
|
+
`[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).`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return condition;
|
|
453
|
+
}
|
|
454
|
+
return where;
|
|
455
|
+
}
|
|
456
|
+
function conjunctFieldKeys(condition) {
|
|
457
|
+
const keys = [];
|
|
458
|
+
const walk = (cond) => {
|
|
459
|
+
for (const [key, value] of Object.entries(cond)) {
|
|
460
|
+
if (key === "$and" && Array.isArray(value)) {
|
|
461
|
+
for (const child of value) {
|
|
462
|
+
if (isFilterObject(child)) walk(child);
|
|
463
|
+
}
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (key.startsWith("$")) continue;
|
|
467
|
+
keys.push(key);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
walk(condition);
|
|
471
|
+
return keys;
|
|
472
|
+
}
|
|
473
|
+
function normalizeAnalyticsFilterTree(query) {
|
|
474
|
+
const condition = lowerAnalyticsWhere(query);
|
|
475
|
+
if (!condition) return null;
|
|
476
|
+
return buildNode(condition);
|
|
274
477
|
}
|
|
275
478
|
function collectFilterLeaves(node) {
|
|
276
479
|
if (!node) return [];
|
|
277
480
|
if (node.kind === "leaf") return [{ member: node.member, operator: node.operator, values: node.values }];
|
|
481
|
+
if (node.kind === "const") return [];
|
|
278
482
|
if (node.kind === "not") return collectFilterLeaves(node.child);
|
|
279
483
|
return node.children.flatMap(collectFilterLeaves);
|
|
280
484
|
}
|
|
281
|
-
function
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return void 0;
|
|
485
|
+
function toSqlBindValue(v) {
|
|
486
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
487
|
+
if (v instanceof Date) return v.toISOString();
|
|
488
|
+
if (v !== null && typeof v === "object") return JSON.stringify(v);
|
|
489
|
+
return v;
|
|
287
490
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return
|
|
491
|
+
|
|
492
|
+
// src/like-pattern.ts
|
|
493
|
+
var LIKE_ESCAPE_CHAR = "\\";
|
|
494
|
+
function escapeLikePattern(value) {
|
|
495
|
+
return String(value).replace(/[\\%_]/g, "\\$&");
|
|
293
496
|
}
|
|
294
|
-
function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (s === "null") return null;
|
|
298
|
-
return recoverNumber(s) ?? s;
|
|
497
|
+
function likePattern(shape, value) {
|
|
498
|
+
const escaped = escapeLikePattern(value);
|
|
499
|
+
return shape === "starts" ? `${escaped}%` : shape === "ends" ? `%${escaped}` : `%${escaped}%`;
|
|
299
500
|
}
|
|
300
501
|
|
|
301
502
|
// src/read-scope-sql.ts
|
|
302
503
|
var IDENT = /^[a-z_][a-z0-9_]*$/i;
|
|
504
|
+
var READ_SCOPE_COMPILE_FAILED = "READ_SCOPE_COMPILE_FAILED";
|
|
505
|
+
function readScopeCompileError(message) {
|
|
506
|
+
const err = new Error(message);
|
|
507
|
+
err.code = READ_SCOPE_COMPILE_FAILED;
|
|
508
|
+
err.status = 500;
|
|
509
|
+
return err;
|
|
510
|
+
}
|
|
511
|
+
var FALSE_CLAUSE = "1 = 0";
|
|
512
|
+
function isFilterNode(v) {
|
|
513
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
514
|
+
}
|
|
303
515
|
function quoteIdent(name, kind) {
|
|
304
516
|
if (typeof name !== "string" || !IDENT.test(name)) {
|
|
305
|
-
throw
|
|
517
|
+
throw readScopeCompileError(`[read-scope-sql] unsafe ${kind} identifier "${String(name)}" \u2014 refusing to build read scope (fail-closed).`);
|
|
306
518
|
}
|
|
307
519
|
return `"${name}"`;
|
|
308
520
|
}
|
|
@@ -312,25 +524,43 @@ function compileScopedFilterToSql(filter, alias) {
|
|
|
312
524
|
const sql = compileNode(filter, quotedAlias, params);
|
|
313
525
|
return { sql, params };
|
|
314
526
|
}
|
|
527
|
+
function compileSub(node, qAlias) {
|
|
528
|
+
const params = [];
|
|
529
|
+
const sql = compileNode(node, qAlias, params);
|
|
530
|
+
return { sql, params };
|
|
531
|
+
}
|
|
315
532
|
function compileNode(node, qAlias, params) {
|
|
316
|
-
if (
|
|
317
|
-
throw
|
|
533
|
+
if (!isFilterNode(node)) {
|
|
534
|
+
throw readScopeCompileError("[read-scope-sql] read scope must be a filter object (fail-closed).");
|
|
318
535
|
}
|
|
319
536
|
const clauses = [];
|
|
320
537
|
for (const [key, value] of Object.entries(node)) {
|
|
321
538
|
if (key === "$and" || key === "$or") {
|
|
322
|
-
if (!Array.isArray(value)
|
|
323
|
-
throw
|
|
539
|
+
if (!Array.isArray(value)) {
|
|
540
|
+
throw readScopeCompileError(`[read-scope-sql] "${key}" requires an array (fail-closed).`);
|
|
324
541
|
}
|
|
325
|
-
|
|
326
|
-
|
|
542
|
+
if (value.length === 0) {
|
|
543
|
+
if (key === "$or") clauses.push(FALSE_CLAUSE);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const compiled = value.map((child) => compileSub(child, qAlias));
|
|
547
|
+
if (key === "$or" && compiled.some((c) => c.sql.length === 0)) continue;
|
|
548
|
+
const kept = compiled.filter((c) => c.sql.length > 0);
|
|
549
|
+
if (kept.length === 0) continue;
|
|
550
|
+
for (const part of kept) params.push(...part.params);
|
|
327
551
|
const joiner = key === "$and" ? " AND " : " OR ";
|
|
328
|
-
clauses.push(`(${
|
|
552
|
+
clauses.push(`(${kept.map((c) => c.sql).join(joiner)})`);
|
|
329
553
|
} else if (key === "$not") {
|
|
330
|
-
const
|
|
331
|
-
|
|
554
|
+
const operand = isFilterNode(value) ? nullSafeNegationOperand2(value) : value;
|
|
555
|
+
const inner = compileSub(operand, qAlias);
|
|
556
|
+
if (inner.sql.length === 0) {
|
|
557
|
+
clauses.push(FALSE_CLAUSE);
|
|
558
|
+
} else {
|
|
559
|
+
params.push(...inner.params);
|
|
560
|
+
clauses.push(`NOT (${inner.sql})`);
|
|
561
|
+
}
|
|
332
562
|
} else if (key.startsWith("$")) {
|
|
333
|
-
throw
|
|
563
|
+
throw readScopeCompileError(`[read-scope-sql] unsupported top-level operator "${key}" (fail-closed).`);
|
|
334
564
|
} else {
|
|
335
565
|
clauses.push(compileField(key, value, qAlias, params));
|
|
336
566
|
}
|
|
@@ -345,12 +575,12 @@ function compileField(field, value, qAlias, params) {
|
|
|
345
575
|
return `${col} = ?`;
|
|
346
576
|
}
|
|
347
577
|
if (Array.isArray(value)) {
|
|
348
|
-
throw
|
|
578
|
+
throw readScopeCompileError(`[read-scope-sql] bare array value for "${field}" \u2014 use { $in: [...] } (fail-closed).`);
|
|
349
579
|
}
|
|
350
580
|
const ops = value;
|
|
351
581
|
const keys = Object.keys(ops);
|
|
352
582
|
if (keys.length === 0 || keys.some((k) => !k.startsWith("$"))) {
|
|
353
|
-
throw
|
|
583
|
+
throw readScopeCompileError(`[read-scope-sql] "${field}" has a nested/relation value which is not supported in a read scope (fail-closed).`);
|
|
354
584
|
}
|
|
355
585
|
const parts = [];
|
|
356
586
|
for (const op of keys) {
|
|
@@ -362,12 +592,20 @@ function bind(params, v) {
|
|
|
362
592
|
params.push(v);
|
|
363
593
|
return "?";
|
|
364
594
|
}
|
|
595
|
+
function bindLike(params, pattern) {
|
|
596
|
+
return `${bind(params, pattern)} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
|
|
597
|
+
}
|
|
598
|
+
function nullSafeNegative(col, test) {
|
|
599
|
+
return `(${col} IS NULL OR ${test})`;
|
|
600
|
+
}
|
|
365
601
|
function compileOperator(col, op, val, field, params) {
|
|
366
602
|
switch (op) {
|
|
367
603
|
case "$eq":
|
|
368
604
|
return val === null ? `${col} IS NULL` : `${col} = ${bind(params, val)}`;
|
|
605
|
+
// [#5298] `$ne: null` stays `IS NOT NULL` — already total, and "has any
|
|
606
|
+
// value" is false for a row that has none. Only the comparison is guarded.
|
|
369
607
|
case "$ne":
|
|
370
|
-
return val === null ? `${col} IS NOT NULL` : `${col} <> ${bind(params, val)}
|
|
608
|
+
return val === null ? `${col} IS NOT NULL` : nullSafeNegative(col, `${col} <> ${bind(params, val)}`);
|
|
371
609
|
case "$gt":
|
|
372
610
|
return `${col} > ${bind(params, val)}`;
|
|
373
611
|
case "$gte":
|
|
@@ -377,35 +615,139 @@ function compileOperator(col, op, val, field, params) {
|
|
|
377
615
|
case "$lte":
|
|
378
616
|
return `${col} <= ${bind(params, val)}`;
|
|
379
617
|
case "$in": {
|
|
380
|
-
if (!Array.isArray(val)) throw
|
|
381
|
-
if (val.length === 0) return
|
|
618
|
+
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $in for "${field}" needs an array (fail-closed).`);
|
|
619
|
+
if (val.length === 0) return FALSE_CLAUSE;
|
|
382
620
|
return `${col} IN (${val.map((v) => bind(params, v)).join(", ")})`;
|
|
383
621
|
}
|
|
384
622
|
case "$nin": {
|
|
385
|
-
if (!Array.isArray(val)) throw
|
|
623
|
+
if (!Array.isArray(val)) throw readScopeCompileError(`[read-scope-sql] $nin for "${field}" needs an array (fail-closed).`);
|
|
386
624
|
if (val.length === 0) return "1 = 1";
|
|
387
|
-
return `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})
|
|
625
|
+
return nullSafeNegative(col, `${col} NOT IN (${val.map((v) => bind(params, v)).join(", ")})`);
|
|
388
626
|
}
|
|
389
627
|
case "$between": {
|
|
390
|
-
if (!Array.isArray(val) || val.length !== 2) throw
|
|
628
|
+
if (!Array.isArray(val) || val.length !== 2) throw readScopeCompileError(`[read-scope-sql] $between for "${field}" needs [min,max] (fail-closed).`);
|
|
391
629
|
return `${col} BETWEEN ${bind(params, val[0])} AND ${bind(params, val[1])}`;
|
|
392
630
|
}
|
|
631
|
+
// [#5567] The comparand is a LITERAL, so it is escaped and the escape
|
|
632
|
+
// character is bound with it. See {@link bindLike}.
|
|
393
633
|
case "$contains":
|
|
394
|
-
return `${col} LIKE ${
|
|
634
|
+
return `${col} LIKE ${bindLike(params, likePattern("contains", val))}`;
|
|
635
|
+
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
|
|
636
|
+
// contain" is true of a value that is not there.
|
|
395
637
|
case "$notContains":
|
|
396
|
-
return `${col} NOT LIKE ${
|
|
638
|
+
return nullSafeNegative(col, `${col} NOT LIKE ${bindLike(params, likePattern("contains", val))}`);
|
|
397
639
|
case "$startsWith":
|
|
398
|
-
return `${col} LIKE ${
|
|
640
|
+
return `${col} LIKE ${bindLike(params, likePattern("starts", val))}`;
|
|
399
641
|
case "$endsWith":
|
|
400
|
-
return `${col} LIKE ${
|
|
642
|
+
return `${col} LIKE ${bindLike(params, likePattern("ends", val))}`;
|
|
401
643
|
case "$null":
|
|
402
644
|
return val ? `${col} IS NULL` : `${col} IS NOT NULL`;
|
|
403
645
|
case "$exists":
|
|
404
646
|
return val ? `${col} IS NOT NULL` : `${col} IS NULL`;
|
|
405
647
|
default:
|
|
406
|
-
throw
|
|
648
|
+
throw readScopeCompileError(`[read-scope-sql] unsupported operator "${op}" on "${field}" (fail-closed).`);
|
|
407
649
|
}
|
|
408
650
|
}
|
|
651
|
+
function nullValueSatisfiesOperator2(op, value) {
|
|
652
|
+
switch (op) {
|
|
653
|
+
// `$eq: null` IS the null predicate; any other comparand is a value test.
|
|
654
|
+
case "$eq":
|
|
655
|
+
return value === null;
|
|
656
|
+
// Mirror image: `$ne: null` compiles to `IS NOT NULL`, which a NULL fails.
|
|
657
|
+
case "$ne":
|
|
658
|
+
return value !== null;
|
|
659
|
+
// Truthiness, matching this file's emitter (see the note above).
|
|
660
|
+
case "$null":
|
|
661
|
+
return Boolean(value);
|
|
662
|
+
case "$exists":
|
|
663
|
+
return !value;
|
|
664
|
+
// Negative-polarity set / substring tests hold vacuously for an absent value.
|
|
665
|
+
case "$nin":
|
|
666
|
+
return true;
|
|
667
|
+
// `$notContains` is the one operator where the two JS backends disagree for
|
|
668
|
+
// a null-valued field (`driver-memory` answers false, `formula` true).
|
|
669
|
+
// `formula` is followed because `driver-sql` follows it, so this compiler
|
|
670
|
+
// does not cast a vote on a disagreement that is filed elsewhere.
|
|
671
|
+
case "$notContains":
|
|
672
|
+
return true;
|
|
673
|
+
default:
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function operatorIsNullTotal2(op, value) {
|
|
678
|
+
switch (op) {
|
|
679
|
+
// Compile to `IS NULL` / `IS NOT NULL` — two-valued by construction.
|
|
680
|
+
case "$null":
|
|
681
|
+
case "$exists":
|
|
682
|
+
return true;
|
|
683
|
+
// A null comparand makes these null PREDICATES too, not comparisons.
|
|
684
|
+
case "$eq":
|
|
685
|
+
case "$ne":
|
|
686
|
+
return value === null;
|
|
687
|
+
default:
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
function nullGuardForFieldSpec2(spec) {
|
|
692
|
+
if (spec === null) return "none";
|
|
693
|
+
if (typeof spec !== "object" || spec instanceof Date || Array.isArray(spec)) return "requireValue";
|
|
694
|
+
const entries = Object.entries(spec);
|
|
695
|
+
if (entries.length === 0) return "none";
|
|
696
|
+
let total = true;
|
|
697
|
+
let nullSatisfies = true;
|
|
698
|
+
for (const [op, value] of entries) {
|
|
699
|
+
if (!operatorIsNullTotal2(op, value)) total = false;
|
|
700
|
+
if (!nullValueSatisfiesOperator2(op, value)) nullSatisfies = false;
|
|
701
|
+
}
|
|
702
|
+
if (total) return "none";
|
|
703
|
+
return nullSatisfies ? "allowNull" : "requireValue";
|
|
704
|
+
}
|
|
705
|
+
function nullSafeNegationOperand2(node) {
|
|
706
|
+
const out = {};
|
|
707
|
+
const guarded = [];
|
|
708
|
+
for (const [key, value] of Object.entries(node)) {
|
|
709
|
+
if ((key === "$and" || key === "$or") && Array.isArray(value)) {
|
|
710
|
+
out[key] = value.map((element) => isFilterNode(element) ? nullSafeNegationOperand2(element) : element);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (key.startsWith("$")) {
|
|
714
|
+
out[key] = value;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const guard = nullGuardForFieldSpec2(value);
|
|
718
|
+
if (guard === "none") {
|
|
719
|
+
out[key] = value;
|
|
720
|
+
} else if (guard === "requireValue") {
|
|
721
|
+
guarded.push({ [key]: { $null: false } }, { [key]: value });
|
|
722
|
+
} else {
|
|
723
|
+
guarded.push({ $or: [{ [key]: { $null: true } }, { [key]: value }] });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (guarded.length > 0) {
|
|
727
|
+
const existing = Array.isArray(out.$and) ? out.$and : [];
|
|
728
|
+
out.$and = [...existing, ...guarded];
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/dataset-refusal.ts
|
|
734
|
+
var DATASET_INVALID = "DATASET_INVALID";
|
|
735
|
+
var INVALID_FIELD = "INVALID_FIELD";
|
|
736
|
+
function datasetInvalidError(message) {
|
|
737
|
+
const err = new Error(message);
|
|
738
|
+
err.code = DATASET_INVALID;
|
|
739
|
+
err.status = 400;
|
|
740
|
+
return err;
|
|
741
|
+
}
|
|
742
|
+
function invalidMemberError(message, meta) {
|
|
743
|
+
const err = new Error(message);
|
|
744
|
+
err.code = INVALID_FIELD;
|
|
745
|
+
err.status = 400;
|
|
746
|
+
err.member = meta.member;
|
|
747
|
+
if (meta.param) err.param = meta.param;
|
|
748
|
+
if (meta.cube) err.cube = meta.cube;
|
|
749
|
+
return err;
|
|
750
|
+
}
|
|
409
751
|
|
|
410
752
|
// src/strategies/native-sql-strategy.ts
|
|
411
753
|
var import_core = require("@objectstack/core");
|
|
@@ -509,7 +851,7 @@ var NativeSQLStrategy = class {
|
|
|
509
851
|
if (allowed) {
|
|
510
852
|
for (const alias of joins.keys()) {
|
|
511
853
|
if (!allowed.has(alias)) {
|
|
512
|
-
throw
|
|
854
|
+
throw datasetInvalidError(
|
|
513
855
|
`[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\`.`
|
|
514
856
|
);
|
|
515
857
|
}
|
|
@@ -660,8 +1002,9 @@ var NativeSQLStrategy = class {
|
|
|
660
1002
|
const measure = this.lookupMember(cube, member, "measure");
|
|
661
1003
|
if (!measure) {
|
|
662
1004
|
const declared = Object.keys(cube.measures ?? {});
|
|
663
|
-
throw
|
|
664
|
-
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)")
|
|
1005
|
+
throw invalidMemberError(
|
|
1006
|
+
`[native-sql-strategy] cube "${cube.name}" declares no measure "${member}"` + (declared.length ? ` (declared: ${declared.join(", ")})` : " (it declares none)"),
|
|
1007
|
+
{ member, param: "measures", cube: cube.name }
|
|
665
1008
|
);
|
|
666
1009
|
}
|
|
667
1010
|
const col = measure.sql === "*" ? "*" : this.qualifyAndRegisterJoin(measure.sql, parentTable, joins, cube);
|
|
@@ -712,16 +1055,24 @@ var NativeSQLStrategy = class {
|
|
|
712
1055
|
* driver-backed `coerceTemporalFilterValue` hook (single source of truth for
|
|
713
1056
|
* the date/datetime storage convention — see StrategyContext); when the hook
|
|
714
1057
|
* is absent, or returns the value unchanged (the field is not a temporal
|
|
715
|
-
* column, or the dialect stores it as a native timestamp), falls back to
|
|
716
|
-
*
|
|
717
|
-
*
|
|
1058
|
+
* column, or the dialect stores it as a native timestamp), falls back to
|
|
1059
|
+
* {@link toSqlBindValue} so an unbindable JS type still reaches the driver as
|
|
1060
|
+
* something it can bind.
|
|
1061
|
+
*
|
|
1062
|
+
* [#5526] `value` is `unknown`, not `string`, because a leaf now carries the
|
|
1063
|
+
* author's comparand at its own type. Both halves of this method were already
|
|
1064
|
+
* `unknown`-typed for it: the hook's contract is
|
|
1065
|
+
* `coerceTemporalFilterValue(object, field, value: unknown)` and the fallback
|
|
1066
|
+
* converts only what a driver cannot bind. What CHANGED is that a string is no
|
|
1067
|
+
* longer re-typed on the way out — the fallback used to be
|
|
1068
|
+
* `coerceFilterValueForSql`, which read `'007'` as the integer `7`.
|
|
718
1069
|
*/
|
|
719
1070
|
coerceTemporal(ctx, target, value) {
|
|
720
1071
|
if (typeof ctx.coerceTemporalFilterValue === "function") {
|
|
721
1072
|
const coerced = ctx.coerceTemporalFilterValue(target.object, target.field, value);
|
|
722
1073
|
if (coerced !== value) return coerced;
|
|
723
1074
|
}
|
|
724
|
-
return
|
|
1075
|
+
return toSqlBindValue(value);
|
|
725
1076
|
}
|
|
726
1077
|
/**
|
|
727
1078
|
* The column side of {@link coerceTemporal}: normalise the reference so it
|
|
@@ -752,9 +1103,31 @@ var NativeSQLStrategy = class {
|
|
|
752
1103
|
* does bind tighter than `OR`, so `a AND b OR c` happens to be right, but
|
|
753
1104
|
* being right by construction is what keeps a future edit from making it
|
|
754
1105
|
* wrong.
|
|
1106
|
+
*
|
|
1107
|
+
* # `null` is the constant TRUE, and TRUE absorbs a disjunction (#5325)
|
|
1108
|
+
*
|
|
1109
|
+
* A `null` return means "constrains nothing", which is the boolean TRUE — the
|
|
1110
|
+
* AND identity, so it drops out of an `and`, but the OR ABSORBER, so one TRUE
|
|
1111
|
+
* disjunct makes the whole `or` TRUE. Filtering it out of an `or` narrowed the
|
|
1112
|
+
* query to the surviving branches. `NOT TRUE ≡ FALSE`, so a negation whose
|
|
1113
|
+
* operand constrains nothing compiles to the FALSE constant rather than
|
|
1114
|
+
* disappearing (which added no `WHERE` and charted every row).
|
|
1115
|
+
*
|
|
1116
|
+
* # The invariant that keeps `params` aligned
|
|
1117
|
+
*
|
|
1118
|
+
* **A call that returns `null` leaves `params` exactly as it found it.** It
|
|
1119
|
+
* has to: a value bound with no `$n` to consume it shifts every later
|
|
1120
|
+
* placeholder onto the wrong value, and a filter that binds the WRONG comparand
|
|
1121
|
+
* is worse than one that is merely too wide (#5297). Leaves decide emptiness
|
|
1122
|
+
* before they bind, and the absorbing `or` — the one place a clause that HAS
|
|
1123
|
+
* bound is discarded — truncates back to the length it started at, so the
|
|
1124
|
+
* invariant holds inductively for every node kind.
|
|
755
1125
|
*/
|
|
756
1126
|
compileFilterNode(node, cube, parentTable, joins, params, ctx) {
|
|
757
1127
|
if (!node) return null;
|
|
1128
|
+
if (node.kind === "const") {
|
|
1129
|
+
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1130
|
+
}
|
|
758
1131
|
if (node.kind === "leaf") {
|
|
759
1132
|
const colExpr = this.resolveFieldSql(cube, node.member, parentTable, joins);
|
|
760
1133
|
const target = this.resolveStorageTarget(cube, node.member, parentTable);
|
|
@@ -762,9 +1135,22 @@ var NativeSQLStrategy = class {
|
|
|
762
1135
|
}
|
|
763
1136
|
if (node.kind === "not") {
|
|
764
1137
|
const inner = this.compileFilterNode(node.child, cube, parentTable, joins, params, ctx);
|
|
765
|
-
return inner ? `NOT (${inner})` :
|
|
1138
|
+
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
1139
|
+
}
|
|
1140
|
+
const paramBase = params.length;
|
|
1141
|
+
const joinBase = new Map(joins);
|
|
1142
|
+
const parts = [];
|
|
1143
|
+
for (const child of node.children) {
|
|
1144
|
+
const clause = this.compileFilterNode(child, cube, parentTable, joins, params, ctx);
|
|
1145
|
+
if (clause === null) {
|
|
1146
|
+
if (node.kind !== "or") continue;
|
|
1147
|
+
params.length = paramBase;
|
|
1148
|
+
joins.clear();
|
|
1149
|
+
for (const [alias, clauseSql] of joinBase) joins.set(alias, clauseSql);
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
parts.push(clause);
|
|
766
1153
|
}
|
|
767
|
-
const parts = node.children.map((child) => this.compileFilterNode(child, cube, parentTable, joins, params, ctx)).filter((s) => !!s);
|
|
768
1154
|
if (parts.length === 0) return null;
|
|
769
1155
|
if (parts.length === 1) return parts[0];
|
|
770
1156
|
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
@@ -782,11 +1168,11 @@ var NativeSQLStrategy = class {
|
|
|
782
1168
|
startsWith: "LIKE",
|
|
783
1169
|
endsWith: "LIKE"
|
|
784
1170
|
};
|
|
785
|
-
const
|
|
786
|
-
contains:
|
|
787
|
-
notContains:
|
|
788
|
-
startsWith:
|
|
789
|
-
endsWith:
|
|
1171
|
+
const likeShape = {
|
|
1172
|
+
contains: "contains",
|
|
1173
|
+
notContains: "contains",
|
|
1174
|
+
startsWith: "starts",
|
|
1175
|
+
endsWith: "ends"
|
|
790
1176
|
};
|
|
791
1177
|
if (operator === "set") return `${rawCol} IS NOT NULL`;
|
|
792
1178
|
if (operator === "notSet") return `${rawCol} IS NULL`;
|
|
@@ -800,10 +1186,12 @@ var NativeSQLStrategy = class {
|
|
|
800
1186
|
}
|
|
801
1187
|
const sqlOp = opMap[operator];
|
|
802
1188
|
if (!sqlOp || !values || values.length === 0) return null;
|
|
803
|
-
const
|
|
804
|
-
if (
|
|
805
|
-
params.push(
|
|
806
|
-
|
|
1189
|
+
const shape = likeShape[operator];
|
|
1190
|
+
if (shape) {
|
|
1191
|
+
params.push(likePattern(shape, values[0]));
|
|
1192
|
+
const patternRef = `$${params.length}`;
|
|
1193
|
+
params.push(LIKE_ESCAPE_CHAR);
|
|
1194
|
+
return `${rawCol} ${sqlOp} ${patternRef} ESCAPE $${params.length}`;
|
|
807
1195
|
}
|
|
808
1196
|
if (operator === "lte") {
|
|
809
1197
|
const nextDay = (0, import_core.nextUtcCalendarDay)(values[0]);
|
|
@@ -902,6 +1290,12 @@ var SCALAR_SQL_OPS = {
|
|
|
902
1290
|
lt: "<",
|
|
903
1291
|
lte: "<="
|
|
904
1292
|
};
|
|
1293
|
+
var LIKE_SQL_OPS = {
|
|
1294
|
+
contains: { sql: "LIKE", shape: "contains" },
|
|
1295
|
+
notContains: { sql: "NOT LIKE", shape: "contains" },
|
|
1296
|
+
startsWith: { sql: "LIKE", shape: "starts" },
|
|
1297
|
+
endsWith: { sql: "LIKE", shape: "ends" }
|
|
1298
|
+
};
|
|
905
1299
|
var ObjectQLStrategy = class {
|
|
906
1300
|
constructor() {
|
|
907
1301
|
this.name = "ObjectQLStrategy";
|
|
@@ -1145,6 +1539,17 @@ var ObjectQLStrategy = class {
|
|
|
1145
1539
|
* cannot be merged). A loud error beats the silent mis-bucket #3654 kills.
|
|
1146
1540
|
* `generateSql()` calls this too, so the preview accepts/rejects the same set.
|
|
1147
1541
|
*
|
|
1542
|
+
* [#5716] All four refusals below are `invalidMemberError` — `INVALID_FIELD` /
|
|
1543
|
+
* 400, naming the member — and the MESSAGES are unchanged (they are good
|
|
1544
|
+
* diagnostics, and #5923's tests read them). Each is decided by two caller-side
|
|
1545
|
+
* facts and nothing else: a member the query named, and whether that member
|
|
1546
|
+
* resolves across a join. Neither is an internal invariant — a cube where the
|
|
1547
|
+
* member exists and a driver that could serve it are both perfectly ordinary,
|
|
1548
|
+
* which is exactly what the "run this on a native-SQL driver" half of each
|
|
1549
|
+
* message says. They are member-level rather than dataset-level (hence not
|
|
1550
|
+
* `datasetInvalidError`) because the fix is always to change or drop ONE named
|
|
1551
|
+
* member, and because they fire on `/analytics/query` where no dataset exists.
|
|
1552
|
+
*
|
|
1148
1553
|
* Detection is on RESOLVED field names, so a dotted dimension the cube
|
|
1149
1554
|
* flattens to a real column is treated as base, not cross-object.
|
|
1150
1555
|
*/
|
|
@@ -1153,18 +1558,30 @@ var ObjectQLStrategy = class {
|
|
|
1153
1558
|
for (const td of query.timeDimensions ?? []) {
|
|
1154
1559
|
const field = this.resolveFieldName(cube, td.dimension, "dimension");
|
|
1155
1560
|
if (this.isCrossObjectField(cube, field, baseObject)) {
|
|
1156
|
-
throw
|
|
1157
|
-
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}")
|
|
1561
|
+
throw invalidMemberError(
|
|
1562
|
+
`[Analytics] ObjectQLStrategy cannot bucket a cross-object time dimension ("${field}").`,
|
|
1563
|
+
{ member: td.dimension, param: "timeDimensions", cube: cube.name }
|
|
1158
1564
|
);
|
|
1159
1565
|
}
|
|
1160
1566
|
}
|
|
1161
1567
|
const nonDim = [
|
|
1162
|
-
...(query.measures ?? []).map((m) => ({
|
|
1163
|
-
|
|
1568
|
+
...(query.measures ?? []).map((m) => ({
|
|
1569
|
+
where: "measure",
|
|
1570
|
+
member: m,
|
|
1571
|
+
field: this.resolveMeasureAggregation(cube, m).field
|
|
1572
|
+
})),
|
|
1573
|
+
...Object.keys(filter).map((f) => ({ where: "filter", member: f, field: f }))
|
|
1164
1574
|
].filter((r) => this.isCrossObjectField(cube, r.field, baseObject));
|
|
1165
1575
|
if (nonDim.length > 0) {
|
|
1166
|
-
throw
|
|
1167
|
-
`[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}
|
|
1576
|
+
throw invalidMemberError(
|
|
1577
|
+
`[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}.`,
|
|
1578
|
+
{
|
|
1579
|
+
member: nonDim[0].member,
|
|
1580
|
+
// The two kinds share one throw, so the request key follows the kind
|
|
1581
|
+
// rather than being guessed by the reader of the message.
|
|
1582
|
+
param: nonDim[0].where === "measure" ? "measures" : "where",
|
|
1583
|
+
cube: cube.name
|
|
1584
|
+
}
|
|
1168
1585
|
);
|
|
1169
1586
|
}
|
|
1170
1587
|
const crossDims = [];
|
|
@@ -1174,8 +1591,9 @@ var ObjectQLStrategy = class {
|
|
|
1174
1591
|
const [alias, ...rest] = field.split(".");
|
|
1175
1592
|
const attr = rest.join(".");
|
|
1176
1593
|
if (attr.includes(".")) {
|
|
1177
|
-
throw
|
|
1178
|
-
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship
|
|
1594
|
+
throw invalidMemberError(
|
|
1595
|
+
`[Analytics] ObjectQLStrategy supports only single-hop cross-object dimensions; "${field}" traverses more than one relationship.`,
|
|
1596
|
+
{ member: dim, param: "dimensions", cube: cube.name }
|
|
1179
1597
|
);
|
|
1180
1598
|
}
|
|
1181
1599
|
crossDims.push({ outputName: dim, fkField: alias, attr, refObject: cube.joins?.[alias]?.name ?? alias });
|
|
@@ -1184,8 +1602,9 @@ var ObjectQLStrategy = class {
|
|
|
1184
1602
|
for (const m of query.measures ?? []) {
|
|
1185
1603
|
const { method } = this.resolveMeasureAggregation(cube, m);
|
|
1186
1604
|
if (!RECOMBINABLE_METHODS.has(method)) {
|
|
1187
|
-
throw
|
|
1188
|
-
`[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
|
|
1605
|
+
throw invalidMemberError(
|
|
1606
|
+
`[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.`,
|
|
1607
|
+
{ member: m, param: "measures", cube: cube.name }
|
|
1189
1608
|
);
|
|
1190
1609
|
}
|
|
1191
1610
|
}
|
|
@@ -1284,10 +1703,21 @@ var ObjectQLStrategy = class {
|
|
|
1284
1703
|
* Render one normalized filter as a display SQL predicate for `generateSql`.
|
|
1285
1704
|
*
|
|
1286
1705
|
* Mirrors `NativeSQLStrategy.buildFilterClause`'s operator vocabulary so the
|
|
1287
|
-
* two previews read alike, but binds
|
|
1288
|
-
* the
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1706
|
+
* two previews read alike, but binds the comparand VERBATIM: the value shown is
|
|
1707
|
+
* the one THIS path actually hands the engine (a real boolean, not SQL's 1/0).
|
|
1708
|
+
*
|
|
1709
|
+
* [#5526] "Verbatim" is now literal. This used to bind through
|
|
1710
|
+
* `coerceFilterValueForObjectQL`, which decoded the string a `string[]` leaf
|
|
1711
|
+
* carried back into a type — so an echo could show `7` for a filter the author
|
|
1712
|
+
* wrote as `'007'`. A leaf carries the author's value at its own type, so the
|
|
1713
|
+
* echo needs no conversion at all to stay honest about execution. The LIKE
|
|
1714
|
+
* family is still the one exception, for the reason `filter.zod.ts` gives: its
|
|
1715
|
+
* comparand is declared a `string`, and what binds is the PATTERN.
|
|
1716
|
+
*
|
|
1717
|
+
* `null` means "this leaf carries no predicate" — a value-less scalar leaf,
|
|
1718
|
+
* which `execute()` and `NativeSQLStrategy` drop too. It does NOT mean "I could
|
|
1719
|
+
* not render that operator": #5333 was exactly that conflation, and an
|
|
1720
|
+
* unrenderable operator now THROWS (see the exit below).
|
|
1291
1721
|
*/
|
|
1292
1722
|
buildFilterClauseSql(col, operator, values, params) {
|
|
1293
1723
|
if (operator === "set") return `${col} IS NOT NULL`;
|
|
@@ -1295,18 +1725,25 @@ var ObjectQLStrategy = class {
|
|
|
1295
1725
|
if (!values || values.length === 0) return null;
|
|
1296
1726
|
if (operator === "in" || operator === "notIn") {
|
|
1297
1727
|
const placeholders = values.map((v) => {
|
|
1298
|
-
params.push(
|
|
1728
|
+
params.push(v);
|
|
1299
1729
|
return `$${params.length}`;
|
|
1300
1730
|
}).join(", ");
|
|
1301
1731
|
return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
|
|
1302
1732
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1733
|
+
const like = LIKE_SQL_OPS[operator];
|
|
1734
|
+
if (like) {
|
|
1735
|
+
params.push(likePattern(like.shape, values[0]));
|
|
1736
|
+
const patternRef = `$${params.length}`;
|
|
1737
|
+
params.push(LIKE_ESCAPE_CHAR);
|
|
1738
|
+
return `${col} ${like.sql} ${patternRef} ESCAPE $${params.length}`;
|
|
1306
1739
|
}
|
|
1307
1740
|
const op = SCALAR_SQL_OPS[operator];
|
|
1308
|
-
if (!op)
|
|
1309
|
-
|
|
1741
|
+
if (!op) {
|
|
1742
|
+
throw new Error(
|
|
1743
|
+
`[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).`
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
params.push(values[0]);
|
|
1310
1747
|
return `${col} ${op} $${params.length}`;
|
|
1311
1748
|
}
|
|
1312
1749
|
/**
|
|
@@ -1418,16 +1855,30 @@ var ObjectQLStrategy = class {
|
|
|
1418
1855
|
const rendered = this.filterNodeToCondition(node, cube);
|
|
1419
1856
|
if (rendered) conjuncts.push(rendered);
|
|
1420
1857
|
}
|
|
1421
|
-
/**
|
|
1858
|
+
/**
|
|
1859
|
+
* A node as a standalone `FilterCondition` the engine can consume.
|
|
1860
|
+
*
|
|
1861
|
+
* `null` = no constraint, which is the boolean TRUE — the AND identity but the
|
|
1862
|
+
* OR ABSORBER, so a `null` branch makes the whole disjunction unconstrained
|
|
1863
|
+
* instead of collapsing it to its surviving branches (#5325). FALSE is handed
|
|
1864
|
+
* to the engine as `{$not: {}}`, the spelling `driver-sql`, `formula` and
|
|
1865
|
+
* `driver-memory`'s matcher all already pin as the zero-row filter (#5134) —
|
|
1866
|
+
* this strategy invents no second one.
|
|
1867
|
+
*/
|
|
1422
1868
|
filterNodeToCondition(node, cube) {
|
|
1423
1869
|
if (!node) return null;
|
|
1870
|
+
if (node.kind === "const") {
|
|
1871
|
+
return node.value ? null : { $not: {} };
|
|
1872
|
+
}
|
|
1424
1873
|
if (node.kind === "not") {
|
|
1425
1874
|
const inner = this.filterNodeToCondition(node.child, cube);
|
|
1426
|
-
return inner ? { $not: inner } :
|
|
1875
|
+
return inner ? { $not: inner } : { $not: {} };
|
|
1427
1876
|
}
|
|
1428
1877
|
if (node.kind === "or") {
|
|
1429
|
-
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube))
|
|
1430
|
-
|
|
1878
|
+
const branches = node.children.map((child) => this.filterNodeToCondition(child, cube));
|
|
1879
|
+
if (branches.some((c) => c === null)) return null;
|
|
1880
|
+
const kept = branches.filter((c) => !!c);
|
|
1881
|
+
return kept.length > 0 ? { $or: kept } : null;
|
|
1431
1882
|
}
|
|
1432
1883
|
const filter = {};
|
|
1433
1884
|
const conjuncts = [];
|
|
@@ -1441,9 +1892,20 @@ var ObjectQLStrategy = class {
|
|
|
1441
1892
|
* Render a normalized filter node as the display SQL `/analytics/sql`
|
|
1442
1893
|
* echoes. Values still bind as `$n` placeholders — the echo travels to the
|
|
1443
1894
|
* browser, so a comparand is never inlined.
|
|
1895
|
+
*
|
|
1896
|
+
* The boolean identities render too (#5325). This string exists to REPRODUCE
|
|
1897
|
+
* execution: a `{$not: {}}` filter that runs as zero rows but echoes SQL with
|
|
1898
|
+
* no `WHERE` hands whoever is debugging "why is this chart empty" a statement
|
|
1899
|
+
* that returns the whole table. Same reason the absorbed `$or` branch and the
|
|
1900
|
+
* `params` truncation below match {@link NativeSQLStrategy.compileFilterNode}
|
|
1901
|
+
* exactly — including the invariant that a `null` return leaves `params`
|
|
1902
|
+
* untouched, so no comparand is left with no placeholder to consume it.
|
|
1444
1903
|
*/
|
|
1445
1904
|
renderFilterNodeSql(node, cube, params) {
|
|
1446
1905
|
if (!node) return null;
|
|
1906
|
+
if (node.kind === "const") {
|
|
1907
|
+
return node.value ? SQL_CONST_TRUE : SQL_CONST_FALSE;
|
|
1908
|
+
}
|
|
1447
1909
|
if (node.kind === "leaf") {
|
|
1448
1910
|
return this.buildFilterClauseSql(
|
|
1449
1911
|
this.resolveFieldName(cube, node.member, "any"),
|
|
@@ -1454,9 +1916,19 @@ var ObjectQLStrategy = class {
|
|
|
1454
1916
|
}
|
|
1455
1917
|
if (node.kind === "not") {
|
|
1456
1918
|
const inner = this.renderFilterNodeSql(node.child, cube, params);
|
|
1457
|
-
return inner ? `NOT (${inner})` :
|
|
1919
|
+
return inner ? `NOT (${inner})` : SQL_CONST_FALSE;
|
|
1920
|
+
}
|
|
1921
|
+
const paramBase = params.length;
|
|
1922
|
+
const parts = [];
|
|
1923
|
+
for (const child of node.children) {
|
|
1924
|
+
const clause = this.renderFilterNodeSql(child, cube, params);
|
|
1925
|
+
if (clause === null) {
|
|
1926
|
+
if (node.kind !== "or") continue;
|
|
1927
|
+
params.length = paramBase;
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
parts.push(clause);
|
|
1458
1931
|
}
|
|
1459
|
-
const parts = node.children.map((child) => this.renderFilterNodeSql(child, cube, params)).filter((s) => !!s);
|
|
1460
1932
|
if (parts.length === 0) return null;
|
|
1461
1933
|
if (parts.length === 1) return parts[0];
|
|
1462
1934
|
return `(${parts.join(node.kind === "or" ? " OR " : " AND ")})`;
|
|
@@ -1492,9 +1964,16 @@ var ObjectQLStrategy = class {
|
|
|
1492
1964
|
* performs the same half-open translation itself because it binds into raw
|
|
1493
1965
|
* SQL, so one dashboard reads the same on every driver.
|
|
1494
1966
|
*
|
|
1495
|
-
*
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1967
|
+
* [#5526] Bounds are forwarded at the type `dateRange` is DECLARED with —
|
|
1968
|
+
* `string` (`AnalyticsQuerySchema`'s `timeDimensions[].dateRange: string[]`) —
|
|
1969
|
+
* and nothing re-types them. They used to pass through
|
|
1970
|
+
* `coerceFilterValueForObjectQL`, whose TSDoc advertised that "an epoch-ms
|
|
1971
|
+
* bound recovers as a number"; that was a lenient CONSUMER rescuing a shape the
|
|
1972
|
+
* contract does not declare, and the same guess is what read a `'007'` filter
|
|
1973
|
+
* comparand as `7` (Prime Directive #12 — the producer or the spec is where an
|
|
1974
|
+
* epoch-ms window would have to be declared, not here). An author who wants an
|
|
1975
|
+
* instant window writes it as one; a declared `string` binds as a string. No
|
|
1976
|
+
* STORAGE coercion happens here either, deliberately: `NativeSQLStrategy` needs
|
|
1498
1977
|
* `coerceTemporal` because it binds into raw SQL and had to learn that a
|
|
1499
1978
|
* SQLite `Field.datetime` is an INTEGER epoch (#2034); this path goes through
|
|
1500
1979
|
* `engine.aggregate()`, where the driver's own CRUD filter coercion applies —
|
|
@@ -1521,20 +2000,34 @@ var ObjectQLStrategy = class {
|
|
|
1521
2000
|
if (start == null) continue;
|
|
1522
2001
|
out.push({
|
|
1523
2002
|
field: this.resolveFieldName(cube, td.dimension, "dimension"),
|
|
1524
|
-
bounds: {
|
|
1525
|
-
$gte: coerceFilterValueForObjectQL(String(start)),
|
|
1526
|
-
$lte: coerceFilterValueForObjectQL(String(end))
|
|
1527
|
-
}
|
|
2003
|
+
bounds: { $gte: start, $lte: end }
|
|
1528
2004
|
});
|
|
1529
2005
|
}
|
|
1530
2006
|
return out;
|
|
1531
2007
|
}
|
|
2008
|
+
/**
|
|
2009
|
+
* One leaf as the operand the engine's `FilterCondition` expects.
|
|
2010
|
+
*
|
|
2011
|
+
* [#5526] The comparand is passed through UNCONVERTED. That is the whole of
|
|
2012
|
+
* this path's share of the fix: the engine compares against the value as
|
|
2013
|
+
* STORED, and a leaf now carries the value the author wrote, so `'007'` stays
|
|
2014
|
+
* `'007'`, `true` stays `true` and `7` stays `7` with nothing in between to
|
|
2015
|
+
* re-type them. The two `coerceFilterValueForObjectQL` calls this replaced
|
|
2016
|
+
* existed only to undo `stringifyForCube`, and undoing it required guessing.
|
|
2017
|
+
*
|
|
2018
|
+
* The four LIKE-family arms are the exception, and a contract one:
|
|
2019
|
+
* `filter.zod.ts` declares `$contains` / `$notContains` / `$startsWith` /
|
|
2020
|
+
* `$endsWith` as `z.string()`, so this PRODUCER must hand the engine a real
|
|
2021
|
+
* string — `String(…)`, the same normalisation `like-pattern.ts` applies at the
|
|
2022
|
+
* two SQL emitters and `driver-sql`'s `applyLike` applies at the driver, so one
|
|
2023
|
+
* `$contains` means one thing on every face (#5567's invariant).
|
|
2024
|
+
*/
|
|
1532
2025
|
convertFilter(operator, values) {
|
|
1533
2026
|
if (operator === "set") return { $ne: null };
|
|
1534
2027
|
if (operator === "notSet") return null;
|
|
1535
2028
|
if (!values || values.length === 0) return void 0;
|
|
1536
|
-
const v0 =
|
|
1537
|
-
const all = values
|
|
2029
|
+
const v0 = values[0];
|
|
2030
|
+
const all = [...values];
|
|
1538
2031
|
switch (operator) {
|
|
1539
2032
|
case "equals":
|
|
1540
2033
|
return v0;
|
|
@@ -1548,19 +2041,42 @@ var ObjectQLStrategy = class {
|
|
|
1548
2041
|
return { $lt: v0 };
|
|
1549
2042
|
case "lte":
|
|
1550
2043
|
return { $lte: v0 };
|
|
2044
|
+
// [#5557] `contains` was `{ $regex: values[0] }` — the comparand dropped
|
|
2045
|
+
// VERBATIM into a regex position while its three siblings below already
|
|
2046
|
+
// passed as canonical spec operators. Three things were wrong with that,
|
|
2047
|
+
// and none of them waits on #4706's ruling about what `$regex` should
|
|
2048
|
+
// mean:
|
|
2049
|
+
//
|
|
2050
|
+
// 1. `$regex` is not in `filter.zod.ts`'s `FILTER_OPERATORS`, so this
|
|
2051
|
+
// was a PRODUCER emitting an operator the contract does not declare
|
|
2052
|
+
// (Prime Directive #12 — fix the producer, not the consumers).
|
|
2053
|
+
// 2. `compileScopedFilterToSql` in this very package is a
|
|
2054
|
+
// `FilterCondition` consumer and fails closed on `$regex`, so one
|
|
2055
|
+
// filter tree no longer travelled between two consumers of the same
|
|
2056
|
+
// contract sitting in the same directory.
|
|
2057
|
+
// 3. On a backend that reads `$regex` as a real regex — driver-memory's
|
|
2058
|
+
// `memory-matcher.ts` does, deliberately, for plugin-auth's adapter
|
|
2059
|
+
// — an unescaped comparand changes what the author asked for:
|
|
2060
|
+
// `a.b` also matched `axb`, and `50% (+)` did not compile at all, so
|
|
2061
|
+
// the `catch { return false }` answered zero rows in silence.
|
|
2062
|
+
// `driver-sql` meanwhile compiles `$regex` to a substring LIKE, so
|
|
2063
|
+
// the same widget returned different row sets per driver.
|
|
2064
|
+
//
|
|
2065
|
+
// `MONGO_TO_CUBE_OP` maps `$contains` → `contains` and nothing else does,
|
|
2066
|
+
// so returning `$contains` here is the round trip of the author's own key.
|
|
1551
2067
|
case "contains":
|
|
1552
|
-
return { $
|
|
2068
|
+
return { $contains: String(v0) };
|
|
1553
2069
|
// `notContains` had no arm and fell to the `default` below, which returns
|
|
1554
2070
|
// a BARE VALUE — i.e. `{field: 'x'}`, an equality. "does not contain x"
|
|
1555
2071
|
// was compiled as "equals x". These three pass through as the canonical
|
|
1556
2072
|
// spec operators every driver implements directly, so an anchored match
|
|
1557
2073
|
// stays anchored rather than depending on regex dialect (#4128).
|
|
1558
2074
|
case "notContains":
|
|
1559
|
-
return { $notContains:
|
|
2075
|
+
return { $notContains: String(v0) };
|
|
1560
2076
|
case "startsWith":
|
|
1561
|
-
return { $startsWith:
|
|
2077
|
+
return { $startsWith: String(v0) };
|
|
1562
2078
|
case "endsWith":
|
|
1563
|
-
return { $endsWith:
|
|
2079
|
+
return { $endsWith: String(v0) };
|
|
1564
2080
|
case "in":
|
|
1565
2081
|
return { $in: all };
|
|
1566
2082
|
case "notIn":
|
|
@@ -1615,15 +2131,15 @@ var ObjectQLStrategy = class {
|
|
|
1615
2131
|
};
|
|
1616
2132
|
|
|
1617
2133
|
// src/dataset-compiler.ts
|
|
1618
|
-
var
|
|
2134
|
+
var import_data2 = require("@objectstack/spec/data");
|
|
1619
2135
|
var UNSUPPORTED_AGGREGATES = /* @__PURE__ */ new Set(["array_agg", "string_agg"]);
|
|
1620
|
-
var SUPPORTED_AGGREGATES =
|
|
2136
|
+
var SUPPORTED_AGGREGATES = import_data2.AggregationFunction.options.filter((a) => !UNSUPPORTED_AGGREGATES.has(a));
|
|
1621
2137
|
function aggregateToMetricType(m) {
|
|
1622
2138
|
if (!m.aggregate) {
|
|
1623
2139
|
throw new Error(`[dataset-compiler] non-derived measure "${m.name}" has no aggregate`);
|
|
1624
2140
|
}
|
|
1625
2141
|
if (UNSUPPORTED_AGGREGATES.has(m.aggregate)) {
|
|
1626
|
-
throw
|
|
2142
|
+
throw datasetInvalidError(
|
|
1627
2143
|
`[dataset-compiler] measure "${m.name}" uses aggregate "${m.aggregate}" which is not supported by the v1 dataset runtime (supported: ${SUPPORTED_AGGREGATES.join(", ")}).`
|
|
1628
2144
|
);
|
|
1629
2145
|
}
|
|
@@ -1651,13 +2167,31 @@ function fieldRelationshipPath(field) {
|
|
|
1651
2167
|
}
|
|
1652
2168
|
var MAX_JOIN_HOPS = 3;
|
|
1653
2169
|
var joinAlias = (path) => path.replace(/\./g, "__");
|
|
1654
|
-
function compileDataset(dataset, resolver) {
|
|
2170
|
+
function compileDataset(dataset, resolver, options) {
|
|
1655
2171
|
const include = dataset.include ?? [];
|
|
2172
|
+
const declaredDatasource = (objectName) => {
|
|
2173
|
+
const declared = options?.getObjectDatasource?.(objectName);
|
|
2174
|
+
return declared && declared.toLowerCase() !== "default" ? declared : void 0;
|
|
2175
|
+
};
|
|
2176
|
+
const isExternal = (objectName) => options?.isExternalObject?.(objectName) ?? false;
|
|
2177
|
+
const baseDatasource = declaredDatasource(dataset.object);
|
|
2178
|
+
const sameDatasource = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
2179
|
+
const baseIsFederated = isExternal(dataset.object);
|
|
2180
|
+
const assertSameDatasource = (targetObject, path) => {
|
|
2181
|
+
if (!baseDatasource || baseIsFederated) return;
|
|
2182
|
+
if (isExternal(targetObject)) return;
|
|
2183
|
+
const targetDatasource = declaredDatasource(targetObject);
|
|
2184
|
+
if (!targetDatasource) return;
|
|
2185
|
+
if (sameDatasource(targetDatasource, baseDatasource)) return;
|
|
2186
|
+
throw datasetInvalidError(
|
|
2187
|
+
`[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).`
|
|
2188
|
+
);
|
|
2189
|
+
};
|
|
1656
2190
|
const resolveHop = (fromObject, rel) => {
|
|
1657
2191
|
if (!resolver) return { object: rel, table: rel };
|
|
1658
2192
|
const resolved = resolver(fromObject, rel);
|
|
1659
2193
|
if (!resolved) {
|
|
1660
|
-
throw
|
|
2194
|
+
throw datasetInvalidError(
|
|
1661
2195
|
`[dataset-compiler] dataset "${dataset.name}" includes relationship "${rel}" which does not exist on object "${fromObject}".`
|
|
1662
2196
|
);
|
|
1663
2197
|
}
|
|
@@ -1667,7 +2201,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1667
2201
|
for (const path of include) {
|
|
1668
2202
|
const segments = path.split(".");
|
|
1669
2203
|
if (segments.length > MAX_JOIN_HOPS) {
|
|
1670
|
-
throw
|
|
2204
|
+
throw datasetInvalidError(
|
|
1671
2205
|
`[dataset-compiler] dataset "${dataset.name}" include path "${path}" exceeds the ${MAX_JOIN_HOPS}-hop limit (${segments.length} hops). Deeper traversal is not supported.`
|
|
1672
2206
|
);
|
|
1673
2207
|
}
|
|
@@ -1677,6 +2211,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1677
2211
|
for (const seg of segments) {
|
|
1678
2212
|
prefix = prefix ? `${prefix}.${seg}` : seg;
|
|
1679
2213
|
const target = resolveHop(fromObject, seg);
|
|
2214
|
+
assertSameDatasource(target.object, prefix);
|
|
1680
2215
|
const alias = joinAlias(prefix);
|
|
1681
2216
|
if (!joins[alias]) {
|
|
1682
2217
|
joins[alias] = {
|
|
@@ -1693,7 +2228,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1693
2228
|
const assertDeclared = (field, ownerKind, ownerName) => {
|
|
1694
2229
|
const relPath = fieldRelationshipPath(field);
|
|
1695
2230
|
if (relPath && !joins[joinAlias(relPath)]) {
|
|
1696
|
-
throw
|
|
2231
|
+
throw datasetInvalidError(
|
|
1697
2232
|
`[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.`
|
|
1698
2233
|
);
|
|
1699
2234
|
}
|
|
@@ -1751,7 +2286,7 @@ function compileDataset(dataset, resolver) {
|
|
|
1751
2286
|
}
|
|
1752
2287
|
|
|
1753
2288
|
// src/dataset-executor.ts
|
|
1754
|
-
var
|
|
2289
|
+
var import_data3 = require("@objectstack/spec/data");
|
|
1755
2290
|
var import_core3 = require("@objectstack/core");
|
|
1756
2291
|
function resolveSelectionTokens(compiled, selection, context) {
|
|
1757
2292
|
const tokenCtx = (0, import_core3.filterTokenContextFrom)(context, /* @__PURE__ */ new Date());
|
|
@@ -1791,7 +2326,7 @@ function evaluateDerivedMeasures(rows, derived) {
|
|
|
1791
2326
|
}
|
|
1792
2327
|
function fillEmptyGroups(rows, columnAggregates) {
|
|
1793
2328
|
for (const [column, aggregate2] of Object.entries(columnAggregates)) {
|
|
1794
|
-
const empty = (0,
|
|
2329
|
+
const empty = (0, import_data3.emptyGroupValueFor)(aggregate2);
|
|
1795
2330
|
if (empty === void 0) continue;
|
|
1796
2331
|
for (const row of rows) if (row[column] == null) row[column] = empty;
|
|
1797
2332
|
}
|
|
@@ -1876,7 +2411,7 @@ function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
|
1876
2411
|
]);
|
|
1877
2412
|
const unknown = Object.keys(order).filter((k) => !selectable.has(k));
|
|
1878
2413
|
if (unknown.length) {
|
|
1879
|
-
throw
|
|
2414
|
+
throw datasetInvalidError(
|
|
1880
2415
|
`[dataset-executor] order key(s) ${unknown.map((k) => `"${k}"`).join(", ")} \u2014 not a selected dimension or measure. Selectable here: ${[...selectable].join(", ") || "(none)"}.`
|
|
1881
2416
|
);
|
|
1882
2417
|
}
|
|
@@ -1893,7 +2428,9 @@ function resolveOrdering(selection, dimensions, timeDimensions = []) {
|
|
|
1893
2428
|
}
|
|
1894
2429
|
function parseUTC(date) {
|
|
1895
2430
|
const ms = Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date);
|
|
1896
|
-
if (Number.isNaN(ms))
|
|
2431
|
+
if (Number.isNaN(ms)) {
|
|
2432
|
+
throw datasetInvalidError(`[dataset-executor] invalid date in dateRange: "${date}"`);
|
|
2433
|
+
}
|
|
1897
2434
|
return ms;
|
|
1898
2435
|
}
|
|
1899
2436
|
var DAY_MS = 864e5;
|
|
@@ -1905,6 +2442,30 @@ function shiftYear(date, years) {
|
|
|
1905
2442
|
d.setUTCFullYear(d.getUTCFullYear() + years);
|
|
1906
2443
|
return toISODate(d.getTime());
|
|
1907
2444
|
}
|
|
2445
|
+
function resolveCompareDimension(selection) {
|
|
2446
|
+
const cmp = selection.compareTo;
|
|
2447
|
+
const shiftable = (selection.timeDimensions ?? []).filter(
|
|
2448
|
+
(t) => t.dateRange != null
|
|
2449
|
+
);
|
|
2450
|
+
const names = shiftable.map((t) => t.dimension);
|
|
2451
|
+
if (cmp.dimension != null) {
|
|
2452
|
+
if (!names.includes(cmp.dimension)) {
|
|
2453
|
+
throw datasetInvalidError(
|
|
2454
|
+
`[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).")
|
|
2455
|
+
);
|
|
2456
|
+
}
|
|
2457
|
+
return cmp.dimension;
|
|
2458
|
+
}
|
|
2459
|
+
if (names.length === 1) return names[0];
|
|
2460
|
+
if (names.length === 0) {
|
|
2461
|
+
throw datasetInvalidError(
|
|
2462
|
+
"[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."
|
|
2463
|
+
);
|
|
2464
|
+
}
|
|
2465
|
+
throw datasetInvalidError(
|
|
2466
|
+
`[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]}' }.`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
1908
2469
|
function shiftRange(range, kind) {
|
|
1909
2470
|
const [start, end] = range;
|
|
1910
2471
|
if (kind === "previousYear") {
|
|
@@ -1947,7 +2508,7 @@ var DatasetExecutor = class {
|
|
|
1947
2508
|
for (const grouping of groupings) {
|
|
1948
2509
|
const unknown = grouping.filter((d) => !selected.has(d));
|
|
1949
2510
|
if (unknown.length) {
|
|
1950
|
-
throw
|
|
2511
|
+
throw datasetInvalidError(
|
|
1951
2512
|
`[dataset-executor] totals grouping [${grouping.join(", ")}] is not a subset of the selected dimensions \u2014 unknown: ${unknown.join(", ")}.`
|
|
1952
2513
|
);
|
|
1953
2514
|
}
|
|
@@ -2056,8 +2617,9 @@ var DatasetExecutor = class {
|
|
|
2056
2617
|
async runMeasurePass(compiled, selection, opts) {
|
|
2057
2618
|
const { measures, dimensions, baseFilter, window, context } = opts;
|
|
2058
2619
|
const { unfiltered, filtered } = splitMeasuresByFilter(measures, compiled.measureFilters);
|
|
2620
|
+
const primary = unfiltered.length > 0 || filtered.length === 0;
|
|
2059
2621
|
let result;
|
|
2060
|
-
if (
|
|
2622
|
+
if (primary) {
|
|
2061
2623
|
result = await this.service.query(this.buildQuery(compiled, {
|
|
2062
2624
|
measures: unfiltered,
|
|
2063
2625
|
dimensions,
|
|
@@ -2069,7 +2631,8 @@ var DatasetExecutor = class {
|
|
|
2069
2631
|
} else {
|
|
2070
2632
|
result = { rows: [], fields: [] };
|
|
2071
2633
|
}
|
|
2072
|
-
|
|
2634
|
+
const measureNames = new Set(measures);
|
|
2635
|
+
for (const [i, m] of filtered.entries()) {
|
|
2073
2636
|
const mFilter = combineFilters(baseFilter, compiled.measureFilters[m]);
|
|
2074
2637
|
const sub = await this.service.query(this.buildQuery(compiled, {
|
|
2075
2638
|
measures: [m],
|
|
@@ -2079,6 +2642,11 @@ var DatasetExecutor = class {
|
|
|
2079
2642
|
contextTimezone: context?.timezone
|
|
2080
2643
|
}), context);
|
|
2081
2644
|
result.rows = mergeByDimensions(result.rows, sub.rows, dimensions, [m]);
|
|
2645
|
+
if (!primary && i === 0) {
|
|
2646
|
+
for (const f of sub.fields ?? []) {
|
|
2647
|
+
if (!measureNames.has(f.name)) result.fields.push(f);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2082
2650
|
result.fields.push({ name: m, type: "number" });
|
|
2083
2651
|
}
|
|
2084
2652
|
return result;
|
|
@@ -2108,14 +2676,17 @@ var DatasetExecutor = class {
|
|
|
2108
2676
|
if (opts.where) q.where = opts.where;
|
|
2109
2677
|
const selTimeDims = opts.selection.timeDimensions ?? [];
|
|
2110
2678
|
const selDims = new Set(selTimeDims.map((t) => t.dimension));
|
|
2679
|
+
const groupedDims = new Set(opts.dimensions);
|
|
2111
2680
|
const granularityFor = (name) => {
|
|
2112
2681
|
const cd = compiled.cube.dimensions[name];
|
|
2113
2682
|
if (cd?.type !== "time") return void 0;
|
|
2114
2683
|
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : void 0;
|
|
2115
2684
|
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
|
|
2116
2685
|
};
|
|
2686
|
+
const bucketsUnstatedEntry = (dimension) => groupedDims.has(dimension) || opts.selection.dateGranularity != null;
|
|
2117
2687
|
const resolvedTimeDims = selTimeDims.map((t) => {
|
|
2118
2688
|
if (t.granularity) return t;
|
|
2689
|
+
if (!bucketsUnstatedEntry(t.dimension)) return t;
|
|
2119
2690
|
const granularity = granularityFor(t.dimension);
|
|
2120
2691
|
return granularity ? { ...t, granularity } : t;
|
|
2121
2692
|
});
|
|
@@ -2134,16 +2705,12 @@ var DatasetExecutor = class {
|
|
|
2134
2705
|
}
|
|
2135
2706
|
async runCompare(compiled, selection, measures, dimensions, baseFilter, context) {
|
|
2136
2707
|
const cmp = selection.compareTo;
|
|
2137
|
-
const
|
|
2138
|
-
|
|
2139
|
-
throw new Error(
|
|
2140
|
-
`[dataset-executor] compareTo requires a timeDimension "${cmp.dimension}" with a dateRange.`
|
|
2141
|
-
);
|
|
2142
|
-
}
|
|
2708
|
+
const dimension = resolveCompareDimension(selection);
|
|
2709
|
+
const td = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension);
|
|
2143
2710
|
const range = Array.isArray(td.dateRange) ? [td.dateRange[0], td.dateRange[1] ?? td.dateRange[0]] : [td.dateRange, td.dateRange];
|
|
2144
2711
|
const shifted = shiftRange(range, cmp.kind);
|
|
2145
2712
|
const shiftedTd = (selection.timeDimensions ?? []).map(
|
|
2146
|
-
(t) => t.dimension ===
|
|
2713
|
+
(t) => t.dimension === dimension ? { ...t, dateRange: shifted } : t
|
|
2147
2714
|
);
|
|
2148
2715
|
const sub = await this.runMeasurePass(
|
|
2149
2716
|
compiled,
|
|
@@ -2158,8 +2725,22 @@ var DatasetExecutor = class {
|
|
|
2158
2725
|
});
|
|
2159
2726
|
}
|
|
2160
2727
|
};
|
|
2728
|
+
var NULL_DIMENSION_SEGMENT = "~";
|
|
2729
|
+
function dimensionKeyOf(row, dimensions) {
|
|
2730
|
+
let key = "";
|
|
2731
|
+
for (const d of dimensions) {
|
|
2732
|
+
const value = row[d];
|
|
2733
|
+
if (value == null) {
|
|
2734
|
+
key += NULL_DIMENSION_SEGMENT;
|
|
2735
|
+
continue;
|
|
2736
|
+
}
|
|
2737
|
+
const s = String(value);
|
|
2738
|
+
key += `${s.length}:${s}`;
|
|
2739
|
+
}
|
|
2740
|
+
return key;
|
|
2741
|
+
}
|
|
2161
2742
|
function mergeByDimensions(base, extra, dimensions, valueColumns) {
|
|
2162
|
-
const keyOf = (row) =>
|
|
2743
|
+
const keyOf = (row) => dimensionKeyOf(row, dimensions);
|
|
2163
2744
|
const index = /* @__PURE__ */ new Map();
|
|
2164
2745
|
for (const row of base) index.set(keyOf(row), row);
|
|
2165
2746
|
for (const row of extra) {
|
|
@@ -2511,15 +3092,71 @@ function evaluateAnalyticsQueryOverRows(query, cube, rows) {
|
|
|
2511
3092
|
}
|
|
2512
3093
|
|
|
2513
3094
|
// src/analytics-service.ts
|
|
3095
|
+
function hasDeclaredErrorEnvelope(err) {
|
|
3096
|
+
const e = err;
|
|
3097
|
+
return typeof e?.status === "number" && typeof e?.code === "string" && e.code.length > 0;
|
|
3098
|
+
}
|
|
2514
3099
|
function isMissingSourceError(err) {
|
|
2515
|
-
const
|
|
3100
|
+
const raw = String(err?.message ?? err ?? "");
|
|
3101
|
+
const msg = raw.toLowerCase();
|
|
2516
3102
|
return msg.includes("no such table") || // sqlite / libsql
|
|
2517
|
-
|
|
3103
|
+
/relation\s+[`"']?[A-Za-z0-9_$.]+[`"']?\s+does not exist/i.test(raw) || // postgres
|
|
2518
3104
|
msg.includes("doesn't exist") || // mysql ("table ... doesn't exist")
|
|
2519
3105
|
msg.includes("not registered") || // framework: object not in registry
|
|
2520
3106
|
msg.includes("unknown object") || msg.includes("is not a registered object");
|
|
2521
3107
|
}
|
|
3108
|
+
function missingSourceRelation(err) {
|
|
3109
|
+
const msg = String(err?.message ?? err ?? "");
|
|
3110
|
+
const patterns = [
|
|
3111
|
+
/no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i,
|
|
3112
|
+
// sqlite / libsql
|
|
3113
|
+
/relation\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+does not exist/i,
|
|
3114
|
+
// postgres
|
|
3115
|
+
/table\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+doesn't exist/i,
|
|
3116
|
+
// mysql
|
|
3117
|
+
/(?:object|table)\s+[`"']([A-Za-z0-9_$.]+)[`"']\s+is not registered/i,
|
|
3118
|
+
// framework
|
|
3119
|
+
/unknown object:?\s*[`"']?([A-Za-z0-9_$.]+)/i,
|
|
3120
|
+
/[`"']([A-Za-z0-9_$.]+)[`"']\s+is not a registered object/i
|
|
3121
|
+
];
|
|
3122
|
+
for (const re of patterns) {
|
|
3123
|
+
const m = re.exec(msg);
|
|
3124
|
+
if (m?.[1]) {
|
|
3125
|
+
const parts = m[1].split(".").filter(Boolean);
|
|
3126
|
+
const bare = parts[parts.length - 1];
|
|
3127
|
+
if (bare) return bare;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return void 0;
|
|
3131
|
+
}
|
|
2522
3132
|
var BARE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/i;
|
|
3133
|
+
function declaredMemberEntry(cube, member, kind) {
|
|
3134
|
+
const bags = kind === "dimension" ? [cube.dimensions] : [
|
|
3135
|
+
cube.dimensions,
|
|
3136
|
+
cube.measures
|
|
3137
|
+
];
|
|
3138
|
+
for (const bag of bags) {
|
|
3139
|
+
if (bag[member]) return { ...bag[member], key: member };
|
|
3140
|
+
if (member.includes(".")) {
|
|
3141
|
+
const [first, ...rest] = member.split(".");
|
|
3142
|
+
const tail = rest.join(".");
|
|
3143
|
+
if (first === cube.name && bag[tail]) return { ...bag[tail], key: tail };
|
|
3144
|
+
if (bag[tail]) return { ...bag[tail], key: tail };
|
|
3145
|
+
const flat = member.replace(/\./g, "_");
|
|
3146
|
+
if (bag[flat]) return { ...bag[flat], key: flat };
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
return void 0;
|
|
3150
|
+
}
|
|
3151
|
+
function resolveMemberSource(cube, member, kind) {
|
|
3152
|
+
const entry = declaredMemberEntry(cube, member, kind);
|
|
3153
|
+
if (entry) {
|
|
3154
|
+
const source = typeof entry.sql === "string" ? entry.sql.trim() : "";
|
|
3155
|
+
return { key: entry.key, source: source && BARE_IDENTIFIER.test(source) ? source : null };
|
|
3156
|
+
}
|
|
3157
|
+
if (member.includes(".")) return { key: member, source: null };
|
|
3158
|
+
return { key: member, source: BARE_IDENTIFIER.test(member) ? member : null };
|
|
3159
|
+
}
|
|
2523
3160
|
var DEFAULT_CAPABILITIES = {
|
|
2524
3161
|
nativeSql: false,
|
|
2525
3162
|
objectqlAggregate: false,
|
|
@@ -2543,6 +3180,8 @@ var AnalyticsService = class {
|
|
|
2543
3180
|
this.draftRowsResolver = config.draftRowsResolver;
|
|
2544
3181
|
this.isRegisteredObject = config.isRegisteredObject;
|
|
2545
3182
|
this.getObjectFieldNames = config.getObjectFieldNames;
|
|
3183
|
+
this.getObjectDatasource = config.getObjectDatasource;
|
|
3184
|
+
this.isExternalObject = config.isExternalObject;
|
|
2546
3185
|
if (config.datasets) {
|
|
2547
3186
|
for (const ds of config.datasets) {
|
|
2548
3187
|
try {
|
|
@@ -2679,7 +3318,10 @@ var AnalyticsService = class {
|
|
|
2679
3318
|
* compiled dataset.
|
|
2680
3319
|
*/
|
|
2681
3320
|
registerDataset(dataset) {
|
|
2682
|
-
const compiled = compileDataset(dataset, this.relationshipResolver
|
|
3321
|
+
const compiled = compileDataset(dataset, this.relationshipResolver, {
|
|
3322
|
+
getObjectDatasource: this.getObjectDatasource,
|
|
3323
|
+
isExternalObject: this.isExternalObject
|
|
3324
|
+
});
|
|
2683
3325
|
this.cubeRegistry.register(compiled.cube);
|
|
2684
3326
|
this.datasetRegistry.set(dataset.name, compiled);
|
|
2685
3327
|
return compiled;
|
|
@@ -2723,9 +3365,22 @@ var AnalyticsService = class {
|
|
|
2723
3365
|
try {
|
|
2724
3366
|
result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context);
|
|
2725
3367
|
} catch (err) {
|
|
3368
|
+
if (hasDeclaredErrorEnvelope(err)) throw err;
|
|
2726
3369
|
if (isMissingSourceError(err)) {
|
|
3370
|
+
const missing = missingSourceRelation(err);
|
|
3371
|
+
const detail = String(err?.message ?? err);
|
|
3372
|
+
const joined = missing && missing.toLowerCase() !== dataset.object.toLowerCase() ? missing : void 0;
|
|
3373
|
+
if (joined && (this.isRegisteredObject?.(joined) ?? true)) {
|
|
3374
|
+
const baseDs = this.getObjectDatasource?.(dataset.object);
|
|
3375
|
+
const joinedDs = this.getObjectDatasource?.(joined);
|
|
3376
|
+
const where = baseDs ? `datasource "${baseDs}"` : "the default datasource";
|
|
3377
|
+
const joinedWhere = joinedDs ? `datasource "${joinedDs}"` : "the default datasource";
|
|
3378
|
+
throw new Error(
|
|
3379
|
+
`[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})`
|
|
3380
|
+
);
|
|
3381
|
+
}
|
|
2727
3382
|
this.logger.warn(
|
|
2728
|
-
`[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable (${
|
|
3383
|
+
`[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable (${detail}); returning an empty result instead of failing the widget`
|
|
2729
3384
|
);
|
|
2730
3385
|
return { rows: [], fields: [], totals: [] };
|
|
2731
3386
|
}
|
|
@@ -2818,13 +3473,19 @@ var AnalyticsService = class {
|
|
|
2818
3473
|
}
|
|
2819
3474
|
}
|
|
2820
3475
|
if (f.percentScale == null) {
|
|
2821
|
-
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0,
|
|
3476
|
+
f.percentScale = m.derived?.op === "ratio" ? "fraction" : (0, import_data4.percentScaleOf)(meta);
|
|
2822
3477
|
}
|
|
2823
3478
|
}
|
|
2824
3479
|
}
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
3480
|
+
const describableDims = [...selectedDims];
|
|
3481
|
+
for (const t of selection.timeDimensions ?? []) {
|
|
3482
|
+
if (describableDims.some((d2) => d2.name === t.dimension)) continue;
|
|
3483
|
+
const d = dataset.dimensions?.find((x) => x.name === t.dimension);
|
|
3484
|
+
if (d) describableDims.push(d);
|
|
3485
|
+
}
|
|
3486
|
+
if (result.fields?.length && describableDims.length) {
|
|
3487
|
+
const dimByName = new Map(describableDims.map((d) => [d.name, d]));
|
|
3488
|
+
const dimByField = new Map(describableDims.filter((d) => !!d.field).map((d) => [d.field, d]));
|
|
2828
3489
|
for (const f of result.fields) {
|
|
2829
3490
|
if (f.label != null) continue;
|
|
2830
3491
|
const d = dimByName.get(f.name) ?? dimByField.get(f.name);
|
|
@@ -2878,6 +3539,19 @@ var AnalyticsService = class {
|
|
|
2878
3539
|
* `cube.measures` (e.g. `amount_sum`, `amount_avg` emitted by dashboard
|
|
2879
3540
|
* widget translators), inject suffix-inferred Metric entries so the
|
|
2880
3541
|
* strategies pick the right aggregation function and field.
|
|
3542
|
+
*
|
|
3543
|
+
* It is also where the three SOURCE-FIELD gates run, on every path out of this
|
|
3544
|
+
* method and always BEFORE the (possibly augmented) cube is registered:
|
|
3545
|
+
* {@link assertMeasureFields} (#4437), {@link assertDimensionFields} (#5520)
|
|
3546
|
+
* and {@link assertWhereFields} (#5669) — one per request key that can carry a
|
|
3547
|
+
* field name. All three answer the same question — does the object actually
|
|
3548
|
+
* have the column this member resolves to — and all three must answer it here,
|
|
3549
|
+
* because from the strategy onwards the answer is the driver's `no such
|
|
3550
|
+
* column`.
|
|
3551
|
+
*
|
|
3552
|
+
* They run in request-key order (measures → dimensions/timeDimensions →
|
|
3553
|
+
* where), so a query that gets several wrong is answered about one at a time,
|
|
3554
|
+
* naming a real mistake either way.
|
|
2881
3555
|
*/
|
|
2882
3556
|
ensureCube(query) {
|
|
2883
3557
|
const name = query.cube;
|
|
@@ -2886,6 +3560,8 @@ var AnalyticsService = class {
|
|
|
2886
3560
|
this.assertInferableCube(name);
|
|
2887
3561
|
cube = this.inferCubeFromQuery(query);
|
|
2888
3562
|
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
3563
|
+
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
|
|
3564
|
+
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
|
|
2889
3565
|
this.cubeRegistry.register(cube);
|
|
2890
3566
|
const isScalarMetric = (query.dimensions?.length ?? 0) === 0 && (query.timeDimensions?.length ?? 0) === 0;
|
|
2891
3567
|
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.`;
|
|
@@ -2906,12 +3582,16 @@ var AnalyticsService = class {
|
|
|
2906
3582
|
measures: { ...cube.measures, ...extraMeasures }
|
|
2907
3583
|
};
|
|
2908
3584
|
this.assertMeasureFields(query, augmented, Object.keys(cube.measures));
|
|
3585
|
+
this.assertDimensionFields(query, augmented, Object.keys(cube.dimensions));
|
|
3586
|
+
this.assertWhereFields(query, augmented, Object.keys(cube.dimensions));
|
|
2909
3587
|
this.cubeRegistry.register(augmented);
|
|
2910
3588
|
this.logger.debug(
|
|
2911
3589
|
`[Analytics] Augmented cube "${name}" with inferred measures: ${Object.keys(extraMeasures).join(",")}`
|
|
2912
3590
|
);
|
|
2913
3591
|
} else {
|
|
2914
3592
|
this.assertMeasureFields(query, cube, Object.keys(cube.measures));
|
|
3593
|
+
this.assertDimensionFields(query, cube, Object.keys(cube.dimensions));
|
|
3594
|
+
this.assertWhereFields(query, cube, Object.keys(cube.dimensions));
|
|
2915
3595
|
}
|
|
2916
3596
|
}
|
|
2917
3597
|
/**
|
|
@@ -2983,6 +3663,207 @@ var AnalyticsService = class {
|
|
|
2983
3663
|
throw err;
|
|
2984
3664
|
}
|
|
2985
3665
|
}
|
|
3666
|
+
/**
|
|
3667
|
+
* [#5520] Reject a DIMENSION whose source field the backing object does not
|
|
3668
|
+
* have, BEFORE the strategy compiles it into `GROUP BY`.
|
|
3669
|
+
*
|
|
3670
|
+
* The symmetric half of {@link assertMeasureFields}. #4437 closed the measure
|
|
3671
|
+
* side and stopped there, so the identical mistake one request key over still
|
|
3672
|
+
* reached the driver:
|
|
3673
|
+
*
|
|
3674
|
+
* ```
|
|
3675
|
+
* POST /analytics/query {"cube":"crm_account","measures":["account_count"],"dimensions":["bogus_dim"]}
|
|
3676
|
+
* → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
|
|
3677
|
+
*
|
|
3678
|
+
* POST /analytics/dataset/query {"selection":{"dimensions":["bogus_dim"],…}}
|
|
3679
|
+
* → 500 {"code":"ANALYTICS_QUERY_FAILED",
|
|
3680
|
+
* "error":"SELECT bogus_dim AS \"bogus_dim\", … GROUP BY bogus_dim - no such column: bogus_dim"}
|
|
3681
|
+
* ```
|
|
3682
|
+
*
|
|
3683
|
+
* A driver error class as the caller's `error.code` is the ADR-0112 violation
|
|
3684
|
+
* #4437 named, and the dataset face additionally echoed the generated
|
|
3685
|
+
* statement — physical table and column names — back to the caller. The
|
|
3686
|
+
* envelope here is deliberately the SAME as the measure gate's
|
|
3687
|
+
* (`INVALID_FIELD`/400 + `field`/`object`/`param`), because "the query names a
|
|
3688
|
+
* field the object does not have" is ONE mistake and must have one wire shape
|
|
3689
|
+
* whichever member kind carried it.
|
|
3690
|
+
*
|
|
3691
|
+
* What it checks, and what it deliberately does not:
|
|
3692
|
+
*
|
|
3693
|
+
* - **Both dimension keys.** `query.dimensions` and `query.timeDimensions`
|
|
3694
|
+
* land in the same `cube.dimensions` bag, are resolved by the same
|
|
3695
|
+
* `lookupMember`, and produced the same 500 (a bogus time dimension became
|
|
3696
|
+
* `date_trunc('month', bogus_at)`); `param` reports which key carried it.
|
|
3697
|
+
* - **An UNDECLARED but real field stays legal.** `dimensions: ['phone']` on a
|
|
3698
|
+
* cube that never declared `phone` groups by `phone` today — the dimension
|
|
3699
|
+
* twin of measure auto-inference, and an established contract. So the
|
|
3700
|
+
* question asked is "does the OBJECT have this field", never "did the cube
|
|
3701
|
+
* declare this dimension". An undeclared member is checked against the
|
|
3702
|
+
* object under the name the strategies would use as the column (their own
|
|
3703
|
+
* `resolveDimensionSql`/`resolveFieldName` fallback: the member itself).
|
|
3704
|
+
* - Only when the cube's `sql` is a bare OBJECT NAME, only when
|
|
3705
|
+
* {@link AnalyticsServiceConfig.getObjectFieldNames} answers, and only for
|
|
3706
|
+
* sources that are BARE COLUMNS — same three stand-downs as the measure
|
|
3707
|
+
* gate, for the same reasons (no field list to check against; nothing
|
|
3708
|
+
* authoritative to consult; a dotted reference resolves through a join whose
|
|
3709
|
+
* target this gate cannot see, so it belongs to the join allowlist).
|
|
3710
|
+
* - `id` / `created_at` / `updated_at` are admitted unconditionally, matching
|
|
3711
|
+
* the data path's `resolveQueryFields`.
|
|
3712
|
+
*
|
|
3713
|
+
* Runs after the measure gate and before the `where` gate on each `ensureCube`
|
|
3714
|
+
* path, so a query that gets several wrong is answered about its measure
|
|
3715
|
+
* first — one rejection at a time, naming a real mistake either way.
|
|
3716
|
+
*/
|
|
3717
|
+
assertDimensionFields(query, cube, declaredDimensions) {
|
|
3718
|
+
const probe = this.getObjectFieldNames;
|
|
3719
|
+
if (!probe) return;
|
|
3720
|
+
const members = [
|
|
3721
|
+
...(query.dimensions ?? []).map((member) => ({ member, param: "dimensions" })),
|
|
3722
|
+
...(query.timeDimensions ?? []).map((td) => ({ member: td.dimension, param: "timeDimensions" }))
|
|
3723
|
+
];
|
|
3724
|
+
if (members.length === 0) return;
|
|
3725
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
3726
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
3727
|
+
const fieldNames = probe(object);
|
|
3728
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
3729
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
3730
|
+
const resolve = (member) => resolveMemberSource(cube, member, "dimension");
|
|
3731
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
3732
|
+
for (const { member } of members) {
|
|
3733
|
+
const { key, source } = resolve(member);
|
|
3734
|
+
if (source && !known.has(source)) invalid.add(key);
|
|
3735
|
+
}
|
|
3736
|
+
if (invalid.size === 0) return;
|
|
3737
|
+
const usable = declaredDimensions.filter((d) => !invalid.has(d));
|
|
3738
|
+
for (const { member, param } of members) {
|
|
3739
|
+
const { source } = resolve(member);
|
|
3740
|
+
if (!source || known.has(source)) continue;
|
|
3741
|
+
const kind = param === "timeDimensions" ? "Time dimension" : "Dimension";
|
|
3742
|
+
const verb = param === "timeDimensions" ? "buckets" : "groups by";
|
|
3743
|
+
const err = new Error(
|
|
3744
|
+
`${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(", ")}.`
|
|
3745
|
+
);
|
|
3746
|
+
err.code = "INVALID_FIELD";
|
|
3747
|
+
err.status = 400;
|
|
3748
|
+
err.field = source;
|
|
3749
|
+
err.object = object;
|
|
3750
|
+
err.param = param;
|
|
3751
|
+
err.dimension = member;
|
|
3752
|
+
throw err;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* [#5669] Reject a `where` member whose source field the backing object does
|
|
3757
|
+
* not have, BEFORE the strategy compiles it into `WHERE`.
|
|
3758
|
+
*
|
|
3759
|
+
* The third and last param of one defect. #4437 gated `measures`, #5520 gated
|
|
3760
|
+
* `dimensions`/`timeDimensions`, and the filter face — the request key that
|
|
3761
|
+
* most often carries a hand-typed field name — had no gate at all:
|
|
3762
|
+
*
|
|
3763
|
+
* ```
|
|
3764
|
+
* POST /analytics/query {"cube":"crm_account","measures":["count"],"where":{"bogus_col":"x"}}
|
|
3765
|
+
* → SELECT COUNT(*) AS "count" FROM "crm_account" WHERE bogus_col = $1
|
|
3766
|
+
* → 500 {"code":"SQLITE_ERROR","message":"Internal server error"}
|
|
3767
|
+
* ```
|
|
3768
|
+
*
|
|
3769
|
+
* Same envelope as its two siblings (`INVALID_FIELD`/400 + `field`/`object`/
|
|
3770
|
+
* `param`), because "the query names a field the object does not have" is ONE
|
|
3771
|
+
* mistake whichever request key carried it, and the DATA route has answered it
|
|
3772
|
+
* that way since #4315/#4254 (`resolveQueryFields`).
|
|
3773
|
+
*
|
|
3774
|
+
* # Where the field names come from: the SQL producer's own reader
|
|
3775
|
+
*
|
|
3776
|
+
* The members are collected through `normalizeAnalyticsFilterTree` +
|
|
3777
|
+
* `collectFilterLeaves` — the SAME pair both strategies call to build the
|
|
3778
|
+
* predicate. This is deliberate and is the whole reason this gate is not a
|
|
3779
|
+
* second filter-tree walker: a hand-rolled walk would have to re-derive
|
|
3780
|
+
* `$and`/`$or`/`$not` recursion, `$`-prefixed operator keys, `$between`
|
|
3781
|
+
* lowering, the nested-relation dot flattening (`{owner: {region: 'NA'}}` →
|
|
3782
|
+
* member `owner.region`) and the #5334 array lowering, and every divergence
|
|
3783
|
+
* would show up as "the field the gate saw" not being "the column that reached
|
|
3784
|
+
* SQL" — in either direction (a phantom rejection, or a hole).
|
|
3785
|
+
* `collectFilterLeaves` discards structure, which is exactly right here:
|
|
3786
|
+
* whether a predicate sits under an `$or` changes nothing about whether its
|
|
3787
|
+
* column exists. (Its doc's warning — never rebuild a predicate from this list
|
|
3788
|
+
* — does not apply; this gate builds nothing.)
|
|
3789
|
+
*
|
|
3790
|
+
* # Three stand-downs at query level, plus the per-member ones
|
|
3791
|
+
*
|
|
3792
|
+
* - No {@link AnalyticsServiceConfig.getObjectFieldNames}, cube `sql` that is
|
|
3793
|
+
* not a bare object name, or a probe that cannot answer for the object — the
|
|
3794
|
+
* same three tiers as the measure and dimension gates, for the same reasons.
|
|
3795
|
+
* - A `where` the normalizer REFUSES (an unknown operator, a non-array
|
|
3796
|
+
* `$and`, an unlowerable filter array) is not judged here: this gate stands
|
|
3797
|
+
* down and lets the refusal happen where it already does. Those inputs
|
|
3798
|
+
* already answer `INVALID_FILTER`/400 from the strategy (#5352/#5367's
|
|
3799
|
+
* geography, not this gate's), and pulling them forward into `ensureCube`
|
|
3800
|
+
* would newly refuse them on the draft-preview path too, whose
|
|
3801
|
+
* `matchesWhere` never consults the normalizer at all. A field gate that
|
|
3802
|
+
* cannot read the tree has nothing to say about it.
|
|
3803
|
+
* - Per member, {@link resolveMemberSource} stands down on an expression `sql`
|
|
3804
|
+
* and on a dotted relation traversal — for the dimension gate's reasons.
|
|
3805
|
+
*
|
|
3806
|
+
* # Array `where` IS gated, and #5353's fix did not change that
|
|
3807
|
+
*
|
|
3808
|
+
* Since #5334 an array `where` is lowered by `normalizeAnalyticsFilterTree`
|
|
3809
|
+
* and compiles to the identical predicate — a measured fact,
|
|
3810
|
+
* `where: [['bogus_col','=','x']]` and `where: {bogus_col: 'x'}` both produce
|
|
3811
|
+
* `WHERE bogus_col = $1` and hand `executeAggregate` the same
|
|
3812
|
+
* `{bogus_col: 'x'}`. Gating one spelling and not the other would answer one
|
|
3813
|
+
* mistake two ways, which is the split this whole gate family exists to close.
|
|
3814
|
+
*
|
|
3815
|
+
* `inferCubeFromQuery` used to skip an array `where` when minting the ad-hoc
|
|
3816
|
+
* cube's `dimensions` — a separate question (the cube's dimension VOCABULARY,
|
|
3817
|
+
* not which columns reach the driver), fixed by #5353 by lowering before
|
|
3818
|
+
* reading keys. Because this gate reads filter LEAVES rather than
|
|
3819
|
+
* `cube.dimensions`, that fix could not change its verdicts, and measurement
|
|
3820
|
+
* confirms it did not: the array where's keys now reach `cube.dimensions`, so
|
|
3821
|
+
* {@link resolveMemberSource} takes the DECLARED-dimension branch for those
|
|
3822
|
+
* members instead of the undeclared-bare-column one — and both branches yield
|
|
3823
|
+
* the same `source` for the same member, since the minted dimension's `sql` IS
|
|
3824
|
+
* the member name. What did change is the rejection's suggestion list, in the
|
|
3825
|
+
* direction that closes the split: `Valid filter members:` now reads the same
|
|
3826
|
+
* for both spellings of one filter.
|
|
3827
|
+
*/
|
|
3828
|
+
assertWhereFields(query, cube, declaredDimensions) {
|
|
3829
|
+
const probe = this.getObjectFieldNames;
|
|
3830
|
+
if (!probe) return;
|
|
3831
|
+
const where = query.where;
|
|
3832
|
+
if (!where || typeof where !== "object") return;
|
|
3833
|
+
const object = typeof cube.sql === "string" ? cube.sql.trim() : "";
|
|
3834
|
+
if (!object || !BARE_IDENTIFIER.test(object)) return;
|
|
3835
|
+
const fieldNames = probe(object);
|
|
3836
|
+
if (!fieldNames || fieldNames.length === 0) return;
|
|
3837
|
+
const known = /* @__PURE__ */ new Set([...fieldNames, "id", "created_at", "updated_at"]);
|
|
3838
|
+
let members;
|
|
3839
|
+
try {
|
|
3840
|
+
members = collectFilterLeaves(normalizeAnalyticsFilterTree(query)).map((leaf) => leaf.member);
|
|
3841
|
+
} catch {
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
if (members.length === 0) return;
|
|
3845
|
+
const invalid = /* @__PURE__ */ new Set();
|
|
3846
|
+
for (const member of members) {
|
|
3847
|
+
const { key, source } = resolveMemberSource(cube, member, "any");
|
|
3848
|
+
if (source && !known.has(source)) invalid.add(key);
|
|
3849
|
+
}
|
|
3850
|
+
if (invalid.size === 0) return;
|
|
3851
|
+
const usable = declaredDimensions.filter((d) => !invalid.has(d));
|
|
3852
|
+
for (const member of members) {
|
|
3853
|
+
const { source } = resolveMemberSource(cube, member, "any");
|
|
3854
|
+
if (!source || known.has(source)) continue;
|
|
3855
|
+
const err = new Error(
|
|
3856
|
+
`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(", ")}.`
|
|
3857
|
+
);
|
|
3858
|
+
err.code = "INVALID_FIELD";
|
|
3859
|
+
err.status = 400;
|
|
3860
|
+
err.field = source;
|
|
3861
|
+
err.object = object;
|
|
3862
|
+
err.param = "where";
|
|
3863
|
+
err.member = member;
|
|
3864
|
+
throw err;
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
2986
3867
|
/**
|
|
2987
3868
|
* [#3867] Gate on the cube auto-inference path: a name with no registered
|
|
2988
3869
|
* Cube may only be inferred into one if it is a registered object.
|
|
@@ -3021,29 +3902,37 @@ var AnalyticsService = class {
|
|
|
3021
3902
|
const cubeName = query.cube;
|
|
3022
3903
|
const measures = {};
|
|
3023
3904
|
const dimensions = {};
|
|
3024
|
-
const
|
|
3905
|
+
const stripCubeQualifier = (m) => {
|
|
3906
|
+
const dot = m.indexOf(".");
|
|
3907
|
+
if (dot < 0) return m;
|
|
3908
|
+
return m.slice(0, dot) === cubeName ? m.slice(dot + 1) : m;
|
|
3909
|
+
};
|
|
3025
3910
|
measures.count = { name: "count", label: "Count", type: "count", sql: "*" };
|
|
3026
3911
|
for (const m of query.measures || []) {
|
|
3027
|
-
const key =
|
|
3912
|
+
const key = m.includes(".") ? m.split(".").slice(1).join(".") : m;
|
|
3028
3913
|
if (measures[key]) continue;
|
|
3029
3914
|
const inferred = inferMeasure(key);
|
|
3030
3915
|
measures[key] = inferred;
|
|
3031
3916
|
}
|
|
3032
3917
|
for (const d of query.dimensions || []) {
|
|
3033
|
-
const key =
|
|
3918
|
+
const key = stripCubeQualifier(d);
|
|
3034
3919
|
if (dimensions[key]) continue;
|
|
3035
3920
|
dimensions[key] = { name: key, label: key, type: "string", sql: key };
|
|
3036
3921
|
}
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3922
|
+
let lowered = null;
|
|
3923
|
+
try {
|
|
3924
|
+
lowered = lowerAnalyticsWhere(query);
|
|
3925
|
+
} catch {
|
|
3926
|
+
}
|
|
3927
|
+
if (lowered) {
|
|
3928
|
+
for (const key of conjunctFieldKeys(lowered)) {
|
|
3929
|
+
const minted = stripCubeQualifier(key);
|
|
3930
|
+
if (dimensions[minted] || measures[minted]) continue;
|
|
3931
|
+
dimensions[minted] = { name: minted, label: minted, type: "string", sql: minted };
|
|
3043
3932
|
}
|
|
3044
3933
|
}
|
|
3045
3934
|
for (const td of query.timeDimensions || []) {
|
|
3046
|
-
const key =
|
|
3935
|
+
const key = stripCubeQualifier(td.dimension);
|
|
3047
3936
|
if (dimensions[key]) continue;
|
|
3048
3937
|
dimensions[key] = {
|
|
3049
3938
|
name: key,
|
|
@@ -3210,7 +4099,7 @@ var AnalyticsServicePlugin = class {
|
|
|
3210
4099
|
return void 0;
|
|
3211
4100
|
}
|
|
3212
4101
|
};
|
|
3213
|
-
executeRawSql = async (
|
|
4102
|
+
executeRawSql = async (objectName, sql, params) => {
|
|
3214
4103
|
const engine = tryGetExecutor();
|
|
3215
4104
|
if (!engine || !engine.execute) {
|
|
3216
4105
|
throw new Error(
|
|
@@ -3218,7 +4107,7 @@ var AnalyticsServicePlugin = class {
|
|
|
3218
4107
|
);
|
|
3219
4108
|
}
|
|
3220
4109
|
const knexSql = sql.replace(/\$(\d+)/g, "?");
|
|
3221
|
-
const result = await engine.execute(knexSql, { args: params });
|
|
4110
|
+
const result = await engine.execute(knexSql, { args: params, object: objectName });
|
|
3222
4111
|
if (result === null || result === void 0) {
|
|
3223
4112
|
const err = new Error(
|
|
3224
4113
|
`[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.`
|
|
@@ -3372,6 +4261,12 @@ var AnalyticsServicePlugin = class {
|
|
|
3372
4261
|
const f = dataEngine()?.getObject?.(object)?.fields?.[field];
|
|
3373
4262
|
return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : void 0;
|
|
3374
4263
|
},
|
|
4264
|
+
// #5033 — the datasource an object is bound to, used ONLY to name the
|
|
4265
|
+
// actual cause when a dataset's SQL references a table that is not on the
|
|
4266
|
+
// datasource the query was routed to. Undefined ⇒ the object rides the
|
|
4267
|
+
// default datasource (or the engine cannot answer), and the diagnostic
|
|
4268
|
+
// says so rather than inventing a name.
|
|
4269
|
+
getObjectDatasource: (objectName) => dataEngine()?.getObject?.(objectName)?.datasource,
|
|
3375
4270
|
// ADR-0062 D6 — a federated object carries an `external` block (ADR-0015).
|
|
3376
4271
|
// Reported so NativeSQLStrategy declines it (its hand-compiled FROM would
|
|
3377
4272
|
// hit the wrong physical table) and the driver-correct ObjectQL path runs.
|
|
@@ -3393,7 +4288,8 @@ var AnalyticsServicePlugin = class {
|
|
|
3393
4288
|
if (!engine) return true;
|
|
3394
4289
|
return engine.getObject?.(name) != null;
|
|
3395
4290
|
},
|
|
3396
|
-
// [#4437] Field names for the
|
|
4291
|
+
// [#4437, #5520] Field names for the two source-field gates — measures
|
|
4292
|
+
// (#4437) and dimensions/timeDimensions (#5520). Read from the
|
|
3397
4293
|
// SAME schema registry `isRegisteredObject` above consults (and the data
|
|
3398
4294
|
// path's #4315 gate reads), so "which fields exist" has one answer across
|
|
3399
4295
|
// /data and /analytics. `undefined` — no engine, unknown object, or an
|