@stonyx/orm 0.2.1-beta.86 → 0.2.1-beta.88
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 +4 -4
- package/dist/hooks.js +6 -2
- package/dist/main.js +3 -1
- package/dist/manage-record.js +6 -2
- package/dist/mysql/migration-generator.js +15 -6
- package/dist/mysql/migration-runner.js +5 -0
- package/dist/mysql/mysql-db.js +24 -14
- package/dist/mysql/schema-introspector.js +11 -6
- package/dist/orm-request.js +19 -11
- package/dist/postgres/connection.js +3 -1
- package/dist/postgres/migration-generator.js +9 -5
- package/dist/postgres/migration-runner.js +5 -0
- package/dist/postgres/postgres-db.js +14 -13
- package/dist/postgres/schema-introspector.js +11 -6
- package/dist/postgres/type-map.js +3 -0
- package/dist/relationships.js +2 -0
- package/dist/setup-rest-server.js +4 -6
- package/dist/store.js +2 -0
- package/dist/timescale/query-builder.d.ts +2 -0
- package/dist/timescale/query-builder.js +30 -2
- package/dist/utils.js +2 -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 +4 -4
- package/src/hooks.ts +4 -2
- package/src/main.ts +3 -2
- package/src/manage-record.ts +5 -2
- package/src/mysql/migration-generator.ts +12 -7
- package/src/mysql/migration-runner.ts +5 -0
- package/src/mysql/mysql-db.ts +23 -17
- package/src/mysql/schema-introspector.ts +10 -7
- package/src/orm-request.ts +19 -12
- package/src/postgres/connection.ts +3 -1
- package/src/postgres/migration-generator.ts +7 -5
- package/src/postgres/migration-runner.ts +5 -0
- package/src/postgres/postgres-db.ts +14 -13
- package/src/postgres/schema-introspector.ts +10 -7
- package/src/postgres/type-map.ts +4 -0
- package/src/relationships.ts +3 -2
- package/src/setup-rest-server.ts +7 -10
- package/src/store.ts +2 -1
- package/src/timescale/query-builder.ts +39 -2
- package/src/utils.ts +2 -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
|
@@ -66,7 +66,7 @@ export default class DB {
|
|
|
66
66
|
const data = await readFile(dbFilePath, { json: true });
|
|
67
67
|
const hasData = collectionKeys.some(key => Array.isArray(data[key]) && data[key].length > 0);
|
|
68
68
|
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`);
|
|
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`);
|
|
70
70
|
process.exit(1);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -76,7 +76,7 @@ export default class DB {
|
|
|
76
76
|
if (dirExists) {
|
|
77
77
|
const hasCollectionFiles = (await Promise.all(collectionKeys.map(key => fileExists(path.join(dirPath, `${key}.json`))))).some(Boolean);
|
|
78
78
|
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`);
|
|
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`);
|
|
80
80
|
process.exit(1);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -138,11 +138,11 @@ export default class DB {
|
|
|
138
138
|
await updateFile(dbFilePath, skeleton, { json: true });
|
|
139
139
|
else
|
|
140
140
|
await createFile(dbFilePath, skeleton, { json: true });
|
|
141
|
-
log.db(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
141
|
+
log.db?.(`DB has been successfully saved to ${config.orm.db.directory}/ directory`);
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
await updateFile(`${config.rootPath}/${file}`, jsonData, { json: true });
|
|
145
|
-
log.db(`DB has been successfully saved to ${file}`);
|
|
145
|
+
log.db?.(`DB has been successfully saved to ${file}`);
|
|
146
146
|
}
|
|
147
147
|
async getRecord() {
|
|
148
148
|
const { mode } = config.orm.db;
|
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
|
@@ -105,8 +105,12 @@ function assignRecordId(modelName, rawData) {
|
|
|
105
105
|
rawData.__pendingSqlId = true;
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
108
|
+
const storeMap = store.get(modelName);
|
|
109
|
+
if (!storeMap)
|
|
110
|
+
throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
111
|
+
const modelStore = Array.from(storeMap.values());
|
|
112
|
+
const lastRecord = modelStore.at(-1);
|
|
113
|
+
rawData.id = lastRecord ? lastRecord.id + 1 : 1;
|
|
110
114
|
}
|
|
111
115
|
function isStringIdModel(modelName) {
|
|
112
116
|
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) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fileExists } from '@stonyx/utils/file';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
+
import { validateIdentifier } from './query-builder.js';
|
|
3
4
|
export async function ensureMigrationsTable(pool, tableName = '__migrations') {
|
|
5
|
+
validateIdentifier(tableName, 'migration table name');
|
|
4
6
|
await pool.execute(`
|
|
5
7
|
CREATE TABLE IF NOT EXISTS \`${tableName}\` (
|
|
6
8
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
@@ -10,6 +12,7 @@ export async function ensureMigrationsTable(pool, tableName = '__migrations') {
|
|
|
10
12
|
`);
|
|
11
13
|
}
|
|
12
14
|
export async function getAppliedMigrations(pool, tableName = '__migrations') {
|
|
15
|
+
validateIdentifier(tableName, 'migration table name');
|
|
13
16
|
const [rows] = await pool.execute(`SELECT filename FROM \`${tableName}\` ORDER BY id ASC`);
|
|
14
17
|
return rows.map(row => row.filename);
|
|
15
18
|
}
|
|
@@ -37,6 +40,7 @@ export function parseMigrationFile(content) {
|
|
|
37
40
|
return { up, down };
|
|
38
41
|
}
|
|
39
42
|
export async function applyMigration(pool, filename, upSql, tableName = '__migrations') {
|
|
43
|
+
validateIdentifier(tableName, 'migration table name');
|
|
40
44
|
const connection = await pool.getConnection();
|
|
41
45
|
try {
|
|
42
46
|
await connection.beginTransaction();
|
|
@@ -57,6 +61,7 @@ export async function applyMigration(pool, filename, upSql, tableName = '__migra
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
export async function rollbackMigration(pool, filename, downSql, tableName = '__migrations') {
|
|
64
|
+
validateIdentifier(tableName, 'migration table name');
|
|
60
65
|
const connection = await pool.getConnection();
|
|
61
66
|
try {
|
|
62
67
|
await connection.beginTransaction();
|
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
|
@@ -69,7 +69,7 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
|
|
|
69
69
|
return response;
|
|
70
70
|
const includedRecords = collectIncludedRecords(recordOrRecords, includes);
|
|
71
71
|
if (includedRecords.length > 0) {
|
|
72
|
-
response.included = includedRecords.map(record => record.toJSON({ baseUrl }));
|
|
72
|
+
response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
|
|
73
73
|
}
|
|
74
74
|
return response;
|
|
75
75
|
}
|
|
@@ -96,6 +96,8 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
|
|
|
96
96
|
for (const relatedRecord of recordsToProcess) {
|
|
97
97
|
if (!relatedRecord)
|
|
98
98
|
continue;
|
|
99
|
+
if (!relatedRecord.__model)
|
|
100
|
+
continue;
|
|
99
101
|
const type = relatedRecord.__model.__name;
|
|
100
102
|
const id = relatedRecord.id;
|
|
101
103
|
// Initialize Set for this type if needed
|
|
@@ -207,7 +209,7 @@ export default class OrmRequest extends Request {
|
|
|
207
209
|
if (queryFilterPredicate)
|
|
208
210
|
recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
|
|
209
211
|
const baseUrl = getBaseUrl(request);
|
|
210
|
-
const data = recordsToReturn.map(record => record.toJSON({ fields: modelFields, baseUrl }));
|
|
212
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
|
|
211
213
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
212
214
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
213
215
|
baseUrl
|
|
@@ -220,7 +222,7 @@ export default class OrmRequest extends Request {
|
|
|
220
222
|
const fieldsMap = parseFields(request.query);
|
|
221
223
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
222
224
|
const baseUrl = getBaseUrl(request);
|
|
223
|
-
return buildResponse(record.toJSON({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
225
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
|
|
224
226
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
225
227
|
baseUrl
|
|
226
228
|
});
|
|
@@ -246,7 +248,7 @@ export default class OrmRequest extends Request {
|
|
|
246
248
|
}
|
|
247
249
|
const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
|
|
248
250
|
const record = createRecord(model, recordAttributes, { serialize: false });
|
|
249
|
-
return { data: record.toJSON({ fields: modelFields }) };
|
|
251
|
+
return { data: record.toJSON?.({ fields: modelFields }) };
|
|
250
252
|
};
|
|
251
253
|
const updateHandler = async ({ body, params }) => {
|
|
252
254
|
const record = await store.find(model, getId(params));
|
|
@@ -277,7 +279,7 @@ export default class OrmRequest extends Request {
|
|
|
277
279
|
updateRecord(record, relUpdates);
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
|
-
return { data: record.toJSON() };
|
|
282
|
+
return { data: record.toJSON?.() };
|
|
281
283
|
};
|
|
282
284
|
const deleteHandler = ({ params }) => {
|
|
283
285
|
store.remove(model, getId(params));
|
|
@@ -340,8 +342,9 @@ export default class OrmRequest extends Request {
|
|
|
340
342
|
// Execute main handler
|
|
341
343
|
const response = await handler(request, state);
|
|
342
344
|
// Persist to SQL database for write operations
|
|
343
|
-
|
|
344
|
-
|
|
345
|
+
const sqlDb = Orm.instance.sqlDb;
|
|
346
|
+
if (sqlDb && WRITE_OPERATIONS.has(operation)) {
|
|
347
|
+
await sqlDb.persist(operation, this.model, context, response);
|
|
345
348
|
}
|
|
346
349
|
// Add response and relevant records to context
|
|
347
350
|
context.response = response;
|
|
@@ -390,11 +393,11 @@ export default class OrmRequest extends Request {
|
|
|
390
393
|
let data;
|
|
391
394
|
if (info.isArray) {
|
|
392
395
|
// hasMany - return array
|
|
393
|
-
data = (relatedData || []).map(r => r.toJSON({ baseUrl }));
|
|
396
|
+
data = (relatedData || []).map(r => r.toJSON?.({ baseUrl }));
|
|
394
397
|
}
|
|
395
398
|
else {
|
|
396
399
|
// belongsTo - return single or null
|
|
397
|
-
data = relatedData ? relatedData.toJSON({ baseUrl }) : null;
|
|
400
|
+
data = relatedData ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
398
401
|
}
|
|
399
402
|
return {
|
|
400
403
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
|
|
@@ -411,11 +414,16 @@ export default class OrmRequest extends Request {
|
|
|
411
414
|
let data;
|
|
412
415
|
if (info.isArray) {
|
|
413
416
|
// hasMany - return array of linkage objects
|
|
414
|
-
data = (relatedData || [])
|
|
417
|
+
data = (relatedData || [])
|
|
418
|
+
.filter((r) => Boolean(r.__model))
|
|
419
|
+
.map(r => ({ type: r.__model.__name, id: r.id }));
|
|
415
420
|
}
|
|
416
421
|
else {
|
|
417
422
|
// belongsTo - return single linkage or null
|
|
418
|
-
|
|
423
|
+
const model = relatedData ? relatedData.__model : undefined;
|
|
424
|
+
data = model
|
|
425
|
+
? { type: model.__name, id: relatedData.id }
|
|
426
|
+
: null;
|
|
419
427
|
}
|
|
420
428
|
return {
|
|
421
429
|
links: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateIdentifier } from './query-builder.js';
|
|
1
2
|
let pool = null;
|
|
2
3
|
/**
|
|
3
4
|
* Create or return the singleton pg Pool.
|
|
@@ -18,7 +19,8 @@ export async function getPool(pgConfig, extensions = ['vector']) {
|
|
|
18
19
|
});
|
|
19
20
|
// Enable requested PostgreSQL extensions
|
|
20
21
|
for (const ext of extensions) {
|
|
21
|
-
|
|
22
|
+
validateIdentifier(ext, 'extension name');
|
|
23
|
+
await pool.query(`CREATE EXTENSION IF NOT EXISTS "${ext}"`);
|
|
22
24
|
}
|
|
23
25
|
return pool;
|
|
24
26
|
}
|
|
@@ -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) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fileExists } from '@stonyx/utils/file';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
+
import { validateIdentifier } from './query-builder.js';
|
|
3
4
|
export async function ensureMigrationsTable(pool, tableName = '__migrations') {
|
|
5
|
+
validateIdentifier(tableName, 'migration table name');
|
|
4
6
|
await pool.query(`
|
|
5
7
|
CREATE TABLE IF NOT EXISTS "${tableName}" (
|
|
6
8
|
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
@@ -10,6 +12,7 @@ export async function ensureMigrationsTable(pool, tableName = '__migrations') {
|
|
|
10
12
|
`);
|
|
11
13
|
}
|
|
12
14
|
export async function getAppliedMigrations(pool, tableName = '__migrations') {
|
|
15
|
+
validateIdentifier(tableName, 'migration table name');
|
|
13
16
|
const result = await pool.query(`SELECT filename FROM "${tableName}" ORDER BY id ASC`);
|
|
14
17
|
return result.rows.map(row => row.filename);
|
|
15
18
|
}
|
|
@@ -37,6 +40,7 @@ export function parseMigrationFile(content) {
|
|
|
37
40
|
return { up, down };
|
|
38
41
|
}
|
|
39
42
|
export async function applyMigration(pool, filename, upSql, tableName = '__migrations') {
|
|
43
|
+
validateIdentifier(tableName, 'migration table name');
|
|
40
44
|
const client = await pool.connect();
|
|
41
45
|
try {
|
|
42
46
|
await client.query('BEGIN');
|
|
@@ -56,6 +60,7 @@ export async function applyMigration(pool, filename, upSql, tableName = '__migra
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
export async function rollbackMigration(pool, filename, downSql, tableName = '__migrations') {
|
|
63
|
+
validateIdentifier(tableName, 'migration table name');
|
|
59
64
|
const client = await pool.connect();
|
|
60
65
|
try {
|
|
61
66
|
await client.query('BEGIN');
|