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