@happyvertical/smrt-core 0.43.9 → 0.43.10

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.
@@ -16,6 +16,78 @@ import { createLogger } from "@happyvertical/logger";
16
16
  import { buildWhere } from "@happyvertical/sql";
17
17
  //#region src/collection.ts
18
18
  var logger = createLogger({ level: "info" });
19
+ /** SQL words outside comments and quoted literals/identifiers. */
20
+ function executableSqlWords(sql) {
21
+ const words = [];
22
+ let index = 0;
23
+ while (index < sql.length) {
24
+ const character = sql[index];
25
+ const next = sql[index + 1];
26
+ if (character === "-" && next === "-") {
27
+ index = sql.indexOf("\n", index + 2);
28
+ if (index === -1) break;
29
+ continue;
30
+ }
31
+ if (character === "/" && next === "*") {
32
+ let depth = 1;
33
+ index += 2;
34
+ while (index < sql.length && depth > 0) if (sql[index] === "/" && sql[index + 1] === "*") {
35
+ depth += 1;
36
+ index += 2;
37
+ } else if (sql[index] === "*" && sql[index + 1] === "/") {
38
+ depth -= 1;
39
+ index += 2;
40
+ } else index += 1;
41
+ if (depth > 0) break;
42
+ continue;
43
+ }
44
+ if (character === "'" || character === "\"" || character === "`") {
45
+ const quote = character;
46
+ const backslashEscapes = quote === "'" && index > 0 && /[eE]/.test(sql[index - 1]) && (index === 1 || !/[A-Za-z0-9_$]/.test(sql[index - 2]));
47
+ index += 1;
48
+ while (index < sql.length) {
49
+ if (backslashEscapes && sql[index] === "\\") {
50
+ index += 2;
51
+ continue;
52
+ }
53
+ if (sql[index] === quote) {
54
+ if (sql[index + 1] === quote) {
55
+ index += 2;
56
+ continue;
57
+ }
58
+ index += 1;
59
+ break;
60
+ }
61
+ index += 1;
62
+ }
63
+ continue;
64
+ }
65
+ if (character === "$") {
66
+ const tag = sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
67
+ if (tag) {
68
+ const end = sql.indexOf(tag, index + tag.length);
69
+ if (end === -1) break;
70
+ index = end + tag.length;
71
+ continue;
72
+ }
73
+ }
74
+ if (/[A-Za-z_]/.test(character)) {
75
+ const start = index;
76
+ index += 1;
77
+ while (index < sql.length && /[A-Za-z0-9_$]/.test(sql[index])) index += 1;
78
+ words.push(sql.slice(start, index).toLowerCase());
79
+ continue;
80
+ }
81
+ index += 1;
82
+ }
83
+ return words;
84
+ }
85
+ function isMutatingSql(words) {
86
+ return words.some((word, index) => {
87
+ const next = words[index + 1];
88
+ return word === "update" || word === "insert" && next === "into" || word === "delete" && next === "from" || word === "merge" && next === "into" || word === "truncate" && next === "table" || word === "replace" && next === "into";
89
+ });
90
+ }
19
91
  /**
20
92
  * `_smrt_contexts.owner_id` value for collection-level (as opposed to
21
93
  * per-object) memory. Tenant-scoped collections suffix the active tenant id
@@ -450,6 +522,26 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
450
522
  const isDuckDbConnection = (db.client?.constructor?.name?.toLowerCase() ?? "").includes("duckdb") || typeof db.exportTable === "function" && db.client !== void 0 && "connection" in db.client;
451
523
  return detectEngine(db.url || db.config?.url || "", this.getDatabaseEngineHint() || db.type || db.config?.type || (isDuckDbConnection ? "duckdb" : void 0));
452
524
  }
525
+ getHydrationSelectSql(schema = ObjectRegistry.getSchema(this.getResolvedItemQualifiedName()) ?? ObjectRegistry.getSchema(this.getResolvedItemClassName())) {
526
+ if (this.getDatabaseEngine() !== "duckdb") return "*";
527
+ const registeredFields = ObjectRegistry.getFields(this.getResolvedItemQualifiedName()).size > 0 ? ObjectRegistry.getFields(this.getResolvedItemQualifiedName()) : ObjectRegistry.getFields(this.getResolvedItemClassName());
528
+ const itemColumns = /* @__PURE__ */ new Set(["id", ...Array.from(registeredFields.keys(), (fieldName) => this.toDbColumnName(fieldName))]);
529
+ const uuidColumns = Object.entries(schema?.columns ?? {}).filter(([columnName, column]) => itemColumns.has(columnName) && String(column.type).toUpperCase() === "UUID").map(([columnName]) => columnName);
530
+ if (uuidColumns.length === 0) return "*";
531
+ return `* REPLACE (${uuidColumns.map((columnName) => {
532
+ const quoted = this.quoteProjectionIdentifier(columnName);
533
+ return `CAST(${quoted} AS VARCHAR) AS ${quoted}`;
534
+ }).join(", ")})`;
535
+ }
536
+ hasDuckDbUuidColumnsOutsideHydrationSelect() {
537
+ if (this.getDatabaseEngine() !== "duckdb") return false;
538
+ const qualifiedName = this.getResolvedItemQualifiedName();
539
+ const className = this.getResolvedItemClassName();
540
+ const schema = ObjectRegistry.getSchema(qualifiedName) ?? ObjectRegistry.getSchema(className);
541
+ const registeredFields = ObjectRegistry.getFields(qualifiedName).size > 0 ? ObjectRegistry.getFields(qualifiedName) : ObjectRegistry.getFields(className);
542
+ const selectedColumns = /* @__PURE__ */ new Set(["id", ...Array.from(registeredFields.keys(), (fieldName) => this.toDbColumnName(fieldName))]);
543
+ return Object.entries(schema?.columns ?? {}).some(([columnName, column]) => !selectedColumns.has(columnName) && String(column.type).toUpperCase() === "UUID");
544
+ }
453
545
  isOmittedCustomPrimaryKeySystemField(fieldName, hasCustomPrimaryKey, explicitFieldNames) {
454
546
  return hasCustomPrimaryKey && !explicitFieldNames.has(fieldName) && (fieldName === "id" || fieldName === "slug" || fieldName === "context");
455
547
  }
@@ -489,7 +581,9 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
489
581
  const isTransient = fieldDef?.transient === true || fieldDef?._meta?.transient === true;
490
582
  if (fieldType === "meta" || isTransient || fieldType === "oneToMany" || fieldType === "manyToMany") throw new Error(`Invalid select field: '${fieldName}' is not a column-backed field on ${itemClassName}.`);
491
583
  const columnName = this.toDbColumnName(fieldName);
492
- selectExpressions.push(`${this.quoteProjectionIdentifier(columnName)} AS ${this.quoteProjectionIdentifier(fieldName)}`);
584
+ const quotedColumn = this.quoteProjectionIdentifier(columnName);
585
+ const selectValue = this.getDatabaseEngine() === "duckdb" && String(schema?.columns[columnName]?.type).toUpperCase() === "UUID" ? `CAST(${quotedColumn} AS VARCHAR)` : quotedColumn;
586
+ selectExpressions.push(`${selectValue} AS ${this.quoteProjectionIdentifier(fieldName)}`);
493
587
  outputFields.push(fieldName);
494
588
  }
495
589
  return {
@@ -603,7 +697,10 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
603
697
  while (occupiedParentIdentifiers.has(alias) || [...relatedFieldAliases.values()].includes(alias));
604
698
  relatedFieldAliases.set(fieldName, alias);
605
699
  }
606
- const parentSelectSql = parentColumnNames.length > 0 ? parentColumnNames.map((columnName) => this.quoteProjectionIdentifier(columnName)).join(", ") : "*";
700
+ const parentSelectSql = parentColumnNames.length > 0 ? parentColumnNames.map((columnName) => {
701
+ const quoted = this.quoteProjectionIdentifier(columnName);
702
+ return this.getDatabaseEngine() === "duckdb" && String(parentSchema?.columns[columnName]?.type).toUpperCase() === "UUID" ? `CAST(${quoted} AS VARCHAR) AS ${quoted}` : quoted;
703
+ }).join(", ") : "*";
607
704
  const relatedAlias = (fieldName) => {
608
705
  const alias = relatedFieldAliases.get(fieldName);
609
706
  if (!alias) throw new Error(`Missing latest-related alias for '${fieldName}'.`);
@@ -612,12 +709,15 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
612
709
  const relatedSelectExpressions = Array.from(relatedOutputFields).map((fieldName) => {
613
710
  const column = relatedFieldColumns.get(fieldName) ?? relatedCollection.toDbColumnName(fieldName);
614
711
  const alias = relatedAlias(fieldName);
615
- return `${relatedCollection.quoteProjectionIdentifier(column)} AS ${relatedCollection.quoteProjectionIdentifier(alias)}`;
712
+ const quotedColumn = relatedCollection.quoteProjectionIdentifier(column);
713
+ const relatedSchema = ObjectRegistry.getSchema(relatedQualifiedName) ?? ObjectRegistry.getSchema(relatedItemClassName);
714
+ const source = `r.${quotedColumn}`;
715
+ return `${relatedCollection.getDatabaseEngine() === "duckdb" && String(relatedSchema?.columns[column]?.type).toUpperCase() === "UUID" ? `CAST(${source} AS VARCHAR)` : source} AS ${relatedCollection.quoteProjectionIdentifier(alias)}`;
616
716
  });
617
717
  const relatedRankOrder = [relatedCollection.qualifyLatestOrderTerms("r", relatedOrderTerms), `r.${relatedCollection.quoteProjectionIdentifier(relatedPrimaryKey.column)} DESC`].filter(Boolean).join(", ");
618
718
  const latestRankAlias = relatedCollection.quoteProjectionIdentifier("__smrt_latest_rank");
619
719
  const latestRelatedAlias = (fieldName) => relatedCollection.quoteProjectionIdentifier(relatedAlias(fieldName));
620
- const relatedCte = `WITH ranked_latest_related AS (SELECT ${relatedSelectExpressions.map((expression) => `r.${expression}`).join(", ")}, ROW_NUMBER() OVER (PARTITION BY r.${relatedCollection.quoteProjectionIdentifier(relatedCollection.toDbColumnName(inverseForeignKey.fieldName))} ORDER BY ${relatedRankOrder}) AS ${latestRankAlias} FROM ${relatedCollection.tableName} r${relatedWhereSql ? ` ${relatedWhereSql}` : ""}) `;
720
+ const relatedCte = `WITH ranked_latest_related AS (SELECT ${relatedSelectExpressions.join(", ")}, ROW_NUMBER() OVER (PARTITION BY r.${relatedCollection.quoteProjectionIdentifier(relatedCollection.toDbColumnName(inverseForeignKey.fieldName))} ORDER BY ${relatedRankOrder}) AS ${latestRankAlias} FROM ${relatedCollection.tableName} r${relatedWhereSql ? ` ${relatedWhereSql}` : ""}) `;
621
721
  const { sql: rawWhereSql, values: parentWhereValues } = buildWhere(this.convertWhereKeys(where || {}));
622
722
  const parentWhereSql = rawWhereSql.replace(/\$(\d+)/g, (_match, index) => `$${Number(index) + relatedWhereValues.length}`);
623
723
  const relatedJoinConditions = [`rr.${latestRankAlias} = 1`, `rr.${latestRelatedAlias(inverseForeignKey.fieldName)} = ${this.quoteProjectionIdentifier(parentPrimaryKey.column)}`];
@@ -647,16 +747,21 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
647
747
  limitOffsetValues.push(boundedOffset);
648
748
  }
649
749
  const sql = `${relatedCte}SELECT ${parentSelectSql}, ${Array.from(relatedOutputFields).map((fieldName) => `rr.${latestRelatedAlias(fieldName)} AS ${this.quoteProjectionIdentifier(relatedAlias(fieldName))}`).join(", ")} FROM ${this.quoteProjectionIdentifier(this.tableName)} LEFT JOIN ranked_latest_related rr ON ${relatedJoinConditions.join(" AND ")} ${parentWhereSql} ${orderFragments.length > 0 ? `ORDER BY ${orderFragments.join(", ")}` : ""}${limitOffsetSql}`;
650
- const rows = await this.db.query(sql, ...relatedWhereValues, ...parentWhereValues, ...limitOffsetValues);
750
+ const queryValues = [
751
+ ...relatedWhereValues,
752
+ ...parentWhereValues,
753
+ ...limitOffsetValues
754
+ ];
755
+ const rows = await this.queryDuckDbCanonicalSelectRows(sql, queryValues);
651
756
  const instances = [];
652
757
  const relatedAliasNames = new Set(relatedFieldAliases.values());
653
758
  const polymorphicFields = /* @__PURE__ */ new Map();
654
- for (const row of rows.rows) {
759
+ for (const row of rows) {
655
760
  const parentRow = Object.fromEntries(Object.entries(row).filter(([key]) => !relatedAliasNames.has(key)));
656
761
  instances.push(await this.hydrateResultRow(parentRow, parentFields, isSTI, polymorphicFields));
657
762
  }
658
763
  const relatedByParentId = /* @__PURE__ */ new Map();
659
- for (const row of rows.rows) {
764
+ for (const row of rows) {
660
765
  const parentId = row[parentPrimaryKey.column];
661
766
  if (parentId === null || parentId === void 0) continue;
662
767
  const relatedRow = Object.fromEntries(relatedProjection.outputFields.map((fieldName) => [fieldName, row[relatedAlias(fieldName)]]));
@@ -937,8 +1042,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
937
1042
  * differently-scoped queries can never share an entry. Cached values are
938
1043
  * raw rows; callers still run their normal post-query formatting path.
939
1044
  */
940
- async queryRowsWithCache(sql, params, cacheConfig) {
941
- if (!cacheConfig) return (await this.db.query(sql, ...params)).rows;
1045
+ async queryRowsWithCache(sql, params, cacheConfig, describeUuidOutputs) {
1046
+ if (!cacheConfig) return await this.queryDuckDbCanonicalSelectRows(sql, params, describeUuidOutputs);
942
1047
  const dbKey = resolveDbCacheKey(this.db);
943
1048
  const queryKey = buildQueryCacheKey(sql, params);
944
1049
  if (cacheConfig.crossProcess) {
@@ -948,9 +1053,31 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
948
1053
  const cached = getCachedRows(dbKey, this.tableName, queryKey);
949
1054
  if (cached) return cached;
950
1055
  const generation = getCacheGeneration(dbKey, this.tableName);
951
- const result = await this.db.query(sql, ...params);
952
- setCachedRows(dbKey, this.tableName, queryKey, result.rows, cacheConfig.ttl, generation);
953
- return result.rows;
1056
+ const rows = await this.queryDuckDbCanonicalSelectRows(sql, params, describeUuidOutputs);
1057
+ setCachedRows(dbKey, this.tableName, queryKey, rows, cacheConfig.ttl, generation);
1058
+ return rows;
1059
+ }
1060
+ /**
1061
+ * Describe a native DuckDB SELECT before executing it, then cast UUID output
1062
+ * columns in the one data-bearing execution. DuckDB's JavaScript binding
1063
+ * exposes UUID values as lossy HUGEINT wrappers, so they cannot be repaired
1064
+ * from the first result without replaying volatile SELECT expressions or
1065
+ * combining two different database snapshots.
1066
+ */
1067
+ async queryDuckDbCanonicalSelectRows(sql, params, describeUuidOutputs = true) {
1068
+ if (this.getDatabaseEngine() !== "duckdb" || !describeUuidOutputs) return (await this.db.query(sql, ...params)).rows;
1069
+ const readSql = sql.trim().replace(/;$/, "");
1070
+ const words = executableSqlWords(readSql);
1071
+ if (isMutatingSql(words)) return (await this.db.query(sql, ...params)).rows;
1072
+ if (words[0] !== "select" && words[0] !== "with") return (await this.db.query(sql, ...params)).rows;
1073
+ const uuidColumns = (await this.db.query(`DESCRIBE ${readSql}`, ...params)).rows.filter((row) => String(row.column_type).toUpperCase() === "UUID").map((row) => String(row.column_name));
1074
+ if (uuidColumns.length === 0) return (await this.db.query(sql, ...params)).rows;
1075
+ const alias = this.quoteProjectionIdentifier("__smrt_uuid_rows");
1076
+ const replacements = uuidColumns.map((column) => {
1077
+ const quoted = this.quoteProjectionIdentifier(column);
1078
+ return `CAST(${alias}.${quoted} AS VARCHAR) AS ${quoted}`;
1079
+ }).join(", ");
1080
+ return (await this.db.query(`SELECT ${alias}.* REPLACE (${replacements}) FROM (${readSql}) AS ${alias}`, ...params)).rows;
954
1081
  }
955
1082
  /**
956
1083
  * Retrieves a single object from the collection by ID, slug, or a custom filter.
@@ -1005,8 +1132,8 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1005
1132
  };
1006
1133
  }
1007
1134
  const { sql: whereSql, values: whereValues } = buildWhere(this.convertWhereKeys(where));
1008
- const fullSQL = `SELECT * FROM ${this.tableName} ${whereSql} LIMIT 1`;
1009
- const rows = await this.queryRowsWithCache(fullSQL, whereValues, this.resolveReadCacheConfig(options.cache));
1135
+ const fullSQL = `SELECT ${this.getHydrationSelectSql()} FROM ${this.tableName} ${whereSql} LIMIT 1`;
1136
+ const rows = await this.queryRowsWithCache(fullSQL, whereValues, this.resolveReadCacheConfig(options.cache), this.hasDuckDbUuidColumnsOutsideHydrationSelect());
1010
1137
  if (!rows?.[0]) return await GlobalInterceptors.executeAfterGet(itemClassName, null, interceptorContext);
1011
1138
  const fields = this.getFieldsSync();
1012
1139
  const instance = await this.hydrateResultRow(rows[0], fields, isSTI);
@@ -1040,9 +1167,9 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1040
1167
  limitOffsetSql += ` OFFSET $${paramIndex++}`;
1041
1168
  limitOffsetValues.push(boundedOffset);
1042
1169
  }
1043
- const sql = `SELECT ${projection ? projection.sql : "*"} FROM ${this.tableName} ${whereSql} ${orderBySql} ${limitOffsetSql}`;
1170
+ const sql = `SELECT ${projection ? projection.sql : this.getHydrationSelectSql()} FROM ${this.tableName} ${whereSql} ${orderBySql} ${limitOffsetSql}`;
1044
1171
  const params = [...whereValues, ...limitOffsetValues];
1045
- const rows = await this.queryRowsWithCache(sql, params, this.resolveReadCacheConfig(interceptedOptions.cache));
1172
+ const rows = await this.queryRowsWithCache(sql, params, this.resolveReadCacheConfig(interceptedOptions.cache), !projection && this.hasDuckDbUuidColumnsOutsideHydrationSelect());
1046
1173
  if (projection) return rows.map((item) => this.formatProjectionRow(item, fields, projection.outputFields));
1047
1174
  const instances = await this.hydrateResultRows(rows, fields, isSTI);
1048
1175
  if (interceptedOptions.include && interceptedOptions.include.length > 0) await this.eagerLoadRelationships(instances, interceptedOptions.include);
@@ -1753,11 +1880,11 @@ var SmrtCollection = class SmrtCollection extends SmrtClass {
1753
1880
  params,
1754
1881
  allowRawOnTenantScoped: options.allowRawOnTenantScoped
1755
1882
  }, interceptorContext);
1756
- const result = await this.db.query(interceptedQuery.sql, ...interceptedQuery.params);
1883
+ const rows = await this.queryDuckDbCanonicalSelectRows(interceptedQuery.sql, interceptedQuery.params);
1757
1884
  if (/\b(?:insert|update|delete|merge|truncate|replace)\b/i.test(interceptedQuery.sql)) invalidateCollectionCache(resolveDbCacheKey(this.db), this.tableName);
1758
1885
  const fields = this.getFieldsSync();
1759
1886
  const isSTI = ObjectRegistry.getTableStrategy(this.getResolvedItemQualifiedName()) === "sti";
1760
- const instances = await this.hydrateResultRows(result.rows, fields, isSTI);
1887
+ const instances = await this.hydrateResultRows(rows, fields, isSTI);
1761
1888
  return await GlobalInterceptors.executeAfterQuery(this._itemClass.name, instances, interceptorContext);
1762
1889
  }
1763
1890
  async hydrateResultRow(row, fields, isSTI, polymorphicFields = /* @__PURE__ */ new Map()) {