@stonyx/orm 0.2.1-beta.87 → 0.2.1-beta.89
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/aggregates.js +9 -6
- package/dist/db.js +13 -7
- package/dist/hooks.js +6 -2
- package/dist/main.js +3 -1
- package/dist/manage-record.js +10 -4
- package/dist/mysql/migration-generator.js +15 -6
- package/dist/mysql/mysql-db.js +24 -14
- package/dist/mysql/schema-introspector.js +11 -6
- package/dist/orm-request.js +35 -16
- package/dist/postgres/migration-generator.js +9 -5
- package/dist/postgres/postgres-db.js +14 -13
- package/dist/postgres/schema-introspector.js +11 -6
- package/dist/relationships.js +2 -0
- package/dist/setup-rest-server.js +4 -6
- package/dist/store.js +32 -17
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +5 -1
- package/dist/view-resolver.js +3 -1
- package/package.json +1 -1
- package/src/aggregates.ts +9 -7
- package/src/belongs-to.ts +1 -1
- package/src/db.ts +16 -9
- package/src/hooks.ts +4 -2
- package/src/main.ts +3 -2
- package/src/manage-record.ts +11 -6
- package/src/mysql/migration-generator.ts +12 -7
- package/src/mysql/mysql-db.ts +23 -17
- package/src/mysql/schema-introspector.ts +10 -7
- package/src/orm-request.ts +33 -18
- package/src/postgres/migration-generator.ts +7 -5
- package/src/postgres/postgres-db.ts +14 -13
- package/src/postgres/schema-introspector.ts +10 -7
- package/src/relationships.ts +3 -2
- package/src/setup-rest-server.ts +7 -10
- package/src/store.ts +30 -19
- package/src/utils.ts +7 -1
- package/src/view-resolver.ts +2 -1
package/dist/aggregates.js
CHANGED
|
@@ -18,19 +18,22 @@ export class AggregateProperty {
|
|
|
18
18
|
return null;
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
|
+
if (this.aggregateType === 'count')
|
|
22
|
+
return relatedRecords.length;
|
|
23
|
+
const field = this.field;
|
|
24
|
+
if (!field)
|
|
25
|
+
return null;
|
|
21
26
|
switch (this.aggregateType) {
|
|
22
|
-
case 'count':
|
|
23
|
-
return relatedRecords.length;
|
|
24
27
|
case 'sum':
|
|
25
28
|
return relatedRecords.reduce((acc, record) => {
|
|
26
|
-
const val = parseFloat(record?.__data?.[
|
|
29
|
+
const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
|
|
27
30
|
return acc + (isNaN(val) ? 0 : val);
|
|
28
31
|
}, 0);
|
|
29
32
|
case 'avg': {
|
|
30
33
|
let sum = 0;
|
|
31
34
|
let count = 0;
|
|
32
35
|
for (const record of relatedRecords) {
|
|
33
|
-
const val = parseFloat(record?.__data?.[
|
|
36
|
+
const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
|
|
34
37
|
if (!isNaN(val)) {
|
|
35
38
|
sum += val;
|
|
36
39
|
count++;
|
|
@@ -41,7 +44,7 @@ export class AggregateProperty {
|
|
|
41
44
|
case 'min': {
|
|
42
45
|
let min = null;
|
|
43
46
|
for (const record of relatedRecords) {
|
|
44
|
-
const val = parseFloat(record?.__data?.[
|
|
47
|
+
const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
|
|
45
48
|
if (!isNaN(val) && (min === null || val < min))
|
|
46
49
|
min = val;
|
|
47
50
|
}
|
|
@@ -50,7 +53,7 @@ export class AggregateProperty {
|
|
|
50
53
|
case 'max': {
|
|
51
54
|
let max = null;
|
|
52
55
|
for (const record of relatedRecords) {
|
|
53
|
-
const val = parseFloat(record?.__data?.[
|
|
56
|
+
const val = parseFloat(record?.__data?.[field] ?? record?.[field]);
|
|
54
57
|
if (!isNaN(val) && (max === null || val > max))
|
|
55
58
|
max = val;
|
|
56
59
|
}
|
package/dist/db.js
CHANGED
|
@@ -21,6 +21,12 @@ import { createRecord } from './manage-record.js';
|
|
|
21
21
|
import { createFile, createDirectory, updateFile, readFile, fileExists } from '@stonyx/utils/file';
|
|
22
22
|
import path from 'path';
|
|
23
23
|
export const dbKey = '__db';
|
|
24
|
+
function asDBRecord(value) {
|
|
25
|
+
if (typeof value !== 'object' || value === null || typeof value.format !== 'function') {
|
|
26
|
+
throw new Error('createRecord did not return a valid DBRecord');
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
24
30
|
export default class DB {
|
|
25
31
|
static instance;
|
|
26
32
|
record;
|
|
@@ -66,7 +72,7 @@ export default class DB {
|
|
|
66
72
|
const data = await readFile(dbFilePath, { json: true });
|
|
67
73
|
const hasData = collectionKeys.some(key => Array.isArray(data[key]) && data[key].length > 0);
|
|
68
74
|
if (hasData) {
|
|
69
|
-
log.error(`DB mode mismatch: db.json contains data but mode is set to 'directory'. Run migration first:\n\n stonyx db:migrate-to-directory\n`);
|
|
75
|
+
log.error?.(`DB mode mismatch: db.json contains data but mode is set to 'directory'. Run migration first:\n\n stonyx db:migrate-to-directory\n`);
|
|
70
76
|
process.exit(1);
|
|
71
77
|
}
|
|
72
78
|
}
|
|
@@ -76,7 +82,7 @@ export default class DB {
|
|
|
76
82
|
if (dirExists) {
|
|
77
83
|
const hasCollectionFiles = (await Promise.all(collectionKeys.map(key => fileExists(path.join(dirPath, `${key}.json`))))).some(Boolean);
|
|
78
84
|
if (hasCollectionFiles) {
|
|
79
|
-
log.error(`DB mode mismatch: directory '${config.orm.db.directory}/' contains collection files but mode is set to 'file'. Run migration first:\n\n stonyx db:migrate-to-file\n`);
|
|
85
|
+
log.error?.(`DB mode mismatch: directory '${config.orm.db.directory}/' contains collection files but mode is set to 'file'. Run migration first:\n\n stonyx db:migrate-to-file\n`);
|
|
80
86
|
process.exit(1);
|
|
81
87
|
}
|
|
82
88
|
}
|
|
@@ -138,11 +144,11 @@ export default class DB {
|
|
|
138
144
|
await updateFile(dbFilePath, skeleton, { json: true });
|
|
139
145
|
else
|
|
140
146
|
await createFile(dbFilePath, skeleton, { json: true });
|
|
141
|
-
log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
147
|
+
log.db?.(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
142
148
|
return;
|
|
143
149
|
}
|
|
144
150
|
await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
|
|
145
|
-
log.db(`DB has been successfully saved to ${file}`);
|
|
151
|
+
log.db?.(`DB has been successfully saved to ${file}`);
|
|
146
152
|
}
|
|
147
153
|
async getRecord() {
|
|
148
154
|
const { mode } = config.orm.db;
|
|
@@ -153,7 +159,7 @@ export default class DB {
|
|
|
153
159
|
async getRecordFromFile() {
|
|
154
160
|
const { file } = config.orm.db;
|
|
155
161
|
const data = await readFile(file, { json: true, missingFileCallback: this.create.bind(this) });
|
|
156
|
-
return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false });
|
|
162
|
+
return asDBRecord(createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false }));
|
|
157
163
|
}
|
|
158
164
|
async getRecordFromDirectory() {
|
|
159
165
|
const dirPath = this.getDirPath();
|
|
@@ -161,7 +167,7 @@ export default class DB {
|
|
|
161
167
|
const dirExists = await fileExists(dirPath);
|
|
162
168
|
if (!dirExists) {
|
|
163
169
|
const data = await this.create();
|
|
164
|
-
return createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false });
|
|
170
|
+
return asDBRecord(createRecord(dbKey, data, { isDbRecord: true, serialize: false, transform: false }));
|
|
165
171
|
}
|
|
166
172
|
const assembled = {};
|
|
167
173
|
await Promise.all(collectionKeys.map(async (key) => {
|
|
@@ -169,6 +175,6 @@ export default class DB {
|
|
|
169
175
|
const exists = await fileExists(filePath);
|
|
170
176
|
assembled[key] = exists ? await readFile(filePath, { json: true }) : [];
|
|
171
177
|
}));
|
|
172
|
-
return createRecord(dbKey, assembled, { isDbRecord: true, serialize: false, transform: false });
|
|
178
|
+
return asDBRecord(createRecord(dbKey, assembled, { isDbRecord: true, serialize: false, transform: false }));
|
|
173
179
|
}
|
|
174
180
|
}
|
package/dist/hooks.js
CHANGED
|
@@ -31,7 +31,9 @@ export function beforeHook(operation, model, handler) {
|
|
|
31
31
|
if (!beforeHooks.has(key)) {
|
|
32
32
|
beforeHooks.set(key, []);
|
|
33
33
|
}
|
|
34
|
-
beforeHooks.get(key)
|
|
34
|
+
const hooks = beforeHooks.get(key);
|
|
35
|
+
if (hooks)
|
|
36
|
+
hooks.push(handler);
|
|
35
37
|
// Return unsubscribe function
|
|
36
38
|
return () => {
|
|
37
39
|
const hooks = beforeHooks.get(key);
|
|
@@ -56,7 +58,9 @@ export function afterHook(operation, model, handler) {
|
|
|
56
58
|
if (!afterHooks.has(key)) {
|
|
57
59
|
afterHooks.set(key, []);
|
|
58
60
|
}
|
|
59
|
-
afterHooks.get(key)
|
|
61
|
+
const hooks = afterHooks.get(key);
|
|
62
|
+
if (hooks)
|
|
63
|
+
hooks.push(handler);
|
|
60
64
|
// Return unsubscribe function
|
|
61
65
|
return () => {
|
|
62
66
|
const hooks = afterHooks.get(key);
|
package/dist/main.js
CHANGED
|
@@ -145,6 +145,8 @@ export default class Orm {
|
|
|
145
145
|
static get db() {
|
|
146
146
|
if (!Orm.initialized)
|
|
147
147
|
throw new Error('ORM has not been initialized yet');
|
|
148
|
+
if (!Orm.instance.db)
|
|
149
|
+
throw new Error('ORM database has not been initialized');
|
|
148
150
|
return Orm.instance.db;
|
|
149
151
|
}
|
|
150
152
|
getRecordClasses(modelName) {
|
|
@@ -170,7 +172,7 @@ export default class Orm {
|
|
|
170
172
|
warn(message) {
|
|
171
173
|
this.warnings.add(message);
|
|
172
174
|
setTimeout(() => {
|
|
173
|
-
this.warnings.forEach(warning => log.warn(warning));
|
|
175
|
+
this.warnings.forEach(warning => log.warn?.(warning));
|
|
174
176
|
this.warnings.clear();
|
|
175
177
|
}, 0);
|
|
176
178
|
}
|
package/dist/manage-record.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Orm, { store } from '@stonyx/orm';
|
|
2
2
|
import OrmRecord from './record.js';
|
|
3
3
|
import { getGlobalRegistry, getPendingRegistry, getPendingBelongsToRegistry, getBelongsToRegistry, getHasManyRegistry } from './relationships.js';
|
|
4
|
+
import { isOrmRecord } from './utils.js';
|
|
4
5
|
const defaultOptions = {
|
|
5
6
|
isDbRecord: false,
|
|
6
7
|
serialize: true,
|
|
@@ -23,7 +24,7 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
23
24
|
throw new Error(`Model store for '${modelName}' is not registered. Ensure the model is defined before creating records.`);
|
|
24
25
|
assignRecordId(modelName, rawData);
|
|
25
26
|
const existingRecord = modelStore.get(rawData.id);
|
|
26
|
-
if (existingRecord) {
|
|
27
|
+
if (existingRecord instanceof OrmRecord) {
|
|
27
28
|
// Update the existing record with new data so the last entry wins
|
|
28
29
|
updateRecord(existingRecord, rawData, { ...options, update: true });
|
|
29
30
|
return existingRecord;
|
|
@@ -52,7 +53,8 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
52
53
|
}
|
|
53
54
|
// Fulfill pending belongsTo relationships
|
|
54
55
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
55
|
-
const
|
|
56
|
+
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
57
|
+
const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw : undefined;
|
|
56
58
|
if (pendingBelongsTo) {
|
|
57
59
|
const belongsToReg = getBelongsToRegistry();
|
|
58
60
|
const hasManyReg = getHasManyRegistry();
|
|
@@ -105,8 +107,12 @@ function assignRecordId(modelName, rawData) {
|
|
|
105
107
|
rawData.__pendingSqlId = true;
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
+
const storeMap = store.get(modelName);
|
|
111
|
+
if (!storeMap)
|
|
112
|
+
throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
113
|
+
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
114
|
+
const lastRecord = modelStore.at(-1);
|
|
115
|
+
rawData.id = lastRecord ? lastRecord.id + 1 : 1;
|
|
110
116
|
}
|
|
111
117
|
function isStringIdModel(modelName) {
|
|
112
118
|
const modelClass = Orm.instance.getRecordClasses(modelName).modelClass;
|
|
@@ -4,7 +4,12 @@ import path from 'path';
|
|
|
4
4
|
import config from 'stonyx/config';
|
|
5
5
|
import log from 'stonyx/log';
|
|
6
6
|
export async function generateMigration(description = 'migration') {
|
|
7
|
-
const
|
|
7
|
+
const mysqlConfig = config.orm.mysql;
|
|
8
|
+
if (!mysqlConfig)
|
|
9
|
+
throw new Error('MySQL configuration (config.orm.mysql) is required for migration generation');
|
|
10
|
+
const { migrationsDir } = mysqlConfig;
|
|
11
|
+
if (!migrationsDir)
|
|
12
|
+
throw new Error('MySQL migrationsDir is required in config');
|
|
8
13
|
const rootPath = config.rootPath;
|
|
9
14
|
const migrationsPath = path.resolve(rootPath, migrationsDir);
|
|
10
15
|
await createDirectory(migrationsPath);
|
|
@@ -20,7 +25,7 @@ export async function generateMigration(description = 'migration') {
|
|
|
20
25
|
const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
|
|
21
26
|
const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
|
|
22
27
|
if (!viewDiffPrelim.hasChanges) {
|
|
23
|
-
log.db('No schema changes detected.');
|
|
28
|
+
log.db?.('No schema changes detected.');
|
|
24
29
|
return null;
|
|
25
30
|
}
|
|
26
31
|
}
|
|
@@ -47,7 +52,9 @@ export async function generateMigration(description = 'migration') {
|
|
|
47
52
|
}
|
|
48
53
|
// Removed columns
|
|
49
54
|
for (const { model, column, type } of diff.removedColumns) {
|
|
50
|
-
const table = previousSnapshot[model]
|
|
55
|
+
const table = previousSnapshot[model]?.table;
|
|
56
|
+
if (!table)
|
|
57
|
+
throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
51
58
|
upStatements.push(`ALTER TABLE \`${table}\` DROP COLUMN \`${column}\`;`);
|
|
52
59
|
downStatements.push(`ALTER TABLE \`${table}\` ADD COLUMN \`${column}\` ${type};`);
|
|
53
60
|
}
|
|
@@ -70,7 +77,9 @@ export async function generateMigration(description = 'migration') {
|
|
|
70
77
|
}
|
|
71
78
|
// Removed foreign keys
|
|
72
79
|
for (const { model, column, references } of diff.removedForeignKeys) {
|
|
73
|
-
const table = previousSnapshot[model]
|
|
80
|
+
const table = previousSnapshot[model]?.table;
|
|
81
|
+
if (!table)
|
|
82
|
+
throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
74
83
|
// Resolve FK column type from the referenced table's PK type in previous snapshot
|
|
75
84
|
const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
|
|
76
85
|
const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INT';
|
|
@@ -119,7 +128,7 @@ export async function generateMigration(description = 'migration') {
|
|
|
119
128
|
}
|
|
120
129
|
const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
|
|
121
130
|
if (!combinedHasChanges) {
|
|
122
|
-
log.db('No schema changes detected.');
|
|
131
|
+
log.db?.('No schema changes detected.');
|
|
123
132
|
return null;
|
|
124
133
|
}
|
|
125
134
|
// Merge view snapshot into the main snapshot
|
|
@@ -133,7 +142,7 @@ export async function generateMigration(description = 'migration') {
|
|
|
133
142
|
const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
|
|
134
143
|
await createFile(path.join(migrationsPath, filename), content);
|
|
135
144
|
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
|
|
136
|
-
log.db(`Migration generated: ${filename}`);
|
|
145
|
+
log.db?.(`Migration generated: ${filename}`);
|
|
137
146
|
return { filename, content, snapshot: combinedSnapshot };
|
|
138
147
|
}
|
|
139
148
|
export async function loadLatestSnapshot(migrationsPath) {
|
package/dist/mysql/mysql-db.js
CHANGED
|
@@ -32,7 +32,10 @@ export default class MysqlDB {
|
|
|
32
32
|
MysqlDB.instance = this;
|
|
33
33
|
this.deps = { ...defaultDeps, ...deps };
|
|
34
34
|
this.pool = null;
|
|
35
|
-
|
|
35
|
+
const mysqlConfig = this.deps.config.orm.mysql;
|
|
36
|
+
if (!mysqlConfig)
|
|
37
|
+
throw new Error('MySQL configuration (config.orm.mysql) is required');
|
|
38
|
+
this.mysqlConfig = mysqlConfig;
|
|
36
39
|
}
|
|
37
40
|
requirePool() {
|
|
38
41
|
if (!this.pool)
|
|
@@ -45,26 +48,28 @@ export default class MysqlDB {
|
|
|
45
48
|
await this.loadMemoryRecords();
|
|
46
49
|
}
|
|
47
50
|
async startup() {
|
|
51
|
+
if (!this.mysqlConfig.migrationsDir)
|
|
52
|
+
throw new Error('MySQL migrationsDir is required in config');
|
|
48
53
|
const migrationsPath = this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir);
|
|
49
54
|
// Check for pending migrations
|
|
50
55
|
const applied = await this.deps.getAppliedMigrations(this.requirePool(), this.mysqlConfig.migrationsTable);
|
|
51
56
|
const files = await this.deps.getMigrationFiles(migrationsPath);
|
|
52
57
|
const pending = files.filter(f => !applied.includes(f));
|
|
53
58
|
if (pending.length > 0) {
|
|
54
|
-
this.deps.log.db(`${pending.length} pending migration(s) found.`);
|
|
59
|
+
this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
|
|
55
60
|
const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
|
|
56
61
|
if (shouldApply) {
|
|
57
62
|
for (const filename of pending) {
|
|
58
63
|
const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
|
|
59
64
|
const { up } = this.deps.parseMigrationFile(content);
|
|
60
65
|
await this.deps.applyMigration(this.requirePool(), filename, up, this.mysqlConfig.migrationsTable);
|
|
61
|
-
this.deps.log.db(`Applied migration: ${filename}`);
|
|
66
|
+
this.deps.log.db?.(`Applied migration: ${filename}`);
|
|
62
67
|
}
|
|
63
68
|
// Reload records after applying migrations
|
|
64
69
|
await this.loadMemoryRecords();
|
|
65
70
|
}
|
|
66
71
|
else {
|
|
67
|
-
this.deps.log.warn('Skipping pending migrations. Schema may be outdated.');
|
|
72
|
+
this.deps.log.warn?.('Skipping pending migrations. Schema may be outdated.');
|
|
68
73
|
}
|
|
69
74
|
}
|
|
70
75
|
else if (files.length === 0) {
|
|
@@ -78,23 +83,25 @@ export default class MysqlDB {
|
|
|
78
83
|
if (result) {
|
|
79
84
|
const { up } = this.deps.parseMigrationFile(result.content);
|
|
80
85
|
await this.deps.applyMigration(this.requirePool(), result.filename, up, this.mysqlConfig.migrationsTable);
|
|
81
|
-
this.deps.log.db(`Applied migration: ${result.filename}`);
|
|
86
|
+
this.deps.log.db?.(`Applied migration: ${result.filename}`);
|
|
82
87
|
await this.loadMemoryRecords();
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
else {
|
|
86
|
-
this.deps.log.warn('Skipping initial migration. Tables may not exist.');
|
|
91
|
+
this.deps.log.warn?.('Skipping initial migration. Tables may not exist.');
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
// Check for schema drift
|
|
91
96
|
const schemas = this.deps.introspectModels();
|
|
97
|
+
if (!this.mysqlConfig.migrationsDir)
|
|
98
|
+
throw new Error('MySQL migrationsDir is required in config');
|
|
92
99
|
const snapshot = await this.deps.loadLatestSnapshot(this.deps.path.resolve(this.deps.config.rootPath, this.mysqlConfig.migrationsDir));
|
|
93
100
|
if (Object.keys(snapshot).length > 0) {
|
|
94
101
|
const drift = this.deps.detectSchemaDrift(schemas, snapshot);
|
|
95
102
|
if (drift.hasChanges) {
|
|
96
|
-
this.deps.log.warn('Schema drift detected: models have changed since the last migration.');
|
|
97
|
-
this.deps.log.warn('Run `stonyx db:generate-migration` to create a new migration.');
|
|
103
|
+
this.deps.log.warn?.('Schema drift detected: models have changed since the last migration.');
|
|
104
|
+
this.deps.log.warn?.('Run `stonyx db:generate-migration` to create a new migration.');
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
}
|
|
@@ -117,7 +124,7 @@ export default class MysqlDB {
|
|
|
117
124
|
// Check the model's memory flag — skip non-memory models
|
|
118
125
|
const { modelClass } = Orm.instance.getRecordClasses(modelName);
|
|
119
126
|
if (modelClass?.memory === false) {
|
|
120
|
-
this.deps.log.db(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
127
|
+
this.deps.log.db?.(`Skipping memory load for '${modelName}' (memory: false)`);
|
|
121
128
|
continue;
|
|
122
129
|
}
|
|
123
130
|
const schema = schemas[modelName];
|
|
@@ -133,7 +140,7 @@ export default class MysqlDB {
|
|
|
133
140
|
catch (error) {
|
|
134
141
|
// Table may not exist yet (pre-migration) — skip gracefully
|
|
135
142
|
if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
|
|
136
|
-
this.deps.log.db(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
143
|
+
this.deps.log.db?.(`Table '${schema.table}' does not exist yet. Skipping load for '${modelName}'.`);
|
|
137
144
|
continue;
|
|
138
145
|
}
|
|
139
146
|
throw error;
|
|
@@ -144,7 +151,7 @@ export default class MysqlDB {
|
|
|
144
151
|
for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
|
|
145
152
|
const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName);
|
|
146
153
|
if (viewClass?.memory !== true) {
|
|
147
|
-
this.deps.log.db(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
154
|
+
this.deps.log.db?.(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
148
155
|
continue;
|
|
149
156
|
}
|
|
150
157
|
const schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
|
|
@@ -159,7 +166,7 @@ export default class MysqlDB {
|
|
|
159
166
|
}
|
|
160
167
|
catch (error) {
|
|
161
168
|
if (isDbError(error) && error.code === 'ER_NO_SUCH_TABLE') {
|
|
162
|
-
this.deps.log.db(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
169
|
+
this.deps.log.db?.(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
163
170
|
continue;
|
|
164
171
|
}
|
|
165
172
|
throw error;
|
|
@@ -224,12 +231,13 @@ export default class MysqlDB {
|
|
|
224
231
|
}
|
|
225
232
|
if (!schema)
|
|
226
233
|
return [];
|
|
227
|
-
const
|
|
234
|
+
const resolvedSchema = schema;
|
|
235
|
+
const { sql, values } = this.deps.buildSelect(resolvedSchema.table, conditions);
|
|
228
236
|
try {
|
|
229
237
|
const result = await this.requirePool().execute(sql, values);
|
|
230
238
|
const rows = result[0];
|
|
231
239
|
const records = rows.map(row => {
|
|
232
|
-
const rawData = this._rowToRawData(row,
|
|
240
|
+
const rawData = this._rowToRawData(row, resolvedSchema);
|
|
233
241
|
return this.deps.createRecord(modelName, rawData, { isDbRecord: true, serialize: false, transform: false });
|
|
234
242
|
});
|
|
235
243
|
// Don't let memory:false records accumulate in the store
|
|
@@ -328,6 +336,8 @@ export default class MysqlDB {
|
|
|
328
336
|
const pendingId = record.id;
|
|
329
337
|
const realId = result.insertId;
|
|
330
338
|
const modelStore = this.deps.store.get(modelName);
|
|
339
|
+
if (!modelStore)
|
|
340
|
+
throw new Error(`Model "${modelName}" not found in store during ID re-key`);
|
|
331
341
|
modelStore.delete(pendingId);
|
|
332
342
|
record.__data.id = realId;
|
|
333
343
|
record.id = realId;
|
|
@@ -53,6 +53,8 @@ export function introspectModels() {
|
|
|
53
53
|
}
|
|
54
54
|
// Build foreign keys from belongsTo relationships
|
|
55
55
|
for (const [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
|
|
56
|
+
if (!targetModelName)
|
|
57
|
+
continue;
|
|
56
58
|
const fkColumn = `${relName}_id`;
|
|
57
59
|
foreignKeys[fkColumn] = {
|
|
58
60
|
references: sanitizeTableName(getPluralName(targetModelName)),
|
|
@@ -122,7 +124,8 @@ export function getTopologicalOrder(schemas) {
|
|
|
122
124
|
return;
|
|
123
125
|
// Visit dependencies (belongsTo targets) first
|
|
124
126
|
for (const targetModelName of Object.values(schema.relationships.belongsTo)) {
|
|
125
|
-
|
|
127
|
+
if (targetModelName)
|
|
128
|
+
visit(targetModelName);
|
|
126
129
|
}
|
|
127
130
|
order.push(name);
|
|
128
131
|
}
|
|
@@ -158,11 +161,13 @@ export function introspectViews() {
|
|
|
158
161
|
const relInfo = getRelationshipInfo(property);
|
|
159
162
|
if (relInfo?.type === 'belongsTo') {
|
|
160
163
|
relationships.belongsTo[key] = relInfo.modelName;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
if (relInfo.modelName) {
|
|
165
|
+
const fkColumn = `${key}_id`;
|
|
166
|
+
foreignKeys[fkColumn] = {
|
|
167
|
+
references: sanitizeTableName(getPluralName(relInfo.modelName)),
|
|
168
|
+
column: 'id',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
166
171
|
}
|
|
167
172
|
else if (relInfo?.type === 'hasMany') {
|
|
168
173
|
relationships.hasMany[key] = relInfo.modelName;
|
package/dist/orm-request.js
CHANGED
|
@@ -4,6 +4,7 @@ import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
|
4
4
|
import { getPluralName } from './plural-registry.js';
|
|
5
5
|
import { getBeforeHooks, getAfterHooks } from './hooks.js';
|
|
6
6
|
import config from 'stonyx/config';
|
|
7
|
+
import { isOrmRecord } from './utils.js';
|
|
7
8
|
const methodAccessMap = {
|
|
8
9
|
GET: 'read',
|
|
9
10
|
POST: 'create',
|
|
@@ -69,7 +70,7 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
69
70
|
return response;
|
|
70
71
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
71
72
|
if (includedRecords.length > 0) {
|
|
72
|
-
response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
|
|
73
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
73
74
|
}
|
|
74
75
|
return response;
|
|
75
76
|
}
|
|
@@ -91,11 +92,13 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
91
92
|
continue;
|
|
92
93
|
// Handle both belongsTo (single) and hasMany (array)
|
|
93
94
|
const recordsToProcess = Array.isArray(relatedRecords)
|
|
94
|
-
? relatedRecords
|
|
95
|
-
: [relatedRecords];
|
|
95
|
+
? relatedRecords.filter(isOrmRecord)
|
|
96
|
+
: isOrmRecord(relatedRecords) ? [relatedRecords] : [];
|
|
96
97
|
for (const relatedRecord of recordsToProcess) {
|
|
97
98
|
if (!relatedRecord)
|
|
98
99
|
continue;
|
|
100
|
+
if (!relatedRecord.__model)
|
|
101
|
+
continue;
|
|
99
102
|
const type = relatedRecord.__model.__name;
|
|
100
103
|
const id = relatedRecord.id;
|
|
101
104
|
// Initialize Set for this type if needed
|
|
@@ -196,7 +199,7 @@ export default class OrmRequest extends Request {
|
|
|
196
199
|
const modelRelationships = getModelRelationships(model);
|
|
197
200
|
// Define raw handlers first
|
|
198
201
|
const getCollectionHandler = async (request, { filter: accessFilter }) => {
|
|
199
|
-
const allRecords = await store.findAll(model);
|
|
202
|
+
const allRecords = (await store.findAll(model)).filter(isOrmRecord);
|
|
200
203
|
const queryFilters = parseFilters(request.query);
|
|
201
204
|
const queryFilterPredicate = createFilterPredicate(queryFilters);
|
|
202
205
|
const fieldsMap = parseFields(request.query);
|
|
@@ -207,7 +210,7 @@ export default class OrmRequest extends Request {
|
|
|
207
210
|
if (queryFilterPredicate)
|
|
208
211
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
209
212
|
const baseUrl = getBaseUrl(request);
|
|
210
|
-
const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
|
|
213
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
211
214
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
212
215
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
213
216
|
baseUrl
|
|
@@ -220,7 +223,7 @@ export default class OrmRequest extends Request {
|
|
|
220
223
|
const fieldsMap = parseFields(request.query);
|
|
221
224
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
222
225
|
const baseUrl = getBaseUrl(request);
|
|
223
|
-
return buildResponse(record.toJSON({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
226
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
224
227
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
225
228
|
baseUrl
|
|
226
229
|
});
|
|
@@ -245,11 +248,17 @@ export default class OrmRequest extends Request {
|
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
248
|
-
const
|
|
249
|
-
|
|
251
|
+
const created = createRecord(model, recordAttributes, { serialize: false });
|
|
252
|
+
const record = isOrmRecord(created) ? created : null;
|
|
253
|
+
if (!record)
|
|
254
|
+
return 500;
|
|
255
|
+
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
250
256
|
};
|
|
251
257
|
const updateHandler = async ({ body, params }) => {
|
|
252
|
-
const
|
|
258
|
+
const found = await store.find(model, getId(params));
|
|
259
|
+
if (!found || !isOrmRecord(found))
|
|
260
|
+
return 404;
|
|
261
|
+
const record = found;
|
|
253
262
|
const { attributes, relationships: rels } = (body?.data || {});
|
|
254
263
|
if (!attributes && !rels)
|
|
255
264
|
return 400; // Bad request
|
|
@@ -277,7 +286,7 @@ export default class OrmRequest extends Request {
|
|
|
277
286
|
updateRecord(record, relUpdates);
|
|
278
287
|
}
|
|
279
288
|
}
|
|
280
|
-
return { data: record.toJSON() };
|
|
289
|
+
return { data: record.toJSON?.() };
|
|
281
290
|
};
|
|
282
291
|
const deleteHandler = ({ params }) => {
|
|
283
292
|
store.remove(model, getId(params));
|
|
@@ -340,8 +349,9 @@ export default class OrmRequest extends Request {
|
|
|
340
349
|
// Execute main handler
|
|
341
350
|
const response = await handler(request, state);
|
|
342
351
|
// Persist to SQL database for write operations
|
|
343
|
-
|
|
344
|
-
|
|
352
|
+
const sqlDb = Orm.instance.sqlDb;
|
|
353
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
354
|
+
await sqlDb.persist(operation, this.model, context, response);
|
|
345
355
|
}
|
|
346
356
|
// Add response and relevant records to context
|
|
347
357
|
context.response = response;
|
|
@@ -390,11 +400,12 @@ export default class OrmRequest extends Request {
|
|
|
390
400
|
let data;
|
|
391
401
|
if (info.isArray) {
|
|
392
402
|
// hasMany - return array
|
|
393
|
-
|
|
403
|
+
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
404
|
+
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
394
405
|
}
|
|
395
406
|
else {
|
|
396
407
|
// belongsTo - return single or null
|
|
397
|
-
data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
|
|
408
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
398
409
|
}
|
|
399
410
|
return {
|
|
400
411
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -411,11 +422,19 @@ export default class OrmRequest extends Request {
|
|
|
411
422
|
let data;
|
|
412
423
|
if (info.isArray) {
|
|
413
424
|
// hasMany - return array of linkage objects
|
|
414
|
-
|
|
425
|
+
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
426
|
+
data = related
|
|
427
|
+
.filter((r) => Boolean(r.__model))
|
|
428
|
+
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
415
429
|
}
|
|
416
430
|
else {
|
|
417
431
|
// belongsTo - return single linkage or null
|
|
418
|
-
|
|
432
|
+
if (isOrmRecord(relatedData) && relatedData.__model) {
|
|
433
|
+
data = { type: relatedData.__model.__name, id: relatedData.id };
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
data = null;
|
|
437
|
+
}
|
|
419
438
|
}
|
|
420
439
|
return {
|
|
421
440
|
links: {
|
|
@@ -19,7 +19,7 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
19
19
|
const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
|
|
20
20
|
const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
|
|
21
21
|
if (!viewDiffPrelim.hasChanges) {
|
|
22
|
-
log.db('No schema changes detected.');
|
|
22
|
+
log.db?.('No schema changes detected.');
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -59,7 +59,9 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
59
59
|
}
|
|
60
60
|
// Removed columns
|
|
61
61
|
for (const { model, column, type } of diff.removedColumns) {
|
|
62
|
-
const table = previousSnapshot[model]
|
|
62
|
+
const table = previousSnapshot[model]?.table;
|
|
63
|
+
if (!table)
|
|
64
|
+
throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
63
65
|
upStatements.push(`ALTER TABLE "${table}" DROP COLUMN "${column}";`);
|
|
64
66
|
downStatements.push(`ALTER TABLE "${table}" ADD COLUMN "${column}" ${type};`);
|
|
65
67
|
}
|
|
@@ -82,7 +84,9 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
82
84
|
}
|
|
83
85
|
// Removed foreign keys
|
|
84
86
|
for (const { model, column, references } of diff.removedForeignKeys) {
|
|
85
|
-
const table = previousSnapshot[model]
|
|
87
|
+
const table = previousSnapshot[model]?.table;
|
|
88
|
+
if (!table)
|
|
89
|
+
throw new Error(`Missing table name in snapshot for model "${model}"`);
|
|
86
90
|
const refModel = Object.entries(previousSnapshot).find(([, s]) => s.table === references.references);
|
|
87
91
|
const fkType = refModel && refModel[1].idType === 'string' ? 'VARCHAR(255)' : 'INTEGER';
|
|
88
92
|
const constraintName = `fk_${table}_${column}`;
|
|
@@ -131,7 +135,7 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
131
135
|
}
|
|
132
136
|
const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
|
|
133
137
|
if (!combinedHasChanges) {
|
|
134
|
-
log.db('No schema changes detected.');
|
|
138
|
+
log.db?.('No schema changes detected.');
|
|
135
139
|
return null;
|
|
136
140
|
}
|
|
137
141
|
// Merge view snapshot into the main snapshot
|
|
@@ -145,7 +149,7 @@ export async function generateMigration(description = 'migration', configKey = '
|
|
|
145
149
|
const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
|
|
146
150
|
await createFile(path.join(migrationsPath, filename), content);
|
|
147
151
|
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
|
|
148
|
-
log.db(`Migration generated: ${filename}`);
|
|
152
|
+
log.db?.(`Migration generated: ${filename}`);
|
|
149
153
|
return { filename, content, snapshot: combinedSnapshot };
|
|
150
154
|
}
|
|
151
155
|
export async function loadLatestSnapshot(migrationsPath) {
|