@easy-cms/drizzle 1.28.0 → 1.30.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.ts CHANGED
@@ -126,7 +126,13 @@ interface ColumnModel {
126
126
  readonly locale?: string;
127
127
  /** The default locale's column, used when a query names no locale. */
128
128
  readonly defaultLocale?: boolean;
129
+ /**
130
+ * A relationship to several collections (#327) has two columns, `<field>_relation_to` and
131
+ * `<field>_value`: which part of `{ relationTo, value }` this one holds.
132
+ */
133
+ readonly part?: PolymorphicPart;
129
134
  }
135
+ type PolymorphicPart = 'relationTo' | 'value';
130
136
  /** A child table holding array rows or hasMany values. */
131
137
  interface ChildModel {
132
138
  readonly kind: 'array' | 'values';
@@ -213,6 +219,13 @@ declare class Migrator {
213
219
  private statistics;
214
220
  /** Renames become drop + add unless interactive. */
215
221
  private changes;
222
+ /**
223
+ * A relationship that gets several collections (#327) keeps its values: the id moves from
224
+ * `<field>` to `<field>_value`, with the first collection of `to` as `<field>_relation_to`
225
+ * (list the collection it had first). Back to one collection, the values of that collection
226
+ * stay and the others are removed, with a warning.
227
+ */
228
+ private relationMoves;
216
229
  /**
217
230
  * A new default locale changes which locale the plain column holds (`title` is the default
218
231
  * locale's, `title__en` another's). Moves the values so every locale keeps its own.
package/dist/index.js CHANGED
@@ -24,10 +24,19 @@ function setAt(data, path, value) {
24
24
  }
25
25
  current[path[path.length - 1]] = value;
26
26
  }
27
- var columnPath = (column) => column.locale === void 0 ? column.path : [...column.path, column.locale];
27
+ var columnPath = (column) => {
28
+ const path = column.locale === void 0 ? column.path : [...column.path, column.locale];
29
+ return column.part === void 0 ? path : [...path, column.part];
30
+ };
28
31
  function rowToData(model, row) {
29
32
  const data = {};
30
33
  for (const column of model.columns) setAt(data, columnPath(column), row[column.column] ?? null);
34
+ for (const column of model.columns) {
35
+ if (column.part !== "value") continue;
36
+ const path = columnPath(column).slice(0, -1);
37
+ const pair = getAt(data, path);
38
+ if (pair && pair.value === null && pair.relationTo === null) setAt(data, path, null);
39
+ }
31
40
  for (const child of model.children)
32
41
  setAt(
33
42
  data,
@@ -67,7 +76,9 @@ async function attachChildren(db, model, rows, docs) {
67
76
  const list = getAt(docs[index], path);
68
77
  if (!Array.isArray(list)) return;
69
78
  const id = locale === void 0 ? childRow.id : String(childRow.id).replace(new RegExp(`:${locale}$`), "");
70
- list.push(child.kind === "array" ? { id, ...childDocs?.[i] } : childRow.value);
79
+ list.push(
80
+ child.kind === "array" ? { id, ...childDocs?.[i] } : "relation_to" in childRow ? { relationTo: childRow.relation_to, value: childRow.value } : childRow.value
81
+ );
71
82
  });
72
83
  }
73
84
  }
@@ -104,10 +115,12 @@ async function insertChildren(db, model, parentId, data) {
104
115
  async function insertChild(db, child, parentId, items, locale) {
105
116
  const localeColumn = locale === void 0 ? {} : { _locale: locale };
106
117
  if (child.kind === "values") {
118
+ const polymorphic = "relation_to" in child.table.table;
107
119
  const rows = items.map((value, i) => ({
108
120
  _parent_id: parentId,
109
121
  _order: i,
110
- value,
122
+ // Several collections (#327): `{ relationTo, value }`.
123
+ ...polymorphic ? { relation_to: value.relationTo, value: value.value } : { value },
111
124
  ...localeColumn
112
125
  }));
113
126
  for (const part of chunks(rows)) await db.insert(child.table.table).values(part);
@@ -176,7 +189,7 @@ import { SchemaError } from "@easy-cms/core";
176
189
  // src/schema.ts
177
190
  import { createHash } from "crypto";
178
191
  import { ConfigError } from "@easy-cms/core";
179
- import { isHasMany } from "@easy-cms/core/internal";
192
+ import { isHasMany, isPolymorphic } from "@easy-cms/core/internal";
180
193
  function tableModels(schema) {
181
194
  const all = [];
182
195
  const walk = (model) => {
@@ -345,6 +358,7 @@ function buildTable(builder, name, kind, fields, options) {
345
358
  }
346
359
  if (kind === "values" && options.valueField) {
347
360
  const valueKind = columnKind(options.valueField);
361
+ if (isPolymorphic(options.valueField)) add("relation_to", dialect.text("relation_to"), "text");
348
362
  add("value", makeColumn(dialect, "value", valueKind), valueKind);
349
363
  indexes.push({ column: "value", unique: false });
350
364
  }
@@ -387,21 +401,34 @@ function buildTable(builder, name, kind, fields, options) {
387
401
  } else {
388
402
  const kindOfColumn = columnKind(field);
389
403
  const localization = field.localized ? builder.localization : null;
390
- const targets = localization ? localization.locales.map((locale) => ({
404
+ const parts = isPolymorphic(
405
+ field
406
+ ) ? [
407
+ { suffix: "_relation_to", part: "relationTo", kind: "text" },
408
+ { suffix: "_value", part: "value", kind: kindOfColumn }
409
+ ] : [{ suffix: "", kind: kindOfColumn }];
410
+ const targets = (localization ? localization.locales.map((locale) => ({
391
411
  column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
392
412
  locale
393
- })) : [{ column: base, locale: void 0 }];
394
- for (const { column, locale } of targets) {
395
- add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
396
- columnModels.push(
397
- locale === void 0 ? { path: fieldPath, field, column } : {
398
- path: fieldPath,
399
- field,
400
- column,
401
- locale,
402
- defaultLocale: locale === localization?.defaultLocale
403
- }
404
- );
413
+ })) : [{ column: base, locale: void 0 }]).flatMap(
414
+ ({ column, locale }) => parts.map(({ suffix, part, kind: kind2 }) => ({
415
+ column: locale === void 0 ? `${base}${suffix}` : column.replace(base, `${base}${suffix}`),
416
+ locale,
417
+ part,
418
+ kind: kind2
419
+ }))
420
+ );
421
+ for (const { column, locale, part, kind: partKind } of targets) {
422
+ add(column, makeColumn(dialect, column, partKind), partKind);
423
+ const model2 = locale === void 0 ? { path: fieldPath, field, column } : {
424
+ path: fieldPath,
425
+ field,
426
+ column,
427
+ locale,
428
+ defaultLocale: locale === localization?.defaultLocale
429
+ };
430
+ columnModels.push(part ? { ...model2, part } : model2);
431
+ if (part === "relationTo") continue;
405
432
  if (topLevel && kind === "root" && (field.unique || field.type === "slug") && field.uniqueWithin !== void 0) {
406
433
  const within = [field.uniqueWithin].flat().map(snake);
407
434
  indexes.push({
@@ -547,7 +574,10 @@ var Migrator = class {
547
574
  /** Renames become drop + add unless interactive. */
548
575
  async changes(prev, cur, interactive) {
549
576
  const { dialect } = this.options;
550
- const moves = this.localeMoves(prev, metaOf(prev), metaOf(cur));
577
+ const moves = [
578
+ ...this.localeMoves(prev, metaOf(prev), metaOf(cur)),
579
+ ...this.relationMoves(prev)
580
+ ];
551
581
  const [from, to] = [withoutMeta(prev), withoutMeta(cur)];
552
582
  if (interactive && moves.length === 0) return dialect.migration(from, to);
553
583
  const union = unionSnapshot(from, to);
@@ -557,6 +587,62 @@ var Migrator = class {
557
587
  ...await dialect.migration(union, to)
558
588
  ];
559
589
  }
590
+ /**
591
+ * A relationship that gets several collections (#327) keeps its values: the id moves from
592
+ * `<field>` to `<field>_value`, with the first collection of `to` as `<field>_relation_to`
593
+ * (list the collection it had first). Back to one collection, the values of that collection
594
+ * stay and the others are removed, with a warning.
595
+ */
596
+ relationMoves(prev) {
597
+ const q = (name) => this.q(name);
598
+ const text = (value) => `'${value.replace(/'/g, "''")}'`;
599
+ const statements = [];
600
+ const lost = /* @__PURE__ */ new Set();
601
+ for (const model of tableModels(this.options.schema)) {
602
+ const old = snapshotTable(prev, model.name);
603
+ if (!old) continue;
604
+ const has = (column) => Object.hasOwn(old.columns ?? {}, column);
605
+ if (model.kind === "values" && model.valueField?.type === "relationship") {
606
+ const to = model.valueField.to;
607
+ if (typeof to !== "string" && !has("relation_to"))
608
+ statements.push(
609
+ `UPDATE ${q(model.name)} SET ${q("relation_to")} = ${text(to[0])} WHERE ${q("relation_to")} IS NULL`
610
+ );
611
+ if (typeof to === "string" && has("relation_to")) {
612
+ statements.push(
613
+ `DELETE FROM ${q(model.name)} WHERE ${q("relation_to")} IS NULL OR ${q("relation_to")} <> ${text(to)}`
614
+ );
615
+ lost.add(model.name);
616
+ }
617
+ continue;
618
+ }
619
+ for (const column of model.columns) {
620
+ const { field } = column;
621
+ if (field.type !== "relationship") continue;
622
+ if (column.part === "value" && typeof field.to !== "string") {
623
+ const before = column.column.replace(/_value(?=__|$)/, "");
624
+ const relationTo = column.column.replace(/_value(?=__|$)/, "_relation_to");
625
+ if (has(column.column) || !has(before)) continue;
626
+ statements.push(
627
+ `UPDATE ${q(model.name)} SET ${q(column.column)} = ${q(before)}, ${q(relationTo)} = ${text(field.to[0])} WHERE ${q(before)} IS NOT NULL`
628
+ );
629
+ } else if (column.part === void 0 && typeof field.to === "string") {
630
+ const value = column.column.replace(/(?=__|$)/, "_value");
631
+ const relationTo = column.column.replace(/(?=__|$)/, "_relation_to");
632
+ if (has(column.column) || !has(value) || !has(relationTo)) continue;
633
+ statements.push(
634
+ `UPDATE ${q(model.name)} SET ${q(column.column)} = ${q(value)} WHERE ${q(relationTo)} = ${text(field.to)}`
635
+ );
636
+ lost.add(`${model.name}.${column.column}`);
637
+ }
638
+ }
639
+ }
640
+ if (lost.size > 0)
641
+ this.options.logger.warn(
642
+ `Links to other collections are removed from ${[...lost].join(", ")}: those relationships now have one collection.`
643
+ );
644
+ return statements;
645
+ }
560
646
  /**
561
647
  * A new default locale changes which locale the plain column holds (`title` is the default
562
648
  * locale's, `title__en` another's). Moves the values so every locale keeps its own.
@@ -579,7 +665,7 @@ var Migrator = class {
579
665
  if (!column.defaultLocale || !has(column.column)) continue;
580
666
  const base = column.column;
581
667
  const moved = model.columns.find(
582
- (c) => c.path.join(".") === column.path.join(".") && c.locale === was.defaultLocale
668
+ (c) => c.path.join(".") === column.path.join(".") && c.part === column.part && c.locale === was.defaultLocale
583
669
  );
584
670
  if (moved && !has(moved.column)) keep.push(`${q(moved.column)} = ${q(base)}`);
585
671
  clear.push(`${q(base)} = NULL`);
@@ -764,7 +850,7 @@ function extensionHint(error) {
764
850
 
765
851
  // src/query.ts
766
852
  import { QueryError } from "@easy-cms/core";
767
- import { parseId, TRIGRAM_LIKE_MIN } from "@easy-cms/core/internal";
853
+ import { isPolymorphic as isPolymorphic2, parseId, TRIGRAM_LIKE_MIN } from "@easy-cms/core/internal";
768
854
  import {
769
855
  and as and2,
770
856
  asc as asc2,
@@ -802,6 +888,14 @@ var SYSTEM_COLUMNS = {
802
888
  updatedAt: "updated_at"
803
889
  };
804
890
  var col2 = (model, name) => model.table[name];
891
+ var PARTS = /* @__PURE__ */ new Set(["relationTo", "value"]);
892
+ function polymorphicPart(segment, shown, to) {
893
+ if (segment !== void 0 && PARTS.has(segment)) return segment;
894
+ if (segment !== void 0 && to.includes(segment)) return { collection: segment };
895
+ throw new QueryError(
896
+ `"${shown}" links to several collections: query "${shown}.relationTo" and "${shown}.value", or "${shown}.<collection>" (${to.map((t) => `"${shown}.${t}"`).join(", ")})`
897
+ );
898
+ }
805
899
  var ID_FIELD = { name: "id", type: "relationship", to: "" };
806
900
  var LIKE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email", "slug", "select"]);
807
901
  var JSON_TYPES = {
@@ -836,6 +930,16 @@ function resolveJsonValue(fields, rest, shown, localization, blocks) {
836
930
  }
837
931
  const inner = rest.slice(next);
838
932
  const shownHere = [shown, ...rest.slice(0, next)].join(".");
933
+ if (isPolymorphic2(field)) {
934
+ if (inner.length > 1) throw new QueryError(`Cannot query inside "${shownHere}"`);
935
+ const part = polymorphicPart(inner[0], shownHere, field.to);
936
+ if (typeof part === "object")
937
+ throw new QueryError(
938
+ `Inside blocks, query "${shownHere}.relationTo" and "${shownHere}.value"`
939
+ );
940
+ const value = part === "value" ? { type: "integer", field } : { type: "text", field: void 0 };
941
+ return field.hasMany ? { lists: [path], path: [part], ...value } : { lists: [], path: [...path, part], ...value };
942
+ }
839
943
  if ("hasMany" in field && field.hasMany === true) {
840
944
  if (inner.length > 0) throw new QueryError(`Cannot query inside "${shownHere}"`);
841
945
  return { lists: [path], path: [], type: JSON_TYPES[field.type] ?? "text", field };
@@ -903,6 +1007,33 @@ function resolvePath(model, segments, drafts, localization = null) {
903
1007
  const value = resolveJsonValue(rows, rest, shown, localization, true);
904
1008
  return { kind: "blocks", column: col2(model, column2.column), value };
905
1009
  }
1010
+ if (isPolymorphic2(field)) {
1011
+ if (last + 1 < segments.length - 1)
1012
+ throw new QueryError(`Cannot query inside "${segments.slice(0, last + 2).join(".")}"`);
1013
+ const shownField = segments.slice(0, last + 1).join(".");
1014
+ const named = segments[last + 1];
1015
+ const part = named === void 0 ? "value" : polymorphicPart(named, shownField, field.to);
1016
+ const columnOfPart = (wanted) => {
1017
+ const found = model.columns.find(
1018
+ (c) => same(c) && c.part === wanted && (!field.localized || (locale === void 0 ? c.defaultLocale : c.locale === locale))
1019
+ );
1020
+ if (!found) throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
1021
+ return col2(model, found.column);
1022
+ };
1023
+ if (typeof part === "object")
1024
+ return {
1025
+ kind: "column",
1026
+ column: columnOfPart("value"),
1027
+ field,
1028
+ collection: { slug: part.collection, column: columnOfPart("relationTo") }
1029
+ };
1030
+ return {
1031
+ kind: "column",
1032
+ column: columnOfPart(part),
1033
+ field: part === "value" ? field : void 0,
1034
+ ...named === void 0 ? { bare: field.to } : {}
1035
+ };
1036
+ }
906
1037
  if (last < segments.length - 1) {
907
1038
  throw new QueryError(`Cannot query inside "${shown}" (${field.type} field)`);
908
1039
  }
@@ -1011,8 +1142,32 @@ var WhereBuilder = class {
1011
1142
  );
1012
1143
  const parts = entries.map(([op, value]) => {
1013
1144
  if (!OPERATORS.has(op)) throw new QueryError(`Unknown operator "${op}" on "${path}"`);
1014
- if (target.kind === "column")
1145
+ if (target.kind === "column") {
1146
+ if (target.bare && op !== "exists") polymorphicPart(void 0, path, target.bare);
1147
+ if (target.collection) {
1148
+ const { slug, column } = target.collection;
1149
+ const is = eq2(column, slug);
1150
+ if (op === "exists")
1151
+ return value === false ? or(isNull(column), ne(column, slug)) : is;
1152
+ if (op === "not_equals" || op === "not_in") {
1153
+ const positive = op === "not_equals" ? "equals" : "in";
1154
+ const match = operatorSQL(
1155
+ this.dialect,
1156
+ target.column,
1157
+ target.field,
1158
+ positive,
1159
+ value,
1160
+ path
1161
+ );
1162
+ return or(isNull(column), not(and2(is, match)));
1163
+ }
1164
+ return and2(
1165
+ is,
1166
+ operatorSQL(this.dialect, target.column, target.field, op, value, path)
1167
+ );
1168
+ }
1015
1169
  return operatorSQL(this.dialect, target.column, target.field, op, value, path);
1170
+ }
1016
1171
  if (target.kind === "blocks") return this.blocks(target, op, value, path);
1017
1172
  return this.child(model, target.child, target.rest, op, value, path, target.locale);
1018
1173
  });
@@ -1068,18 +1223,25 @@ var WhereBuilder = class {
1068
1223
  this.db.select({ one: sql`1` }).from(table.table).where(condition ? and2(link, condition) : link)
1069
1224
  );
1070
1225
  if (child.kind === "values") {
1071
- if (rest.length > 0) throw new QueryError(`Cannot query inside "${path}"`);
1072
- const valueColumn = col2(table, "value");
1073
- if (op === "exists") return value === false ? not(subquery(void 0)) : subquery(void 0);
1074
- if (op === "not_equals")
1075
- return not(
1076
- subquery(operatorSQL(this.dialect, valueColumn, table.valueField, "equals", value, path))
1077
- );
1078
- if (op === "not_in")
1079
- return not(
1080
- subquery(operatorSQL(this.dialect, valueColumn, table.valueField, "in", value, path))
1081
- );
1082
- return subquery(operatorSQL(this.dialect, valueColumn, table.valueField, op, value, path));
1226
+ let valueColumn = col2(table, "value");
1227
+ let valueField = table.valueField;
1228
+ let only;
1229
+ if (valueField && isPolymorphic2(valueField)) {
1230
+ if (rest.length > 1) throw new QueryError(`Cannot query inside "${path}"`);
1231
+ const shown = rest.length === 1 ? path.split(".").slice(0, -1).join(".") : path;
1232
+ const part = rest.length === 0 && op === "exists" ? "value" : polymorphicPart(rest[0], shown, valueField.to);
1233
+ if (part === "relationTo") {
1234
+ valueColumn = col2(table, "relation_to");
1235
+ valueField = void 0;
1236
+ } else if (typeof part === "object") only = eq2(col2(table, "relation_to"), part.collection);
1237
+ } else if (rest.length > 0) throw new QueryError(`Cannot query inside "${path}"`);
1238
+ if (op === "exists") return value === false ? not(subquery(only)) : subquery(only);
1239
+ const matching = (operator) => subquery(
1240
+ and2(only, operatorSQL(this.dialect, valueColumn, valueField, operator, value, path))
1241
+ );
1242
+ if (op === "not_equals") return not(matching("equals"));
1243
+ if (op === "not_in") return not(matching("in"));
1244
+ return matching(op);
1083
1245
  }
1084
1246
  if (rest.length === 0) {
1085
1247
  if (op !== "exists") throw new QueryError(`"${path}" is an array; query one of its fields`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "description": "Shared Drizzle ORM layer for Easy CMS database adapters",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,10 +23,10 @@
23
23
  "drizzle-orm": "0.45.4"
24
24
  },
25
25
  "peerDependencies": {
26
- "@easy-cms/core": "^1.28.0"
26
+ "@easy-cms/core": "^1.30.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^1.28.0"
29
+ "@easy-cms/core": "^1.30.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"