tina4-nodejs 3.13.133 → 3.13.134
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/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- package/types/orm/src/seederTypes.d.ts +16 -0
|
@@ -783,204 +783,26 @@ export class BaseModel {
|
|
|
783
783
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
784
784
|
|
|
785
785
|
const db = ModelClass.getDb();
|
|
786
|
-
const pk = ModelClass
|
|
787
|
-
const pkCol = ModelClass.
|
|
786
|
+
const pk = primaryKeyFields(ModelClass)[0];
|
|
787
|
+
const pkCol = ModelClass.getDbColumn(pk);
|
|
788
788
|
const pkValue = this[pk];
|
|
789
789
|
const pkField = (ModelClass.fields as Record<string, FieldDefinition>)[pk];
|
|
790
790
|
this._relCache = {}; // Clear relationship cache on save
|
|
791
791
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
// existence, not on whether the PK is set. Pre-v3.13.11 a
|
|
795
|
-
// natural-key save() always chose UPDATE → matched zero rows →
|
|
796
|
-
// silently returned success without inserting anything.
|
|
797
|
-
//
|
|
798
|
-
// Auto-increment behaviour is unchanged: pkValue is null/undefined
|
|
799
|
-
// → INSERT, pkValue is set → UPDATE.
|
|
800
|
-
let isUpdate = false;
|
|
801
|
-
if (pkValue !== undefined && pkValue !== null) {
|
|
802
|
-
if (pkField?.autoIncrement) {
|
|
803
|
-
isUpdate = true;
|
|
804
|
-
} else {
|
|
805
|
-
try {
|
|
806
|
-
// This asked exists(pkValue), which tests only the FIRST key column.
|
|
807
|
-
// On a composite key that is true for any row sharing that column, so
|
|
808
|
-
// inserting a genuinely NEW row was decided to be an UPDATE and
|
|
809
|
-
// silently OVERWROTE a different row: saving (acme, a2) rewrote
|
|
810
|
-
// (acme, a1). The check has to name the whole key, like the write
|
|
811
|
-
// that follows it.
|
|
812
|
-
const probe = this.pkWhere();
|
|
813
|
-
if (ModelClass.getPkFields().length > 1 && probe.sql) {
|
|
814
|
-
const found = await db.fetch(
|
|
815
|
-
`SELECT 1 AS present FROM ${ModelClass.tableName} WHERE ${probe.sql}`,
|
|
816
|
-
probe.params,
|
|
817
|
-
1,
|
|
818
|
-
);
|
|
819
|
-
isUpdate = (found as unknown as { length: number }).length > 0;
|
|
820
|
-
} else {
|
|
821
|
-
isUpdate = await ModelClass.exists(pkValue);
|
|
822
|
-
}
|
|
823
|
-
} catch {
|
|
824
|
-
// If we can't tell (e.g. table doesn't exist yet), fall back
|
|
825
|
-
// to INSERT so the user sees the real driver error rather
|
|
826
|
-
// than a silent no-op.
|
|
827
|
-
isUpdate = false;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// ── Canonical #2: validate() is enforced. An invalid model never reaches
|
|
833
|
-
// the driver — fail loud (log + lastError), return false. Feature 19: on an
|
|
834
|
-
// UPDATE the partial-update mode (isUpdate) is passed so an unset field is
|
|
835
|
-
// not spuriously "required" (the persisted row already carries it), while a
|
|
836
|
-
// field that IS present stays held to its type/length/pattern/range rules.
|
|
837
|
-
const errors = this.validate(isUpdate);
|
|
838
|
-
if (errors.length > 0) {
|
|
839
|
-
this.lastError = errors.join("; ");
|
|
840
|
-
Log.error(
|
|
841
|
-
`${ModelClass.name}.save() refused: validation failed for table ` +
|
|
842
|
-
`'${ModelClass.tableName}' — ${this.lastError}`,
|
|
843
|
-
);
|
|
844
|
-
return false;
|
|
845
|
-
}
|
|
792
|
+
const isUpdate = await resolveSaveMode(this, ModelClass, db, pk, pkValue, pkField);
|
|
793
|
+
if (!validateBeforeSave(this, ModelClass, isUpdate)) return false;
|
|
846
794
|
|
|
847
795
|
await adapterStartTransaction(db);
|
|
848
796
|
try {
|
|
849
797
|
if (isUpdate) {
|
|
850
|
-
|
|
851
|
-
const updateFields = Object.entries(ModelClass.fields).filter(
|
|
852
|
-
([name, def]) => !def.primaryKey && this[name] !== undefined,
|
|
853
|
-
);
|
|
854
|
-
if (updateFields.length === 0) { await adapterCommit(db); return this; }
|
|
855
|
-
|
|
856
|
-
const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
|
|
857
|
-
// The key params come from pkWhere() below; appending pkValue here too
|
|
858
|
-
// would bind the first key column twice and shift every placeholder.
|
|
859
|
-
const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k]))];
|
|
860
|
-
|
|
861
|
-
// Keyed on the WHOLE primary key: one column of a composite key matches
|
|
862
|
-
// every row sharing that value.
|
|
863
|
-
const uw = this.pkWhere();
|
|
864
|
-
values.push(...uw.params);
|
|
865
|
-
await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE ${uw.sql}`, values);
|
|
798
|
+
await executeModelUpdate(this, ModelClass, db);
|
|
866
799
|
} else {
|
|
867
|
-
|
|
868
|
-
//
|
|
869
|
-
// #165: an INSERT must OMIT a column the caller never assigned so a
|
|
870
|
-
// `NOT NULL DEFAULT <x>` column gets its DB default instead of an
|
|
871
|
-
// explicit NULL that violates the constraint. In TS an unset column is
|
|
872
|
-
// `undefined` (the constructor only seeds a field that declares a
|
|
873
|
-
// non-null ORM default — everything else stays `undefined`), so the
|
|
874
|
-
// `this[name] !== undefined` filter already drops unset columns; a
|
|
875
|
-
// value the caller set to `null` IS included and written as an explicit
|
|
876
|
-
// NULL (parity with Python's _assigned_fields split — JS distinguishes
|
|
877
|
-
// undefined-unset from null-explicit natively). A resolved non-null ORM
|
|
878
|
-
// default is still written (no regression).
|
|
879
|
-
const insertFields = Object.entries(ModelClass.fields).filter(
|
|
880
|
-
([name, def]) => !(def.primaryKey && def.autoIncrement) && this[name] !== undefined,
|
|
881
|
-
);
|
|
882
|
-
|
|
883
|
-
// For auto-increment PKs on engines that need it (PostgreSQL),
|
|
884
|
-
// RETURNING the PK column lets us read the engine-assigned id back.
|
|
885
|
-
// SQLite ignores the RETURNING clause harmlessly (it supports it
|
|
886
|
-
// since 3.35) and we still prefer its lastInsertRowid; for other
|
|
887
|
-
// engines extractLastInsertId() reads rows[0].id.
|
|
888
|
-
const wantReturning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter";
|
|
889
|
-
const returningClause = wantReturning ? ` RETURNING "${pkCol}"` : "";
|
|
890
|
-
|
|
891
|
-
let insertSql: string;
|
|
892
|
-
let values: unknown[];
|
|
893
|
-
if (insertFields.length === 0) {
|
|
894
|
-
// #165: every insertable column was left unset — let the DB apply ALL
|
|
895
|
-
// its column defaults rather than emitting an empty column list
|
|
896
|
-
// (`() VALUES ()` is invalid on SQLite/PostgreSQL/Firebird/MSSQL, which
|
|
897
|
-
// spell the all-defaults insert `DEFAULT VALUES`; only MySQL uses the
|
|
898
|
-
// empty-parens form). Mirrors the Python master's engine split.
|
|
899
|
-
insertSql =
|
|
900
|
-
(db.constructor.name === "MysqlAdapter"
|
|
901
|
-
? `INSERT INTO "${ModelClass.tableName}" () VALUES ()`
|
|
902
|
-
: `INSERT INTO "${ModelClass.tableName}" DEFAULT VALUES`) + returningClause;
|
|
903
|
-
values = [];
|
|
904
|
-
} else {
|
|
905
|
-
const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
|
|
906
|
-
const placeholders = insertFields.map(() => "?").join(", ");
|
|
907
|
-
values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
|
|
908
|
-
insertSql =
|
|
909
|
-
`INSERT INTO "${ModelClass.tableName}" (${columns}) VALUES (${placeholders})` +
|
|
910
|
-
returningClause;
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
const result = await adapterExecute(db, insertSql, values);
|
|
914
|
-
|
|
915
|
-
// v3.13.11 (issue #50.2): only adopt the engine-assigned ID
|
|
916
|
-
// for auto-increment PKs. A natural-key PK was already set by
|
|
917
|
-
// the caller; don't overwrite it with the driver's last_id.
|
|
918
|
-
if (pkField?.autoIncrement) {
|
|
919
|
-
// RETURNING result: pg puts it in rows[0][pkCol]; normalise here.
|
|
920
|
-
// string is allowed for a non-integer PK surfaced by lastInsertId()
|
|
921
|
-
// (e.g. a PostgreSQL UUID PK) — #256.
|
|
922
|
-
let newId: number | bigint | string | null = extractLastInsertId(result);
|
|
923
|
-
if (newId === null && result && typeof result === "object") {
|
|
924
|
-
const rows = (result as any).rows;
|
|
925
|
-
if (Array.isArray(rows) && rows[0]) {
|
|
926
|
-
newId = rows[0][pkCol] ?? rows[0].id ?? null;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
if (newId === null) {
|
|
930
|
-
// Fall back to the adapter's tracked last id (MySQL/MSSQL).
|
|
931
|
-
newId = db.lastInsertId();
|
|
932
|
-
}
|
|
933
|
-
if (newId !== null && newId !== undefined) {
|
|
934
|
-
this[pk] = newId;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
800
|
+
await executeModelInsert(this, ModelClass, db, pk, pkCol, pkField);
|
|
937
801
|
}
|
|
938
802
|
await adapterCommit(db);
|
|
939
|
-
} catch (e:
|
|
803
|
+
} catch (e: unknown) {
|
|
940
804
|
await adapterRollback(db);
|
|
941
|
-
|
|
942
|
-
// contract, but capture the REAL cause (prefer the adapter's
|
|
943
|
-
// getError()/getLastError() when present, falling back to the exception
|
|
944
|
-
// text) on this.lastError so it survives, and log it with model/table
|
|
945
|
-
// context. ──
|
|
946
|
-
const adapterErr =
|
|
947
|
-
typeof (db as any).getError === "function" ? (db as any).getError() :
|
|
948
|
-
typeof (db as any).getLastError === "function" ? (db as any).getLastError() :
|
|
949
|
-
null;
|
|
950
|
-
let cause = adapterErr || e?.message || String(e);
|
|
951
|
-
// ── DX hint (v3.13.60 parity with the Python master): turn a bare driver
|
|
952
|
-
// error into an actionable fix for the two commonest ORM write footguns.
|
|
953
|
-
// Matched case-insensitively (SQLite via node:sqlite: "no such table" /
|
|
954
|
-
// "no such column: is_deleted" / "has no column named is_deleted";
|
|
955
|
-
// Postgres/MySQL: "does not exist" / "doesn't exist" / "unknown column").
|
|
956
|
-
// Any OTHER error keeps its raw cause untouched so an unrelated failure
|
|
957
|
-
// (NOT NULL, duplicate PK) is never masked. ──
|
|
958
|
-
const low = cause.toLowerCase();
|
|
959
|
-
if (
|
|
960
|
-
ModelClass.softDelete && low.includes("is_deleted") &&
|
|
961
|
-
(low.includes("no such column") || low.includes("has no column") ||
|
|
962
|
-
low.includes("does not exist") || low.includes("doesn't exist") ||
|
|
963
|
-
low.includes("unknown column"))
|
|
964
|
-
) {
|
|
965
|
-
cause +=
|
|
966
|
-
" — softDelete=true needs an is_deleted column; declare it " +
|
|
967
|
-
"(is_deleted: { type: 'integer', default: 0 }), boot the server so " +
|
|
968
|
-
"syncModels() adds it, or run a migration";
|
|
969
|
-
} else if (
|
|
970
|
-
low.includes("no such table") ||
|
|
971
|
-
((low.includes("does not exist") || low.includes("doesn't exist")) &&
|
|
972
|
-
!low.includes("column"))
|
|
973
|
-
) {
|
|
974
|
-
cause +=
|
|
975
|
-
` — table '${ModelClass.tableName}' does not exist; call ` +
|
|
976
|
-
`${ModelClass.name}.createTable() or run a migration`;
|
|
977
|
-
}
|
|
978
|
-
this.lastError = cause;
|
|
979
|
-
Log.error(
|
|
980
|
-
`${ModelClass.name}.save() failed for table ` +
|
|
981
|
-
`'${ModelClass.tableName}': ${this.lastError}`,
|
|
982
|
-
);
|
|
983
|
-
return false;
|
|
805
|
+
return saveDatabaseFailure(this, ModelClass, db, e);
|
|
984
806
|
}
|
|
985
807
|
// Success — clear any previously-recorded error.
|
|
986
808
|
this.lastError = null;
|
|
@@ -1045,88 +867,9 @@ export class BaseModel {
|
|
|
1045
867
|
*/
|
|
1046
868
|
toDict(include?: string[], case_: "camel" | "snake" = "camel"): Record<string, unknown> {
|
|
1047
869
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
1048
|
-
const result
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
const outKey = case_ === "snake" ? (ModelClass.fieldMapping[key] ?? key) : key;
|
|
1052
|
-
result[outKey] = this[key] instanceof Point ? (this[key] as Point).geojson : this[key];
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
// Include soft delete field
|
|
1056
|
-
if (ModelClass.softDelete && this.is_deleted !== undefined) {
|
|
1057
|
-
result.is_deleted = this.is_deleted;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
if (include) {
|
|
1061
|
-
// Group includes: top-level and nested
|
|
1062
|
-
const topLevel: Record<string, string[]> = {};
|
|
1063
|
-
for (const inc of include) {
|
|
1064
|
-
const parts = inc.split(".", 2);
|
|
1065
|
-
const relName = parts[0];
|
|
1066
|
-
if (!topLevel[relName]) {
|
|
1067
|
-
topLevel[relName] = [];
|
|
1068
|
-
}
|
|
1069
|
-
if (parts.length > 1) {
|
|
1070
|
-
topLevel[relName].push(parts[1]);
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
1075
|
-
// toDict stays synchronous (used in routes, templates, serialization).
|
|
1076
|
-
// Relationships must be eager-loaded first (await Model._eagerLoad / the
|
|
1077
|
-
// include arg on find/all/where) which fills _relCache. A relation that
|
|
1078
|
-
// isn't cached is simply skipped here — async lazy-load on a sync
|
|
1079
|
-
// serializer is not possible after the Option A async refactor.
|
|
1080
|
-
const data = this._relCache[relName];
|
|
1081
|
-
if (data === undefined) {
|
|
1082
|
-
// LOAD-NODE-SERIALIZE-OMIT (feature 26, 3.13.99): the omission used
|
|
1083
|
-
// to be completely silent — a developer who forgot `include` on the
|
|
1084
|
-
// finder that produced this instance got a serialized object
|
|
1085
|
-
// missing the relation with no signal at all. Warn (never throw —
|
|
1086
|
-
// serialization must keep working) naming the model, the relation,
|
|
1087
|
-
// and the fix, so the gap is visible instead of a quiet data loss.
|
|
1088
|
-
Log.warning(
|
|
1089
|
-
`${ModelClass.name}.toDict(): relation "${relName}" was requested via ` +
|
|
1090
|
-
`include but was never eager-loaded (a synchronous serializer cannot ` +
|
|
1091
|
-
`lazy-load it), so it is OMITTED from the result. Pass ` +
|
|
1092
|
-
`include: ["${relName}"] to the finder (find/all/where/select/load) ` +
|
|
1093
|
-
`that produced this instance.`,
|
|
1094
|
-
);
|
|
1095
|
-
continue;
|
|
1096
|
-
}
|
|
1097
|
-
if (data === null || data === undefined) {
|
|
1098
|
-
result[relName] = null;
|
|
1099
|
-
} else if (Array.isArray(data)) {
|
|
1100
|
-
result[relName] = (data as BaseModel[]).map((r) =>
|
|
1101
|
-
r.toDict(nested.length > 0 ? nested : undefined, case_),
|
|
1102
|
-
);
|
|
1103
|
-
} else if (typeof (data as BaseModel).toDict === "function") {
|
|
1104
|
-
result[relName] = (data as BaseModel).toDict(
|
|
1105
|
-
nested.length > 0 ? nested : undefined, case_,
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
} else {
|
|
1110
|
-
// Legacy: include any relationship data already loaded on instance
|
|
1111
|
-
if (ModelClass.hasOne) {
|
|
1112
|
-
for (const rel of ModelClass.hasOne) {
|
|
1113
|
-
const relKey = rel.model.toLowerCase();
|
|
1114
|
-
if (this[relKey] !== undefined) {
|
|
1115
|
-
result[relKey] = this[relKey];
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
if (ModelClass.hasMany) {
|
|
1120
|
-
for (const rel of ModelClass.hasMany) {
|
|
1121
|
-
const base = rel.model.toLowerCase();
|
|
1122
|
-
const relKey = _pluralRelKeys() ? base + "s" : base;
|
|
1123
|
-
if (this[relKey] !== undefined) {
|
|
1124
|
-
result[relKey] = this[relKey];
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
|
|
870
|
+
const result = serializeModelFields(this, ModelClass, case_);
|
|
871
|
+
if (include) serializeIncludedRelations(this, ModelClass, result, include, case_);
|
|
872
|
+
else serializeLoadedRelations(this, ModelClass, result);
|
|
1130
873
|
return result;
|
|
1131
874
|
}
|
|
1132
875
|
|
|
@@ -1209,99 +952,16 @@ export class BaseModel {
|
|
|
1209
952
|
if (pointFields.length > 0) SQLTranslator.requireSpatial(engine, "PointField");
|
|
1210
953
|
if (await adapterTableExists(db, this.tableName)) return this.createSpatialIndexes(db, pointFields);
|
|
1211
954
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
// etc. on PG). Remap field keys to DB column names if fieldMapping exists.
|
|
1216
|
-
if (typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function") {
|
|
1217
|
-
const mappedFields: Record<string, FieldDefinition> = {};
|
|
1218
|
-
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
1219
|
-
const dbCol = this.getDbColumn(fieldName);
|
|
1220
|
-
// A callable default (e.g. `default: () => new Date()`) is resolved per-row
|
|
1221
|
-
// in the constructor; it must NOT reach the adapter DDL builder, where it
|
|
1222
|
-
// stringifies to an invalid `DEFAULT () => ...` and silently fails table
|
|
1223
|
-
// creation. Drop the default key so no adapter emits it (parity with Python #61).
|
|
1224
|
-
if (typeof def.default === "function") {
|
|
1225
|
-
const { default: _callableDefault, ...rest } = def;
|
|
1226
|
-
mappedFields[dbCol] = rest;
|
|
1227
|
-
} else {
|
|
1228
|
-
mappedFields[dbCol] = def;
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
// SOFTDEL-DEC-02: a softDelete model needs an is_deleted flag column, but
|
|
1232
|
-
// createTable() built the schema from DECLARED fields only — so a
|
|
1233
|
-
// softDelete = true model that never declared is_deleted produced a table
|
|
1234
|
-
// with NO such column, and every soft-delete read/write then errored on
|
|
1235
|
-
// the missing column. syncModels() adds it on boot; createTable() must too.
|
|
1236
|
-
// Inject it (INTEGER 0/1, default 0) unless the model already declares it.
|
|
1237
|
-
if (this.softDelete && !("is_deleted" in mappedFields)) {
|
|
1238
|
-
mappedFields["is_deleted"] = { type: "integer", default: 0 };
|
|
1239
|
-
}
|
|
955
|
+
if (hasAdapterCreateTable(db)) {
|
|
956
|
+
const mappedFields = mappedCreateTableFields(this);
|
|
957
|
+
addSoftDeleteField(this, mappedFields);
|
|
1240
958
|
await adapterCreateTable(db, this.tableName, mappedFields);
|
|
1241
959
|
return this.createSpatialIndexes(db, pointFields);
|
|
1242
960
|
}
|
|
1243
961
|
|
|
1244
962
|
// Fallback: build SQL manually (SQLite-only dialect — used only when an
|
|
1245
963
|
// adapter lacks createTable, which none currently do).
|
|
1246
|
-
|
|
1247
|
-
integer: "INTEGER",
|
|
1248
|
-
string: "TEXT",
|
|
1249
|
-
text: "TEXT",
|
|
1250
|
-
number: "REAL",
|
|
1251
|
-
numeric: "REAL",
|
|
1252
|
-
boolean: "INTEGER",
|
|
1253
|
-
datetime: "TEXT",
|
|
1254
|
-
};
|
|
1255
|
-
|
|
1256
|
-
const colDefs: string[] = [];
|
|
1257
|
-
for (const [fieldName, def] of Object.entries(this.fields)) {
|
|
1258
|
-
const dbCol = this.getDbColumn(fieldName);
|
|
1259
|
-
const sqlType = typeMap[def.type] || "TEXT";
|
|
1260
|
-
const parts = [`"${dbCol}" ${sqlType}`];
|
|
1261
|
-
// A COMPOSITE key is declared ONCE, at table level (below). An inline
|
|
1262
|
-
// PRIMARY KEY per column is invalid DDL - SQLite, PostgreSQL and MySQL
|
|
1263
|
-
// all reject two of them in one table.
|
|
1264
|
-
if (def.primaryKey && this.getPkFields().length === 1) parts.push("PRIMARY KEY");
|
|
1265
|
-
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
1266
|
-
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
1267
|
-
// A callable default (e.g. `default: () => new Date()`) is resolved per-row
|
|
1268
|
-
// in the constructor above; it must NOT be emitted into the CREATE TABLE
|
|
1269
|
-
// DDL, where String(fn) stringifies to `DEFAULT () => ...` — invalid SQL
|
|
1270
|
-
// that silently fails table creation (parity with Python #61).
|
|
1271
|
-
if (def.default !== undefined && typeof def.default !== "function") {
|
|
1272
|
-
const dv = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
|
|
1273
|
-
parts.push(`DEFAULT ${dv}`);
|
|
1274
|
-
}
|
|
1275
|
-
colDefs.push(parts.join(" "));
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// SOFTDEL-DEC-02 (fallback path): inject the is_deleted flag column for a
|
|
1279
|
-
// softDelete model that did not declare it, mirroring the adapter path above.
|
|
1280
|
-
if (this.softDelete) {
|
|
1281
|
-
const dbCols = Object.keys(this.fields).map((f) => this.getDbColumn(f));
|
|
1282
|
-
if (!dbCols.includes("is_deleted")) {
|
|
1283
|
-
colDefs.push(`"is_deleted" INTEGER DEFAULT 0`);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// A COMPOSITE key is declared ONCE, at table level; the per-column inline
|
|
1288
|
-
// form above is suppressed for it, because two inline primary keys is
|
|
1289
|
-
// invalid DDL on every engine.
|
|
1290
|
-
const pkFields = this.getPkFields();
|
|
1291
|
-
if (pkFields.length > 1) {
|
|
1292
|
-
const pkCols = pkFields.map((f) => this.getDbColumn(f));
|
|
1293
|
-
colDefs.push(`PRIMARY KEY (${pkCols.join(", ")})`);
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
const sql = `CREATE TABLE IF NOT EXISTS "${this.tableName}" (${colDefs.join(", ")})`;
|
|
1297
|
-
await adapterStartTransaction(db);
|
|
1298
|
-
try {
|
|
1299
|
-
await adapterExecute(db, sql);
|
|
1300
|
-
await adapterCommit(db);
|
|
1301
|
-
} catch (e) {
|
|
1302
|
-
await adapterRollback(db);
|
|
1303
|
-
throw e;
|
|
1304
|
-
}
|
|
964
|
+
await createFallbackTable(db, this);
|
|
1305
965
|
return true;
|
|
1306
966
|
}
|
|
1307
967
|
|
|
@@ -1875,167 +1535,9 @@ export class BaseModel {
|
|
|
1875
1535
|
// Post._processForeignKeys() never ran. _applyFkRegistry() then merges the
|
|
1876
1536
|
// registered hasMany entries onto each model.
|
|
1877
1537
|
BaseModel._processAllForeignKeys();
|
|
1878
|
-
|
|
1879
|
-
// Group includes: top-level and nested
|
|
1880
|
-
const topLevel: Record<string, string[]> = {};
|
|
1881
|
-
for (const inc of include) {
|
|
1882
|
-
const parts = inc.split(".", 2);
|
|
1883
|
-
const relName = parts[0];
|
|
1884
|
-
if (!topLevel[relName]) {
|
|
1885
|
-
topLevel[relName] = [];
|
|
1886
|
-
}
|
|
1887
|
-
if (parts.length > 1) {
|
|
1888
|
-
topLevel[relName].push(parts[1]);
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1538
|
+
const topLevel = groupIncludedRelations(include);
|
|
1892
1539
|
for (const [relName, nested] of Object.entries(topLevel)) {
|
|
1893
|
-
|
|
1894
|
-
//
|
|
1895
|
-
// Include names are resolved case-insensitively against each candidate
|
|
1896
|
-
// relation. Accepted forms (for a relation to model "Post" on table "posts"):
|
|
1897
|
-
// - the model name → "Post" / "post"
|
|
1898
|
-
// - the auto/related key → "post" (singular) or "posts" (when
|
|
1899
|
-
// TINA4_ORM_PLURAL_TABLE_NAMES is enabled)
|
|
1900
|
-
// - the table name → "posts"
|
|
1901
|
-
// All matching is lower-cased so "Post", "post" and "posts" all resolve.
|
|
1902
|
-
const want = relName.toLowerCase();
|
|
1903
|
-
let relDef: RelationshipDefinition | undefined;
|
|
1904
|
-
let relType: "hasOne" | "hasMany" | "belongsTo" | null = null;
|
|
1905
|
-
|
|
1906
|
-
const matchesModel = (r: RelationshipDefinition): boolean => {
|
|
1907
|
-
const base = r.model.toLowerCase();
|
|
1908
|
-
const related = BaseModel._modelRegistry[r.model];
|
|
1909
|
-
const table = related?.tableName?.toLowerCase();
|
|
1910
|
-
// Outlier F: an FK-auto-wired has-many carries its derived key
|
|
1911
|
-
// (declaring class lowercased + "s", or relatedName) — match it so
|
|
1912
|
-
// include: ["posts"] resolves to the wired relation regardless of the
|
|
1913
|
-
// related table name.
|
|
1914
|
-
const rel = r.relatedName?.toLowerCase();
|
|
1915
|
-
return (
|
|
1916
|
-
base === want ||
|
|
1917
|
-
base + "s" === want ||
|
|
1918
|
-
(rel !== undefined && rel === want) ||
|
|
1919
|
-
(table !== undefined && table === want)
|
|
1920
|
-
);
|
|
1921
|
-
};
|
|
1922
|
-
|
|
1923
|
-
if (ModelClass.hasOne) {
|
|
1924
|
-
relDef = ModelClass.hasOne.find(matchesModel);
|
|
1925
|
-
if (relDef) relType = "hasOne";
|
|
1926
|
-
}
|
|
1927
|
-
if (!relDef && ModelClass.hasMany) {
|
|
1928
|
-
relDef = ModelClass.hasMany.find(matchesModel);
|
|
1929
|
-
if (relDef) relType = "hasMany";
|
|
1930
|
-
}
|
|
1931
|
-
if (!relDef && ModelClass.belongsTo) {
|
|
1932
|
-
relDef = ModelClass.belongsTo.find(matchesModel);
|
|
1933
|
-
if (relDef) relType = "belongsTo";
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
if (!relDef || !relType) {
|
|
1937
|
-
// Don't silently skip — a typo'd or unknown include name is almost
|
|
1938
|
-
// always a developer mistake. Surface it so it's visible.
|
|
1939
|
-
Log.warning(
|
|
1940
|
-
`eager-load: include "${relName}" did not match any relationship on ` +
|
|
1941
|
-
`${ModelClass.name} (table "${ModelClass.tableName}"). ` +
|
|
1942
|
-
`Accepted forms are the related model name, its singular/plural ` +
|
|
1943
|
-
`key, or the related table name (case-insensitive).`,
|
|
1944
|
-
);
|
|
1945
|
-
continue;
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
const relatedClass = BaseModel._modelRegistry[relDef.model];
|
|
1949
|
-
if (!relatedClass) continue;
|
|
1950
|
-
|
|
1951
|
-
const db = relatedClass.getDb();
|
|
1952
|
-
const fk = relDef.foreignKey;
|
|
1953
|
-
|
|
1954
|
-
if (relType === "hasOne" || relType === "hasMany") {
|
|
1955
|
-
const pk = ModelClass.getPkField();
|
|
1956
|
-
const pkValues = instances
|
|
1957
|
-
.map((inst) => inst[pk])
|
|
1958
|
-
.filter((v) => v !== undefined && v !== null);
|
|
1959
|
-
if (pkValues.length === 0) continue;
|
|
1960
|
-
|
|
1961
|
-
// REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays bounded
|
|
1962
|
-
// (each chunk is one query). adapterQuery is uncapped, so no row cap.
|
|
1963
|
-
const related: BaseModel[] = [];
|
|
1964
|
-
for (const pkChunk of _chunk(pkValues, EAGER_IN_CHUNK)) {
|
|
1965
|
-
const placeholders = pkChunk.map(() => "?").join(",");
|
|
1966
|
-
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${fk}" IN (${placeholders})`;
|
|
1967
|
-
if (relatedClass.softDelete) {
|
|
1968
|
-
sql += ` AND is_deleted = 0`;
|
|
1969
|
-
}
|
|
1970
|
-
const rows = await adapterQuery(db, sql, pkChunk);
|
|
1971
|
-
for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// Eager load nested
|
|
1975
|
-
if (nested.length > 0 && related.length > 0) {
|
|
1976
|
-
await relatedClass._eagerLoad(related, nested);
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
// Group by FK — fk is a DB column name, resolve to JS property name on the related model
|
|
1980
|
-
const relatedReverseMap = relatedClass.getReverseMapping();
|
|
1981
|
-
const fkProp = relatedReverseMap[fk] ?? fk;
|
|
1982
|
-
const grouped: Record<string, BaseModel[]> = {};
|
|
1983
|
-
for (const record of related) {
|
|
1984
|
-
const fkVal = _joinKey(record[fkProp]);
|
|
1985
|
-
if (!grouped[fkVal]) grouped[fkVal] = [];
|
|
1986
|
-
grouped[fkVal].push(record);
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
for (const inst of instances) {
|
|
1990
|
-
const pkVal = _joinKey(inst[pk]);
|
|
1991
|
-
const records = grouped[pkVal] || [];
|
|
1992
|
-
if (relType === "hasOne") {
|
|
1993
|
-
inst._relCache[relName] = records[0] ?? null;
|
|
1994
|
-
} else {
|
|
1995
|
-
inst._relCache[relName] = records;
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
} else if (relType === "belongsTo") {
|
|
1999
|
-
// fk is a DB column name on the current model — resolve to JS property name
|
|
2000
|
-
const ownerReverseMap = ModelClass.getReverseMapping();
|
|
2001
|
-
const fkProp = ownerReverseMap[fk] ?? fk;
|
|
2002
|
-
const fkValues = [...new Set(
|
|
2003
|
-
instances
|
|
2004
|
-
.map((inst) => inst[fkProp])
|
|
2005
|
-
.filter((v) => v !== undefined && v !== null),
|
|
2006
|
-
)];
|
|
2007
|
-
if (fkValues.length === 0) continue;
|
|
2008
|
-
|
|
2009
|
-
const relatedPk = relatedClass.getPkField();
|
|
2010
|
-
const relatedPkCol = relatedClass.getPkColumn();
|
|
2011
|
-
// REL-EAGER-UNBOUNDED: chunk the FK values so the IN list stays bounded.
|
|
2012
|
-
const related: BaseModel[] = [];
|
|
2013
|
-
for (const fkChunk of _chunk(fkValues, EAGER_IN_CHUNK)) {
|
|
2014
|
-
const placeholders = fkChunk.map(() => "?").join(",");
|
|
2015
|
-
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${relatedPkCol}" IN (${placeholders})`;
|
|
2016
|
-
if (relatedClass.softDelete) {
|
|
2017
|
-
sql += ` AND is_deleted = 0`;
|
|
2018
|
-
}
|
|
2019
|
-
const rows = await adapterQuery(db, sql, fkChunk);
|
|
2020
|
-
for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
if (nested.length > 0 && related.length > 0) {
|
|
2024
|
-
await relatedClass._eagerLoad(related, nested);
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
const lookup: Record<string, BaseModel> = {};
|
|
2028
|
-
for (const record of related) {
|
|
2029
|
-
lookup[_joinKey(record[relatedPk])] = record;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
for (const inst of instances) {
|
|
2033
|
-
const fkVal = inst[fkProp];
|
|
2034
|
-
inst._relCache[relName] = fkVal !== undefined && fkVal !== null
|
|
2035
|
-
? lookup[_joinKey(fkVal)] ?? null
|
|
2036
|
-
: null;
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
1540
|
+
await eagerLoadRelation(instances, ModelClass, relName, nested);
|
|
2039
1541
|
}
|
|
2040
1542
|
}
|
|
2041
1543
|
|
|
@@ -2062,3 +1564,415 @@ export class BaseModel {
|
|
|
2062
1564
|
this._relCache = {};
|
|
2063
1565
|
}
|
|
2064
1566
|
}
|
|
1567
|
+
|
|
1568
|
+
function savePrimaryKeyWhere(instance: BaseModel, model: typeof BaseModel): { sql: string; params: unknown[] } {
|
|
1569
|
+
const params: unknown[] = [];
|
|
1570
|
+
const clauses = primaryKeyFields(model).map((field) => {
|
|
1571
|
+
params.push(instance[field]);
|
|
1572
|
+
return `${model.getDbColumn(field)} = ?`;
|
|
1573
|
+
});
|
|
1574
|
+
return { sql: clauses.join(" AND "), params };
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
async function resolveSaveMode(
|
|
1578
|
+
instance: BaseModel,
|
|
1579
|
+
model: typeof BaseModel,
|
|
1580
|
+
db: DatabaseAdapter,
|
|
1581
|
+
pk: string,
|
|
1582
|
+
pkValue: unknown,
|
|
1583
|
+
pkField: FieldDefinition | undefined,
|
|
1584
|
+
): Promise<boolean> {
|
|
1585
|
+
if (pkValue === undefined || pkValue === null || pkField?.autoIncrement) return pkValue !== undefined && pkValue !== null;
|
|
1586
|
+
try {
|
|
1587
|
+
const where = savePrimaryKeyWhere(instance, model);
|
|
1588
|
+
if (primaryKeyFields(model).length > 1) {
|
|
1589
|
+
const found = await db.fetch(`SELECT 1 AS present FROM ${model.tableName} WHERE ${where.sql}`, where.params, 1);
|
|
1590
|
+
return (found as unknown as { length: number }).length > 0;
|
|
1591
|
+
}
|
|
1592
|
+
return await model.exists(pkValue);
|
|
1593
|
+
} catch {
|
|
1594
|
+
return false;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function validateBeforeSave(instance: BaseModel, model: typeof BaseModel, isUpdate: boolean): boolean {
|
|
1599
|
+
const errors = instance.validate(isUpdate);
|
|
1600
|
+
if (errors.length === 0) return true;
|
|
1601
|
+
instance.lastError = errors.join("; ");
|
|
1602
|
+
Log.error(`${model.name}.save() refused: validation failed for table '${model.tableName}' — ${instance.lastError}`);
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async function executeModelUpdate(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter): Promise<void> {
|
|
1607
|
+
const fields = Object.entries(model.fields).filter(([name, def]) => !def.primaryKey && instance[name] !== undefined);
|
|
1608
|
+
if (fields.length === 0) return;
|
|
1609
|
+
const setClause = fields.map(([name]) => `"${model.getDbColumn(name)}" = ?`).join(", ");
|
|
1610
|
+
const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
|
|
1611
|
+
const where = savePrimaryKeyWhere(instance, model);
|
|
1612
|
+
values.push(...where.params);
|
|
1613
|
+
await adapterExecute(db, `UPDATE "${model.tableName}" SET ${setClause} WHERE ${where.sql}`, values);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function buildInsertStatement(
|
|
1617
|
+
instance: BaseModel,
|
|
1618
|
+
model: typeof BaseModel,
|
|
1619
|
+
db: DatabaseAdapter,
|
|
1620
|
+
pkField: FieldDefinition | undefined,
|
|
1621
|
+
pkCol: string,
|
|
1622
|
+
): { sql: string; values: unknown[] } {
|
|
1623
|
+
const fields = Object.entries(model.fields).filter(
|
|
1624
|
+
([name, def]) => !(def.primaryKey && def.autoIncrement) && instance[name] !== undefined,
|
|
1625
|
+
);
|
|
1626
|
+
const returning = pkField?.autoIncrement && db.constructor.name !== "SQLiteAdapter" ? ` RETURNING "${pkCol}"` : "";
|
|
1627
|
+
if (fields.length === 0) {
|
|
1628
|
+
const emptyInsert = db.constructor.name === "MysqlAdapter"
|
|
1629
|
+
? `INSERT INTO "${model.tableName}" () VALUES ()`
|
|
1630
|
+
: `INSERT INTO "${model.tableName}" DEFAULT VALUES`;
|
|
1631
|
+
return { sql: emptyInsert + returning, values: [] };
|
|
1632
|
+
}
|
|
1633
|
+
const columns = fields.map(([name]) => `"${model.getDbColumn(name)}"`).join(", ");
|
|
1634
|
+
const placeholders = fields.map(() => "?").join(", ");
|
|
1635
|
+
const values = fields.map(([name, def]) => toDbFieldValue(def, instance[name]));
|
|
1636
|
+
return { sql: `INSERT INTO "${model.tableName}" (${columns}) VALUES (${placeholders})${returning}`, values };
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function applyInsertedId(
|
|
1640
|
+
instance: BaseModel,
|
|
1641
|
+
db: DatabaseAdapter,
|
|
1642
|
+
result: unknown,
|
|
1643
|
+
pk: string,
|
|
1644
|
+
pkCol: string,
|
|
1645
|
+
): void {
|
|
1646
|
+
let newId: number | bigint | string | null = extractLastInsertId(result);
|
|
1647
|
+
if (newId === null && result && typeof result === "object") {
|
|
1648
|
+
const rows = (result as { rows?: Array<Record<string, unknown>> }).rows;
|
|
1649
|
+
if (Array.isArray(rows) && rows[0]) newId = (rows[0][pkCol] ?? rows[0].id ?? null) as typeof newId;
|
|
1650
|
+
}
|
|
1651
|
+
if (newId === null) newId = db.lastInsertId();
|
|
1652
|
+
if (newId !== null && newId !== undefined) instance[pk] = newId;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async function executeModelInsert(
|
|
1656
|
+
instance: BaseModel,
|
|
1657
|
+
model: typeof BaseModel,
|
|
1658
|
+
db: DatabaseAdapter,
|
|
1659
|
+
pk: string,
|
|
1660
|
+
pkCol: string,
|
|
1661
|
+
pkField: FieldDefinition | undefined,
|
|
1662
|
+
): Promise<void> {
|
|
1663
|
+
const statement = buildInsertStatement(instance, model, db, pkField, pkCol);
|
|
1664
|
+
const result = await adapterExecute(db, statement.sql, statement.values);
|
|
1665
|
+
if (pkField?.autoIncrement) applyInsertedId(instance, db, result, pk, pkCol);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function saveDatabaseFailure(instance: BaseModel, model: typeof BaseModel, db: DatabaseAdapter, error: unknown): false {
|
|
1669
|
+
const adapter = db as any;
|
|
1670
|
+
const adapterError = typeof adapter.getError === "function" ? adapter.getError() :
|
|
1671
|
+
typeof adapter.getLastError === "function" ? adapter.getLastError() : null;
|
|
1672
|
+
let cause = String(adapterError || (error instanceof Error ? error.message : error));
|
|
1673
|
+
const low = cause.toLowerCase();
|
|
1674
|
+
if (model.softDelete && low.includes("is_deleted") &&
|
|
1675
|
+
["no such column", "has no column", "does not exist", "doesn't exist", "unknown column"].some((part) => low.includes(part))) {
|
|
1676
|
+
cause += " — softDelete=true needs an is_deleted column; declare it (is_deleted: { type: 'integer', default: 0 }), boot the server so syncModels() adds it, or run a migration";
|
|
1677
|
+
} else if (low.includes("no such table") ||
|
|
1678
|
+
((low.includes("does not exist") || low.includes("doesn't exist")) && !low.includes("column"))) {
|
|
1679
|
+
cause += ` — table '${model.tableName}' does not exist; call ${model.name}.createTable() or run a migration`;
|
|
1680
|
+
}
|
|
1681
|
+
instance.lastError = cause;
|
|
1682
|
+
Log.error(`${model.name}.save() failed for table '${model.tableName}': ${instance.lastError}`);
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function serializeModelFields(instance: BaseModel, model: typeof BaseModel, case_: "camel" | "snake"): Record<string, unknown> {
|
|
1687
|
+
const result: Record<string, unknown> = {};
|
|
1688
|
+
for (const key of Object.keys(model.fields)) {
|
|
1689
|
+
if (instance[key] === undefined) continue;
|
|
1690
|
+
const outKey = case_ === "snake" ? (model.fieldMapping[key] ?? key) : key;
|
|
1691
|
+
result[outKey] = instance[key] instanceof Point ? (instance[key] as Point).geojson : instance[key];
|
|
1692
|
+
}
|
|
1693
|
+
if (model.softDelete && instance.is_deleted !== undefined) result.is_deleted = instance.is_deleted;
|
|
1694
|
+
return result;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function groupIncludedRelations(include: string[]): Record<string, string[]> {
|
|
1698
|
+
const grouped: Record<string, string[]> = {};
|
|
1699
|
+
for (const item of include) {
|
|
1700
|
+
const parts = item.split(".", 2);
|
|
1701
|
+
grouped[parts[0]] ??= [];
|
|
1702
|
+
if (parts.length > 1) grouped[parts[0]].push(parts[1]);
|
|
1703
|
+
}
|
|
1704
|
+
return grouped;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function serializeIncludedRelation(
|
|
1708
|
+
instance: BaseModel,
|
|
1709
|
+
model: typeof BaseModel,
|
|
1710
|
+
result: Record<string, unknown>,
|
|
1711
|
+
relName: string,
|
|
1712
|
+
nested: string[],
|
|
1713
|
+
case_: "camel" | "snake",
|
|
1714
|
+
): void {
|
|
1715
|
+
const cache = (instance as unknown as { _relCache: Record<string, unknown> })._relCache;
|
|
1716
|
+
const data = cache[relName];
|
|
1717
|
+
if (data === undefined) {
|
|
1718
|
+
Log.warning(
|
|
1719
|
+
`${model.name}.toDict(): relation "${relName}" was requested via include but was never eager-loaded ` +
|
|
1720
|
+
`(a synchronous serializer cannot lazy-load it), so it is OMITTED from the result. Pass ` +
|
|
1721
|
+
`include: ["${relName}"] to the finder (find/all/where/select/load) that produced this instance.`,
|
|
1722
|
+
);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
if (data === null) {
|
|
1726
|
+
result[relName] = null;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
const nestedInclude = nested.length > 0 ? nested : undefined;
|
|
1730
|
+
if (Array.isArray(data)) {
|
|
1731
|
+
result[relName] = (data as BaseModel[]).map((item) => item.toDict(nestedInclude, case_));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (typeof (data as BaseModel).toDict === "function") {
|
|
1735
|
+
result[relName] = (data as BaseModel).toDict(nestedInclude, case_);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function serializeIncludedRelations(
|
|
1740
|
+
instance: BaseModel,
|
|
1741
|
+
model: typeof BaseModel,
|
|
1742
|
+
result: Record<string, unknown>,
|
|
1743
|
+
include: string[],
|
|
1744
|
+
case_: "camel" | "snake",
|
|
1745
|
+
): void {
|
|
1746
|
+
const grouped = groupIncludedRelations(include);
|
|
1747
|
+
for (const [relName, nested] of Object.entries(grouped)) {
|
|
1748
|
+
serializeIncludedRelation(instance, model, result, relName, nested, case_);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function serializeLoadedRelations(instance: BaseModel, model: typeof BaseModel, result: Record<string, unknown>): void {
|
|
1753
|
+
for (const relation of model.hasOne ?? []) {
|
|
1754
|
+
const key = relation.model.toLowerCase();
|
|
1755
|
+
if (instance[key] !== undefined) result[key] = instance[key];
|
|
1756
|
+
}
|
|
1757
|
+
for (const relation of model.hasMany ?? []) {
|
|
1758
|
+
const base = relation.model.toLowerCase();
|
|
1759
|
+
const key = _pluralRelKeys() ? base + "s" : base;
|
|
1760
|
+
if (instance[key] !== undefined) result[key] = instance[key];
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
type EagerRelation = {
|
|
1765
|
+
definition: RelationshipDefinition;
|
|
1766
|
+
type: "hasOne" | "hasMany" | "belongsTo";
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
function findEagerRelation(model: typeof BaseModel, name: string): EagerRelation | undefined {
|
|
1770
|
+
const want = name.toLowerCase();
|
|
1771
|
+
const matches = (relation: RelationshipDefinition): boolean => {
|
|
1772
|
+
const base = relation.model.toLowerCase();
|
|
1773
|
+
const related = BaseModel._modelRegistry[relation.model];
|
|
1774
|
+
const table = related?.tableName?.toLowerCase();
|
|
1775
|
+
const relatedName = relation.relatedName?.toLowerCase();
|
|
1776
|
+
return base === want || base + "s" === want || relatedName === want || table === want;
|
|
1777
|
+
};
|
|
1778
|
+
const candidates: Array<[RelationshipDefinition[] | undefined, EagerRelation["type"]]> = [
|
|
1779
|
+
[model.hasOne, "hasOne"],
|
|
1780
|
+
[model.hasMany, "hasMany"],
|
|
1781
|
+
[model.belongsTo, "belongsTo"],
|
|
1782
|
+
];
|
|
1783
|
+
for (const [relations, type] of candidates) {
|
|
1784
|
+
const definition = relations?.find(matches);
|
|
1785
|
+
if (definition) return { definition, type };
|
|
1786
|
+
}
|
|
1787
|
+
return undefined;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function relationshipCache(instance: BaseModel): Record<string, unknown> {
|
|
1791
|
+
return (instance as unknown as { _relCache: Record<string, unknown> })._relCache;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function reverseMappedColumn(model: typeof BaseModel, column: string): string {
|
|
1795
|
+
return model.getReverseMapping()[column] ?? column;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
async function eagerQueryRelated(
|
|
1799
|
+
relatedClass: typeof BaseModel,
|
|
1800
|
+
column: string,
|
|
1801
|
+
values: unknown[],
|
|
1802
|
+
): Promise<BaseModel[]> {
|
|
1803
|
+
const db = (relatedClass as unknown as { getDb(): DatabaseAdapter }).getDb();
|
|
1804
|
+
const related: BaseModel[] = [];
|
|
1805
|
+
for (const chunk of _chunk(values, EAGER_IN_CHUNK)) {
|
|
1806
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
1807
|
+
let sql = `SELECT * FROM "${relatedClass.tableName}" WHERE "${column}" IN (${placeholders})`;
|
|
1808
|
+
if (relatedClass.softDelete) sql += " AND is_deleted = 0";
|
|
1809
|
+
const rows = await adapterQuery(db, sql, chunk);
|
|
1810
|
+
for (const row of rows) related.push(new relatedClass(row as Record<string, unknown>));
|
|
1811
|
+
}
|
|
1812
|
+
return related;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
async function eagerLoadHasRelation(
|
|
1816
|
+
instances: BaseModel[],
|
|
1817
|
+
model: typeof BaseModel,
|
|
1818
|
+
relatedClass: typeof BaseModel,
|
|
1819
|
+
relationName: string,
|
|
1820
|
+
nested: string[],
|
|
1821
|
+
relation: RelationshipDefinition,
|
|
1822
|
+
type: "hasOne" | "hasMany",
|
|
1823
|
+
): Promise<void> {
|
|
1824
|
+
const pk = primaryKeyFields(model)[0];
|
|
1825
|
+
const values = instances.map((instance) => instance[pk]).filter((value) => value !== undefined && value !== null);
|
|
1826
|
+
if (values.length === 0) return;
|
|
1827
|
+
const related = await eagerQueryRelated(relatedClass, relation.foreignKey, values);
|
|
1828
|
+
if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
|
|
1829
|
+
const fkProp = reverseMappedColumn(relatedClass, relation.foreignKey);
|
|
1830
|
+
const grouped: Record<string, BaseModel[]> = {};
|
|
1831
|
+
for (const record of related) {
|
|
1832
|
+
const key = _joinKey(record[fkProp]);
|
|
1833
|
+
(grouped[key] ??= []).push(record);
|
|
1834
|
+
}
|
|
1835
|
+
for (const instance of instances) {
|
|
1836
|
+
const records = grouped[_joinKey(instance[pk])] ?? [];
|
|
1837
|
+
relationshipCache(instance)[relationName] = type === "hasOne" ? records[0] ?? null : records;
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
async function eagerLoadBelongsToRelation(
|
|
1842
|
+
instances: BaseModel[],
|
|
1843
|
+
model: typeof BaseModel,
|
|
1844
|
+
relatedClass: typeof BaseModel,
|
|
1845
|
+
relationName: string,
|
|
1846
|
+
nested: string[],
|
|
1847
|
+
relation: RelationshipDefinition,
|
|
1848
|
+
): Promise<void> {
|
|
1849
|
+
const fkProp = reverseMappedColumn(model, relation.foreignKey);
|
|
1850
|
+
const values = [...new Set(instances.map((instance) => instance[fkProp]).filter((value) => value !== undefined && value !== null))];
|
|
1851
|
+
if (values.length === 0) return;
|
|
1852
|
+
const relatedPk = primaryKeyFields(relatedClass)[0];
|
|
1853
|
+
const relatedPkColumn = relatedClass.getDbColumn(relatedPk);
|
|
1854
|
+
const related = await eagerQueryRelated(relatedClass, relatedPkColumn, values);
|
|
1855
|
+
if (nested.length > 0 && related.length > 0) await relatedClass._eagerLoad(related, nested);
|
|
1856
|
+
const lookup: Record<string, BaseModel> = {};
|
|
1857
|
+
for (const record of related) lookup[_joinKey(record[relatedPk])] = record;
|
|
1858
|
+
for (const instance of instances) {
|
|
1859
|
+
const value = instance[fkProp];
|
|
1860
|
+
relationshipCache(instance)[relationName] = value !== undefined && value !== null
|
|
1861
|
+
? lookup[_joinKey(value)] ?? null
|
|
1862
|
+
: null;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
async function eagerLoadRelation(
|
|
1867
|
+
instances: BaseModel[],
|
|
1868
|
+
model: typeof BaseModel,
|
|
1869
|
+
relationName: string,
|
|
1870
|
+
nested: string[],
|
|
1871
|
+
): Promise<void> {
|
|
1872
|
+
const found = findEagerRelation(model, relationName);
|
|
1873
|
+
if (!found) {
|
|
1874
|
+
Log.warning(
|
|
1875
|
+
`eager-load: include "${relationName}" did not match any relationship on ${model.name} ` +
|
|
1876
|
+
`(table "${model.tableName}"). Accepted forms are the related model name, its ` +
|
|
1877
|
+
`singular/plural key, or the related table name (case-insensitive).`,
|
|
1878
|
+
);
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const relatedClass = BaseModel._modelRegistry[found.definition.model];
|
|
1882
|
+
if (!relatedClass) return;
|
|
1883
|
+
if (found.type === "belongsTo") {
|
|
1884
|
+
await eagerLoadBelongsToRelation(instances, model, relatedClass, relationName, nested, found.definition);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
await eagerLoadHasRelation(instances, model, relatedClass, relationName, nested, found.definition, found.type);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/** Whether the adapter can emit engine-specific CREATE TABLE DDL. */
|
|
1891
|
+
function hasAdapterCreateTable(db: DatabaseAdapter): boolean {
|
|
1892
|
+
return typeof db.createTable === "function" || typeof (db as any).createTableAsync === "function";
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Map model fields to adapter column names while removing callable defaults.
|
|
1897
|
+
* Callable defaults are resolved per row by the model constructor and must not
|
|
1898
|
+
* be stringified into adapter DDL.
|
|
1899
|
+
*/
|
|
1900
|
+
function mappedCreateTableFields(model: typeof BaseModel): Record<string, FieldDefinition> {
|
|
1901
|
+
const mapped: Record<string, FieldDefinition> = {};
|
|
1902
|
+
for (const [fieldName, def] of Object.entries(model.fields)) {
|
|
1903
|
+
const dbCol = model.getDbColumn(fieldName);
|
|
1904
|
+
if (typeof def.default === "function") {
|
|
1905
|
+
const { default: _callableDefault, ...rest } = def;
|
|
1906
|
+
mapped[dbCol] = rest;
|
|
1907
|
+
} else {
|
|
1908
|
+
mapped[dbCol] = def;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return mapped;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/** Add the implicit soft-delete column when a model has not declared it. */
|
|
1915
|
+
function addSoftDeleteField(model: typeof BaseModel, fields: Record<string, FieldDefinition>): void {
|
|
1916
|
+
if (model.softDelete && !("is_deleted" in fields)) {
|
|
1917
|
+
fields.is_deleted = { type: "integer", default: 0 };
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function primaryKeyFields(model: typeof BaseModel): string[] {
|
|
1922
|
+
const keys = Object.entries(model.fields)
|
|
1923
|
+
.filter(([, def]) => def.primaryKey)
|
|
1924
|
+
.map(([name]) => name);
|
|
1925
|
+
return keys.length > 0 ? keys : ["id"];
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const FALLBACK_COLUMN_TYPES: Record<string, string> = {
|
|
1929
|
+
integer: "INTEGER",
|
|
1930
|
+
string: "TEXT",
|
|
1931
|
+
text: "TEXT",
|
|
1932
|
+
number: "REAL",
|
|
1933
|
+
numeric: "REAL",
|
|
1934
|
+
boolean: "INTEGER",
|
|
1935
|
+
datetime: "TEXT",
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
function fallbackColumnDefinition(model: typeof BaseModel, fieldName: string, def: FieldDefinition): string {
|
|
1939
|
+
const dbCol = model.getDbColumn(fieldName);
|
|
1940
|
+
const parts = [`"${dbCol}" ${FALLBACK_COLUMN_TYPES[def.type] || "TEXT"}`];
|
|
1941
|
+
if (def.primaryKey && primaryKeyFields(model).length === 1) parts.push("PRIMARY KEY");
|
|
1942
|
+
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
1943
|
+
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
1944
|
+
if (def.default !== undefined && typeof def.default !== "function") {
|
|
1945
|
+
const value = typeof def.default === "string" ? `'${def.default}'` : String(def.default);
|
|
1946
|
+
parts.push(`DEFAULT ${value}`);
|
|
1947
|
+
}
|
|
1948
|
+
return parts.join(" ");
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function fallbackPrimaryKeyDefinition(model: typeof BaseModel): string | undefined {
|
|
1952
|
+
const pkFields = primaryKeyFields(model);
|
|
1953
|
+
if (pkFields.length <= 1) return undefined;
|
|
1954
|
+
const pkCols = pkFields.map((fieldName) => model.getDbColumn(fieldName));
|
|
1955
|
+
return `PRIMARY KEY (${pkCols.join(", ")})`;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function fallbackColumnDefinitions(model: typeof BaseModel): string[] {
|
|
1959
|
+
const definitions = Object.entries(model.fields).map(([fieldName, def]) => fallbackColumnDefinition(model, fieldName, def));
|
|
1960
|
+
const dbCols = Object.keys(model.fields).map((fieldName) => model.getDbColumn(fieldName));
|
|
1961
|
+
if (model.softDelete && !dbCols.includes("is_deleted")) definitions.push('"is_deleted" INTEGER DEFAULT 0');
|
|
1962
|
+
const primaryKey = fallbackPrimaryKeyDefinition(model);
|
|
1963
|
+
if (primaryKey) definitions.push(primaryKey);
|
|
1964
|
+
return definitions;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
async function createFallbackTable(db: DatabaseAdapter, model: typeof BaseModel): Promise<void> {
|
|
1968
|
+
const definitions = fallbackColumnDefinitions(model);
|
|
1969
|
+
const sql = `CREATE TABLE IF NOT EXISTS "${model.tableName}" (${definitions.join(", ")})`;
|
|
1970
|
+
await adapterStartTransaction(db);
|
|
1971
|
+
try {
|
|
1972
|
+
await adapterExecute(db, sql);
|
|
1973
|
+
await adapterCommit(db);
|
|
1974
|
+
} catch (e) {
|
|
1975
|
+
await adapterRollback(db);
|
|
1976
|
+
throw e;
|
|
1977
|
+
}
|
|
1978
|
+
}
|