@objectstack/service-analytics 17.0.0-rc.3 → 17.0.0-rc.5

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