@objectstack/service-analytics 9.9.1 → 9.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -212,6 +212,15 @@ interface AnalyticsServiceConfig {
212
212
  * config hook is a fallback for legacy hand-authored cubes.
213
213
  */
214
214
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
215
+ /**
216
+ * Coerce a filter comparand to a temporal column's storage form so a
217
+ * relative-date / ISO-string value compares correctly on the active driver
218
+ * (SQLite `Field.datetime` → epoch ms; `Field.date` / native timestamp →
219
+ * unchanged). Threaded into the StrategyContext and consulted by
220
+ * `NativeSQLStrategy` when binding filter values. See the contract docs on
221
+ * `StrategyContext.coerceTemporalFilterValue` for the full rationale.
222
+ */
223
+ coerceTemporalFilterValue?: (objectName: string, fieldName: string, value: unknown) => unknown;
215
224
  /**
216
225
  * ADR-0021 — optional object-graph resolver used when compiling datasets:
217
226
  * `(baseObject, relationshipName) => relatedObjectName | undefined`. When
@@ -561,6 +570,31 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
561
570
  private resolveDimensionSql;
562
571
  private resolveMeasureSql;
563
572
  private resolveFieldSql;
573
+ /**
574
+ * Resolve the (object, column) a filter member binds against, so its
575
+ * comparand can be coerced to that column's on-disk storage form.
576
+ *
577
+ * Mirrors `resolveFieldSql`'s `sql` resolution but yields the *logical*
578
+ * target rather than the qualified SQL:
579
+ * - A dotted column (`account.region`, emitted for a relation traversal)
580
+ * belongs to the JOINED object — resolve the alias → target table via the
581
+ * cube's `joins` map (alias `account` → object `crm_account` when
582
+ * namespaced) and take the tail as the column.
583
+ * - Otherwise the column lives on the cube's BASE table. Use the dimension's
584
+ * resolved `sql` (the real column, which may differ from the member name,
585
+ * e.g. dimension `assessed` → column `assessed_at`) rather than the member.
586
+ */
587
+ private resolveStorageTarget;
588
+ /**
589
+ * Apply the storage-form coercion for a single comparand. Prefers the
590
+ * driver-backed `coerceTemporalFilterValue` hook (single source of truth for
591
+ * the date/datetime storage convention — see StrategyContext); when the hook
592
+ * is absent, or returns the value unchanged (the field is not a temporal
593
+ * column, or the dialect stores it as a native timestamp), falls back to the
594
+ * generic boolean/number recovery so non-temporal typed columns still bind
595
+ * correctly.
596
+ */
597
+ private coerceTemporal;
564
598
  private buildFilterClause;
565
599
  private extractObjectName;
566
600
  private buildFieldMeta;
package/dist/index.d.ts CHANGED
@@ -212,6 +212,15 @@ interface AnalyticsServiceConfig {
212
212
  * config hook is a fallback for legacy hand-authored cubes.
213
213
  */
214
214
  getAllowedRelationships?: (cubeName: string) => Set<string> | undefined;
215
+ /**
216
+ * Coerce a filter comparand to a temporal column's storage form so a
217
+ * relative-date / ISO-string value compares correctly on the active driver
218
+ * (SQLite `Field.datetime` → epoch ms; `Field.date` / native timestamp →
219
+ * unchanged). Threaded into the StrategyContext and consulted by
220
+ * `NativeSQLStrategy` when binding filter values. See the contract docs on
221
+ * `StrategyContext.coerceTemporalFilterValue` for the full rationale.
222
+ */
223
+ coerceTemporalFilterValue?: (objectName: string, fieldName: string, value: unknown) => unknown;
215
224
  /**
216
225
  * ADR-0021 — optional object-graph resolver used when compiling datasets:
217
226
  * `(baseObject, relationshipName) => relatedObjectName | undefined`. When
@@ -561,6 +570,31 @@ declare class NativeSQLStrategy implements AnalyticsStrategy {
561
570
  private resolveDimensionSql;
562
571
  private resolveMeasureSql;
563
572
  private resolveFieldSql;
573
+ /**
574
+ * Resolve the (object, column) a filter member binds against, so its
575
+ * comparand can be coerced to that column's on-disk storage form.
576
+ *
577
+ * Mirrors `resolveFieldSql`'s `sql` resolution but yields the *logical*
578
+ * target rather than the qualified SQL:
579
+ * - A dotted column (`account.region`, emitted for a relation traversal)
580
+ * belongs to the JOINED object — resolve the alias → target table via the
581
+ * cube's `joins` map (alias `account` → object `crm_account` when
582
+ * namespaced) and take the tail as the column.
583
+ * - Otherwise the column lives on the cube's BASE table. Use the dimension's
584
+ * resolved `sql` (the real column, which may differ from the member name,
585
+ * e.g. dimension `assessed` → column `assessed_at`) rather than the member.
586
+ */
587
+ private resolveStorageTarget;
588
+ /**
589
+ * Apply the storage-form coercion for a single comparand. Prefers the
590
+ * driver-backed `coerceTemporalFilterValue` hook (single source of truth for
591
+ * the date/datetime storage convention — see StrategyContext); when the hook
592
+ * is absent, or returns the value unchanged (the field is not a temporal
593
+ * column, or the dialect stores it as a native timestamp), falls back to the
594
+ * generic boolean/number recovery so non-temporal typed columns still bind
595
+ * correctly.
596
+ */
597
+ private coerceTemporal;
564
598
  private buildFilterClause;
565
599
  private extractObjectName;
566
600
  private buildFieldMeta;
package/dist/index.js CHANGED
@@ -319,6 +319,7 @@ var NativeSQLStrategy = class {
319
319
  }
320
320
  canHandle(query, ctx) {
321
321
  if (!query.cube) return false;
322
+ if (query.timeDimensions?.some((td) => !!td.granularity)) return false;
322
323
  const caps = ctx.queryCapabilities(query.cube);
323
324
  return caps.nativeSql && typeof ctx.executeRawSql === "function";
324
325
  }
@@ -358,7 +359,8 @@ var NativeSQLStrategy = class {
358
359
  if (normalizedFilters.length > 0) {
359
360
  for (const filter of normalizedFilters) {
360
361
  const colExpr = this.resolveFieldSql(cube, filter.member, tableName, joins);
361
- const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params);
362
+ const target = this.resolveStorageTarget(cube, filter.member, tableName);
363
+ const clause = this.buildFilterClause(colExpr, filter.operator, filter.values, params, ctx, target);
362
364
  if (clause) whereClauses.push(clause);
363
365
  }
364
366
  }
@@ -368,7 +370,11 @@ var NativeSQLStrategy = class {
368
370
  if (td.dateRange) {
369
371
  const range = Array.isArray(td.dateRange) ? td.dateRange : [td.dateRange, td.dateRange];
370
372
  if (range.length === 2) {
371
- params.push(range[0], range[1]);
373
+ const td2 = this.resolveStorageTarget(cube, td.dimension, tableName);
374
+ params.push(
375
+ this.coerceTemporal(ctx, td2, range[0]),
376
+ this.coerceTemporal(ctx, td2, range[1])
377
+ );
372
378
  whereClauses.push(`${colExpr} BETWEEN $${params.length - 1} AND $${params.length}`);
373
379
  }
374
380
  }
@@ -532,7 +538,48 @@ var NativeSQLStrategy = class {
532
538
  const fieldName = member.includes(".") ? member.split(".")[1] : member;
533
539
  return fieldName;
534
540
  }
535
- buildFilterClause(col, operator, values, params) {
541
+ /**
542
+ * Resolve the (object, column) a filter member binds against, so its
543
+ * comparand can be coerced to that column's on-disk storage form.
544
+ *
545
+ * Mirrors `resolveFieldSql`'s `sql` resolution but yields the *logical*
546
+ * target rather than the qualified SQL:
547
+ * - A dotted column (`account.region`, emitted for a relation traversal)
548
+ * belongs to the JOINED object — resolve the alias → target table via the
549
+ * cube's `joins` map (alias `account` → object `crm_account` when
550
+ * namespaced) and take the tail as the column.
551
+ * - Otherwise the column lives on the cube's BASE table. Use the dimension's
552
+ * resolved `sql` (the real column, which may differ from the member name,
553
+ * e.g. dimension `assessed` → column `assessed_at`) rather than the member.
554
+ */
555
+ resolveStorageTarget(cube, member, baseTable) {
556
+ const dim = this.lookupMember(cube, member, "dimension");
557
+ const measure = dim ? void 0 : this.lookupMember(cube, member, "measure");
558
+ const rawSql = dim?.sql ?? measure?.sql ?? (member.includes(".") ? member.split(".").slice(1).join(".") : member);
559
+ if (rawSql.includes(".")) {
560
+ const [alias, ...rest] = rawSql.split(".");
561
+ const object = cube.joins?.[alias]?.name ?? alias;
562
+ return { object, field: rest.join(".") };
563
+ }
564
+ return { object: baseTable, field: rawSql };
565
+ }
566
+ /**
567
+ * Apply the storage-form coercion for a single comparand. Prefers the
568
+ * driver-backed `coerceTemporalFilterValue` hook (single source of truth for
569
+ * the date/datetime storage convention — see StrategyContext); when the hook
570
+ * is absent, or returns the value unchanged (the field is not a temporal
571
+ * column, or the dialect stores it as a native timestamp), falls back to the
572
+ * generic boolean/number recovery so non-temporal typed columns still bind
573
+ * correctly.
574
+ */
575
+ coerceTemporal(ctx, target, value) {
576
+ if (typeof ctx.coerceTemporalFilterValue === "function") {
577
+ const coerced = ctx.coerceTemporalFilterValue(target.object, target.field, value);
578
+ if (coerced !== value) return coerced;
579
+ }
580
+ return coerceFilterValueForSql(value);
581
+ }
582
+ buildFilterClause(col, operator, values, params, ctx, target) {
536
583
  const opMap = {
537
584
  equals: "=",
538
585
  notEquals: "!=",
@@ -548,7 +595,7 @@ var NativeSQLStrategy = class {
548
595
  if (operator === "in" || operator === "notIn") {
549
596
  if (!values || values.length === 0) return null;
550
597
  const placeholders = values.map((v) => {
551
- params.push(coerceFilterValueForSql(v));
598
+ params.push(this.coerceTemporal(ctx, target, v));
552
599
  return `$${params.length}`;
553
600
  }).join(", ");
554
601
  return `${col} ${operator === "in" ? "IN" : "NOT IN"} (${placeholders})`;
@@ -558,7 +605,7 @@ var NativeSQLStrategy = class {
558
605
  if (operator === "contains" || operator === "notContains") {
559
606
  params.push(`%${values[0]}%`);
560
607
  } else {
561
- params.push(coerceFilterValueForSql(values[0]));
608
+ params.push(this.coerceTemporal(ctx, target, values[0]));
562
609
  }
563
610
  return `${col} ${sqlOp} $${params.length}`;
564
611
  }
@@ -1428,7 +1475,8 @@ var AnalyticsService = class {
1428
1475
  fallbackService: config.fallbackService,
1429
1476
  // Prefer a compiled dataset's declared relationships (D-C join allowlist);
1430
1477
  // fall back to any explicitly-configured provider for legacy cubes.
1431
- getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName)
1478
+ getAllowedRelationships: (cubeName) => this.datasetRegistry.get(cubeName)?.allowedRelationships ?? config.getAllowedRelationships?.(cubeName),
1479
+ coerceTemporalFilterValue: config.coerceTemporalFilterValue
1432
1480
  };
1433
1481
  const builtIn = [
1434
1482
  new NativeSQLStrategy(),
@@ -1973,6 +2021,17 @@ var AnalyticsServicePlugin = class {
1973
2021
  }
1974
2022
  return pending ? rows : null;
1975
2023
  };
2024
+ const coerceTemporalFilterValue = (objectName, fieldName, value) => {
2025
+ try {
2026
+ const svc = ctx.getService("data");
2027
+ const driver = svc?.getDriverForObject?.(objectName);
2028
+ if (driver && typeof driver.temporalFilterValue === "function") {
2029
+ return driver.temporalFilterValue(objectName, fieldName, value);
2030
+ }
2031
+ } catch {
2032
+ }
2033
+ return value;
2034
+ };
1976
2035
  const config = {
1977
2036
  cubes: this.options.cubes,
1978
2037
  logger: ctx.logger,
@@ -1982,6 +2041,7 @@ var AnalyticsServicePlugin = class {
1982
2041
  fallbackService,
1983
2042
  getReadScope,
1984
2043
  getAllowedRelationships: this.options.getAllowedRelationships,
2044
+ coerceTemporalFilterValue,
1985
2045
  relationshipResolver,
1986
2046
  labelResolver,
1987
2047
  draftRowsResolver