@objectstack/driver-sql 6.1.1 → 6.3.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.mts CHANGED
@@ -90,6 +90,8 @@ declare class SqlDriver implements IDataDriver {
90
90
  protected config: Knex.Config;
91
91
  protected jsonFields: Record<string, string[]>;
92
92
  protected booleanFields: Record<string, string[]>;
93
+ protected dateFields: Record<string, Set<string>>;
94
+ protected datetimeFields: Record<string, Set<string>>;
93
95
  protected tablesWithTimestamps: Set<string>;
94
96
  /**
95
97
  * Autonumber field configs per table, captured during initObjects.
@@ -332,8 +334,30 @@ declare class SqlDriver implements IDataDriver {
332
334
  * `OS_TENANT_AUDIT=0`) to silence intentionally.
333
335
  */
334
336
  protected auditMissingTenant(object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions): void;
337
+ /**
338
+ * Resolve the underlying table name for a Knex query builder so we can
339
+ * look up column type metadata (date/datetime maps populated during
340
+ * `initObjects`). Returns null when the builder is not table-scoped yet.
341
+ */
342
+ protected tableNameForBuilder(builder: any): string | null;
343
+ /**
344
+ * Normalise a filter value for a single column so the comparison the
345
+ * driver sends to SQLite matches the on-disk representation.
346
+ *
347
+ * The platform stores `Field.datetime()` values as INTEGER milliseconds
348
+ * (the result of passing a JS `Date` through better-sqlite3) but date
349
+ * macros like `{last_quarter_start}` expand to an ISO `YYYY-MM-DD` string
350
+ * client-side. Without coercion the SQL becomes `published_at >= '2026-…'`
351
+ * which collapses to a TEXT-vs-INTEGER affinity compare and never
352
+ * matches. We translate the ISO/Date/numeric inputs into the storage
353
+ * type so the comparison works.
354
+ *
355
+ * For `Field.date()` we keep ISO TEXT but normalise Date objects to
356
+ * `YYYY-MM-DD` for the same reason.
357
+ */
358
+ protected coerceFilterValue(table: string | null, field: string, value: any): any;
335
359
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
336
- protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or'): void;
360
+ protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
337
361
  protected mapSortField(field: string): string;
338
362
  protected mapAggregateFunc(func: string): string;
339
363
  protected buildWindowFunction(spec: any): string;
package/dist/index.d.ts CHANGED
@@ -90,6 +90,8 @@ declare class SqlDriver implements IDataDriver {
90
90
  protected config: Knex.Config;
91
91
  protected jsonFields: Record<string, string[]>;
92
92
  protected booleanFields: Record<string, string[]>;
93
+ protected dateFields: Record<string, Set<string>>;
94
+ protected datetimeFields: Record<string, Set<string>>;
93
95
  protected tablesWithTimestamps: Set<string>;
94
96
  /**
95
97
  * Autonumber field configs per table, captured during initObjects.
@@ -332,8 +334,30 @@ declare class SqlDriver implements IDataDriver {
332
334
  * `OS_TENANT_AUDIT=0`) to silence intentionally.
333
335
  */
334
336
  protected auditMissingTenant(object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions): void;
337
+ /**
338
+ * Resolve the underlying table name for a Knex query builder so we can
339
+ * look up column type metadata (date/datetime maps populated during
340
+ * `initObjects`). Returns null when the builder is not table-scoped yet.
341
+ */
342
+ protected tableNameForBuilder(builder: any): string | null;
343
+ /**
344
+ * Normalise a filter value for a single column so the comparison the
345
+ * driver sends to SQLite matches the on-disk representation.
346
+ *
347
+ * The platform stores `Field.datetime()` values as INTEGER milliseconds
348
+ * (the result of passing a JS `Date` through better-sqlite3) but date
349
+ * macros like `{last_quarter_start}` expand to an ISO `YYYY-MM-DD` string
350
+ * client-side. Without coercion the SQL becomes `published_at >= '2026-…'`
351
+ * which collapses to a TEXT-vs-INTEGER affinity compare and never
352
+ * matches. We translate the ISO/Date/numeric inputs into the storage
353
+ * type so the comparison works.
354
+ *
355
+ * For `Field.date()` we keep ISO TEXT but normalise Date objects to
356
+ * `YYYY-MM-DD` for the same reason.
357
+ */
358
+ protected coerceFilterValue(table: string | null, field: string, value: any): any;
335
359
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
336
- protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or'): void;
360
+ protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
337
361
  protected mapSortField(field: string): string;
338
362
  protected mapAggregateFunc(func: string): string;
339
363
  protected buildWindowFunction(spec: any): string;
package/dist/index.js CHANGED
@@ -49,6 +49,8 @@ var SqlDriver = class {
49
49
  this.version = "1.0.0";
50
50
  this.jsonFields = {};
51
51
  this.booleanFields = {};
52
+ this.dateFields = {};
53
+ this.datetimeFields = {};
52
54
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
53
55
  /**
54
56
  * Autonumber field configs per table, captured during initObjects.
@@ -783,6 +785,7 @@ var SqlDriver = class {
783
785
  * Batch-initialise tables from an array of object definitions.
784
786
  */
785
787
  async initObjects(objects) {
788
+ var _a, _b;
786
789
  await this.ensureDatabaseExists();
787
790
  for (const obj of objects) {
788
791
  const tableName = import_system.StorageNameMapping.resolveTableName(obj);
@@ -810,6 +813,12 @@ var SqlDriver = class {
810
813
  if (type === "boolean") {
811
814
  booleanCols.push(name);
812
815
  }
816
+ if (type === "date") {
817
+ ((_a = this.dateFields)[tableName] ?? (_a[tableName] = /* @__PURE__ */ new Set())).add(name);
818
+ }
819
+ if (type === "datetime") {
820
+ ((_b = this.datetimeFields)[tableName] ?? (_b[tableName] = /* @__PURE__ */ new Set())).add(name);
821
+ }
813
822
  if (type === "auto_number" || type === "autonumber") {
814
823
  const fmt = typeof field.format === "string" && field.format ? field.format : "{0000}";
815
824
  const m = fmt.match(/\{(0+)\}/);
@@ -997,19 +1006,83 @@ var SqlDriver = class {
997
1006
  );
998
1007
  }
999
1008
  // ── Filter helpers ──────────────────────────────────────────────────────────
1009
+ /**
1010
+ * Resolve the underlying table name for a Knex query builder so we can
1011
+ * look up column type metadata (date/datetime maps populated during
1012
+ * `initObjects`). Returns null when the builder is not table-scoped yet.
1013
+ */
1014
+ tableNameForBuilder(builder) {
1015
+ const t = builder?._single?.table;
1016
+ if (typeof t === "string") return t;
1017
+ return null;
1018
+ }
1019
+ /**
1020
+ * Normalise a filter value for a single column so the comparison the
1021
+ * driver sends to SQLite matches the on-disk representation.
1022
+ *
1023
+ * The platform stores `Field.datetime()` values as INTEGER milliseconds
1024
+ * (the result of passing a JS `Date` through better-sqlite3) but date
1025
+ * macros like `{last_quarter_start}` expand to an ISO `YYYY-MM-DD` string
1026
+ * client-side. Without coercion the SQL becomes `published_at >= '2026-…'`
1027
+ * which collapses to a TEXT-vs-INTEGER affinity compare and never
1028
+ * matches. We translate the ISO/Date/numeric inputs into the storage
1029
+ * type so the comparison works.
1030
+ *
1031
+ * For `Field.date()` we keep ISO TEXT but normalise Date objects to
1032
+ * `YYYY-MM-DD` for the same reason.
1033
+ */
1034
+ coerceFilterValue(table, field, value) {
1035
+ if (value == null || !table) return value;
1036
+ if (Array.isArray(value)) return value.map((v) => this.coerceFilterValue(table, field, v));
1037
+ const isDatetime = this.datetimeFields[table]?.has(field);
1038
+ const isDate = this.dateFields[table]?.has(field);
1039
+ if (!isDatetime && !isDate) return value;
1040
+ const toMs = (v) => {
1041
+ if (v instanceof Date) return v.getTime();
1042
+ if (typeof v === "number" && Number.isFinite(v)) return v;
1043
+ if (typeof v === "string") {
1044
+ const trimmed = v.trim();
1045
+ if (trimmed === "") return null;
1046
+ if (/^-?\d+$/.test(trimmed)) {
1047
+ const n2 = Number(trimmed);
1048
+ if (Number.isFinite(n2)) return n2;
1049
+ }
1050
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed;
1051
+ const n = Date.parse(iso);
1052
+ return Number.isFinite(n) ? n : null;
1053
+ }
1054
+ return null;
1055
+ };
1056
+ if (isDatetime) {
1057
+ const ms = toMs(value);
1058
+ return ms == null ? value : ms;
1059
+ }
1060
+ if (value instanceof Date) {
1061
+ const y = value.getUTCFullYear();
1062
+ const m = String(value.getUTCMonth() + 1).padStart(2, "0");
1063
+ const d = String(value.getUTCDate()).padStart(2, "0");
1064
+ return `${y}-${m}-${d}`;
1065
+ }
1066
+ if (typeof value === "string") {
1067
+ const trimmed = value.trim();
1068
+ if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
1069
+ }
1070
+ return value;
1071
+ }
1000
1072
  applyFilters(builder, filters) {
1001
1073
  if (!filters) return;
1074
+ const table = this.tableNameForBuilder(builder);
1002
1075
  if (!Array.isArray(filters) && typeof filters === "object") {
1003
1076
  const hasMongoOperators = Object.keys(filters).some(
1004
1077
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
1005
1078
  );
1006
1079
  if (hasMongoOperators) {
1007
- this.applyFilterCondition(builder, filters);
1080
+ this.applyFilterCondition(builder, filters, "and", table);
1008
1081
  return;
1009
1082
  }
1010
1083
  for (const [key, value] of Object.entries(filters)) {
1011
1084
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1012
- builder.where(key, value);
1085
+ builder.where(key, this.coerceFilterValue(table, key, value));
1013
1086
  }
1014
1087
  return;
1015
1088
  }
@@ -1026,6 +1099,7 @@ var SqlDriver = class {
1026
1099
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1027
1100
  if (isCriterion) {
1028
1101
  const field = this.mapSortField(fieldRaw);
1102
+ const coerced = this.coerceFilterValue(table, field, value);
1029
1103
  const apply = (b) => {
1030
1104
  const method = nextJoin === "or" ? "orWhere" : "where";
1031
1105
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1036,19 +1110,19 @@ var SqlDriver = class {
1036
1110
  }
1037
1111
  switch (op) {
1038
1112
  case "=":
1039
- b[method](field, value);
1113
+ b[method](field, coerced);
1040
1114
  break;
1041
1115
  case "!=":
1042
- b[method](field, "<>", value);
1116
+ b[method](field, "<>", coerced);
1043
1117
  break;
1044
1118
  case "in":
1045
- b[methodIn](field, value);
1119
+ b[methodIn](field, coerced);
1046
1120
  break;
1047
1121
  case "nin":
1048
- b[methodNotIn](field, value);
1122
+ b[methodNotIn](field, coerced);
1049
1123
  break;
1050
1124
  default:
1051
- b[method](field, op, value);
1125
+ b[method](field, op, coerced);
1052
1126
  }
1053
1127
  };
1054
1128
  apply(builder);
@@ -1062,14 +1136,15 @@ var SqlDriver = class {
1062
1136
  }
1063
1137
  }
1064
1138
  }
1065
- applyFilterCondition(builder, condition, logicalOp = "and") {
1139
+ applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1066
1140
  if (!condition || typeof condition !== "object") return;
1141
+ const table = tableHint ?? this.tableNameForBuilder(builder);
1067
1142
  for (const [key, value] of Object.entries(condition)) {
1068
1143
  if (key === "$and" && Array.isArray(value)) {
1069
1144
  builder.where((qb) => {
1070
1145
  for (const sub of value) {
1071
1146
  qb.where((subQb) => {
1072
- this.applyFilterCondition(subQb, sub, "and");
1147
+ this.applyFilterCondition(subQb, sub, "and", table);
1073
1148
  });
1074
1149
  }
1075
1150
  });
@@ -1078,7 +1153,7 @@ var SqlDriver = class {
1078
1153
  builder[method]((qb) => {
1079
1154
  for (const sub of value) {
1080
1155
  qb.orWhere((subQb) => {
1081
- this.applyFilterCondition(subQb, sub, "or");
1156
+ this.applyFilterCondition(subQb, sub, "or", table);
1082
1157
  });
1083
1158
  }
1084
1159
  });
@@ -1086,46 +1161,47 @@ var SqlDriver = class {
1086
1161
  const field = this.mapSortField(key);
1087
1162
  for (const [op, opValue] of Object.entries(value)) {
1088
1163
  const method = logicalOp === "or" ? "orWhere" : "where";
1164
+ const coerced = this.coerceFilterValue(table, field, opValue);
1089
1165
  switch (op) {
1090
1166
  case "$eq":
1091
- builder[method](field, opValue);
1167
+ builder[method](field, coerced);
1092
1168
  break;
1093
1169
  case "$ne":
1094
- builder[method](field, "<>", opValue);
1170
+ builder[method](field, "<>", coerced);
1095
1171
  break;
1096
1172
  case "$gt":
1097
- builder[method](field, ">", opValue);
1173
+ builder[method](field, ">", coerced);
1098
1174
  break;
1099
1175
  case "$gte":
1100
- builder[method](field, ">=", opValue);
1176
+ builder[method](field, ">=", coerced);
1101
1177
  break;
1102
1178
  case "$lt":
1103
- builder[method](field, "<", opValue);
1179
+ builder[method](field, "<", coerced);
1104
1180
  break;
1105
1181
  case "$lte":
1106
- builder[method](field, "<=", opValue);
1182
+ builder[method](field, "<=", coerced);
1107
1183
  break;
1108
1184
  case "$in": {
1109
1185
  const mIn = logicalOp === "or" ? "orWhereIn" : "whereIn";
1110
- builder[mIn](field, opValue);
1186
+ builder[mIn](field, coerced);
1111
1187
  break;
1112
1188
  }
1113
1189
  case "$nin": {
1114
1190
  const mNotIn = logicalOp === "or" ? "orWhereNotIn" : "whereNotIn";
1115
- builder[mNotIn](field, opValue);
1191
+ builder[mNotIn](field, coerced);
1116
1192
  break;
1117
1193
  }
1118
1194
  case "$contains":
1119
1195
  builder[method](field, "like", `%${opValue}%`);
1120
1196
  break;
1121
1197
  default:
1122
- builder[method](field, opValue);
1198
+ builder[method](field, coerced);
1123
1199
  }
1124
1200
  }
1125
1201
  } else {
1126
1202
  const field = this.mapSortField(key);
1127
1203
  const method = logicalOp === "or" ? "orWhere" : "where";
1128
- builder[method](field, value);
1204
+ builder[method](field, this.coerceFilterValue(table, field, value));
1129
1205
  }
1130
1206
  }
1131
1207
  }