@stonyx/orm 0.2.1-alpha.15 → 0.2.1-alpha.17
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/index.md +14 -3
- package/.claude/usage-patterns.md +66 -0
- package/.claude/views.md +292 -0
- package/README.md +21 -0
- package/config/environment.js +3 -1
- package/package.json +6 -5
- package/scripts/setup-test-db.sh +21 -0
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +1 -4
- package/src/has-many.js +1 -4
- package/src/index.js +4 -1
- package/src/main.js +31 -2
- package/src/manage-record.js +11 -0
- package/src/mysql/migration-generator.js +103 -5
- package/src/mysql/mysql-db.js +55 -4
- package/src/mysql/schema-introspector.js +176 -21
- package/src/orm-request.js +12 -6
- package/src/serializer.js +7 -0
- package/src/setup-rest-server.js +1 -1
- package/src/store.js +25 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
package/src/main.js
CHANGED
|
@@ -37,6 +37,7 @@ export default class Orm {
|
|
|
37
37
|
|
|
38
38
|
models = {};
|
|
39
39
|
serializers = {};
|
|
40
|
+
views = {};
|
|
40
41
|
transforms = { ...baseTransforms };
|
|
41
42
|
warnings = new Set();
|
|
42
43
|
|
|
@@ -79,14 +80,28 @@ export default class Orm {
|
|
|
79
80
|
// Wait for imports before db & rest server setup
|
|
80
81
|
await Promise.all(promises);
|
|
81
82
|
|
|
83
|
+
// Discover views from paths.view (separate from model/serializer/transform)
|
|
84
|
+
if (paths.view) {
|
|
85
|
+
await forEachFileImport(paths.view, (exported, { name }) => {
|
|
86
|
+
const alias = `${kebabCaseToPascalCase(name)}View`;
|
|
87
|
+
Orm.store.set(name, new Map());
|
|
88
|
+
registerPluralName(name, exported);
|
|
89
|
+
this.views[alias] = exported;
|
|
90
|
+
}, { ignoreAccessFailure: true, rawName: true, recursive: true, recursiveNaming: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
82
93
|
// Setup event names for hooks after models are loaded
|
|
83
94
|
const eventNames = [];
|
|
84
95
|
const operations = ['list', 'get', 'create', 'update', 'delete'];
|
|
96
|
+
const viewOperations = ['list', 'get'];
|
|
85
97
|
const timings = ['before', 'after'];
|
|
86
98
|
|
|
87
99
|
for (const modelName of Orm.store.data.keys()) {
|
|
100
|
+
const isView = this.isView(modelName);
|
|
101
|
+
const ops = isView ? viewOperations : operations;
|
|
102
|
+
|
|
88
103
|
for (const timing of timings) {
|
|
89
|
-
for (const operation of
|
|
104
|
+
for (const operation of ops) {
|
|
90
105
|
eventNames.push(`${timing}:${operation}:${modelName}`);
|
|
91
106
|
}
|
|
92
107
|
}
|
|
@@ -141,13 +156,27 @@ export default class Orm {
|
|
|
141
156
|
|
|
142
157
|
getRecordClasses(modelName) {
|
|
143
158
|
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
144
|
-
|
|
159
|
+
|
|
160
|
+
// Check views first, then models
|
|
161
|
+
const viewClass = this.views[`${modelClassPrefix}View`];
|
|
162
|
+
if (viewClass) {
|
|
163
|
+
return {
|
|
164
|
+
modelClass: viewClass,
|
|
165
|
+
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
return {
|
|
146
170
|
modelClass: this.models[`${modelClassPrefix}Model`],
|
|
147
171
|
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
148
172
|
};
|
|
149
173
|
}
|
|
150
174
|
|
|
175
|
+
isView(modelName) {
|
|
176
|
+
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
177
|
+
return !!this.views[`${modelClassPrefix}View`];
|
|
178
|
+
}
|
|
179
|
+
|
|
151
180
|
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
152
181
|
warn(message) {
|
|
153
182
|
this.warnings.add(message);
|
package/src/manage-record.js
CHANGED
|
@@ -14,6 +14,11 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
|
|
|
14
14
|
|
|
15
15
|
if (!initialized && !options.isDbRecord) throw new Error('ORM is not ready');
|
|
16
16
|
|
|
17
|
+
// Guard: read-only views cannot have records created directly
|
|
18
|
+
if (orm?.isView?.(modelName) && !options.isDbRecord) {
|
|
19
|
+
throw new Error(`Cannot create records for read-only view '${modelName}'`);
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
const modelStore = store.get(modelName);
|
|
18
23
|
const globalRelationships = relationships.get('global');
|
|
19
24
|
const pendingRelationships = relationships.get('pending');
|
|
@@ -83,6 +88,12 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
|
|
|
83
88
|
export function updateRecord(record, rawData, userOptions={}) {
|
|
84
89
|
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
85
90
|
|
|
91
|
+
// Guard: read-only views cannot be updated
|
|
92
|
+
const modelName = record?.__model?.__name;
|
|
93
|
+
if (modelName && Orm.instance?.isView?.(modelName)) {
|
|
94
|
+
throw new Error(`Cannot update records for read-only view '${modelName}'`);
|
|
95
|
+
}
|
|
96
|
+
|
|
86
97
|
const options = { ...defaultOptions, ...userOptions, update:true };
|
|
87
98
|
|
|
88
99
|
record.serialize(rawData, options);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { introspectModels, buildTableDDL, schemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
|
|
1
|
+
import { introspectModels, introspectViews, buildTableDDL, buildViewDDL, schemasToSnapshot, viewSchemasToSnapshot, getTopologicalOrder } from './schema-introspector.js';
|
|
2
2
|
import { readFile, createFile, createDirectory, fileExists } from '@stonyx/utils/file';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import config from 'stonyx/config';
|
|
@@ -16,9 +16,18 @@ export async function generateMigration(description = 'migration') {
|
|
|
16
16
|
const previousSnapshot = await loadLatestSnapshot(migrationsPath);
|
|
17
17
|
const diff = diffSnapshots(previousSnapshot, currentSnapshot);
|
|
18
18
|
|
|
19
|
+
// Don't return early — check view changes too before deciding
|
|
19
20
|
if (!diff.hasChanges) {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Check if there are view changes before returning null
|
|
22
|
+
const viewSchemasPrelim = introspectViews();
|
|
23
|
+
const currentViewSnapshotPrelim = viewSchemasToSnapshot(viewSchemasPrelim);
|
|
24
|
+
const previousViewSnapshotPrelim = extractViewsFromSnapshot(previousSnapshot);
|
|
25
|
+
const viewDiffPrelim = diffViewSnapshots(previousViewSnapshotPrelim, currentViewSnapshotPrelim);
|
|
26
|
+
|
|
27
|
+
if (!viewDiffPrelim.hasChanges) {
|
|
28
|
+
log.db('No schema changes detected.');
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
const upStatements = [];
|
|
@@ -85,17 +94,71 @@ export async function generateMigration(description = 'migration') {
|
|
|
85
94
|
downStatements.push(`ALTER TABLE \`${table}\` ADD FOREIGN KEY (\`${column}\`) REFERENCES \`${references.references}\`(\`${references.column}\`) ON DELETE SET NULL;`);
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
// View migrations — views are created AFTER tables (dependency order)
|
|
98
|
+
const viewSchemas = introspectViews();
|
|
99
|
+
const currentViewSnapshot = viewSchemasToSnapshot(viewSchemas);
|
|
100
|
+
const previousViewSnapshot = extractViewsFromSnapshot(previousSnapshot);
|
|
101
|
+
const viewDiff = diffViewSnapshots(previousViewSnapshot, currentViewSnapshot);
|
|
102
|
+
|
|
103
|
+
if (viewDiff.hasChanges) {
|
|
104
|
+
upStatements.push('');
|
|
105
|
+
upStatements.push('-- Views');
|
|
106
|
+
downStatements.push('');
|
|
107
|
+
downStatements.push('-- Views');
|
|
108
|
+
|
|
109
|
+
// Added views
|
|
110
|
+
for (const name of viewDiff.addedViews) {
|
|
111
|
+
try {
|
|
112
|
+
const ddl = buildViewDDL(name, viewSchemas[name], schemas);
|
|
113
|
+
upStatements.push(ddl + ';');
|
|
114
|
+
downStatements.unshift(`DROP VIEW IF EXISTS \`${viewSchemas[name].viewName}\`;`);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
upStatements.push(`-- WARNING: Could not generate DDL for view '${name}': ${error.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Removed views
|
|
121
|
+
for (const name of viewDiff.removedViews) {
|
|
122
|
+
upStatements.push(`-- WARNING: View '${name}' was removed. Uncomment to drop view:`);
|
|
123
|
+
upStatements.push(`-- DROP VIEW IF EXISTS \`${previousViewSnapshot[name].viewName}\`;`);
|
|
124
|
+
downStatements.push(`-- Recreate view for removed view '${name}' manually if needed`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Changed views (source or aggregates changed)
|
|
128
|
+
for (const name of viewDiff.changedViews) {
|
|
129
|
+
try {
|
|
130
|
+
const ddl = buildViewDDL(name, viewSchemas[name], schemas);
|
|
131
|
+
upStatements.push(ddl + ';');
|
|
132
|
+
} catch (error) {
|
|
133
|
+
upStatements.push(`-- WARNING: Could not generate DDL for changed view '${name}': ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const combinedHasChanges = diff.hasChanges || viewDiff.hasChanges;
|
|
139
|
+
|
|
140
|
+
if (!combinedHasChanges) {
|
|
141
|
+
log.db('No schema changes detected.');
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Merge view snapshot into the main snapshot
|
|
146
|
+
const combinedSnapshot = { ...currentSnapshot };
|
|
147
|
+
for (const [name, viewSnap] of Object.entries(currentViewSnapshot)) {
|
|
148
|
+
combinedSnapshot[name] = viewSnap;
|
|
149
|
+
}
|
|
150
|
+
|
|
88
151
|
const sanitizedDescription = description.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
|
89
152
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
90
153
|
const filename = `${timestamp}_${sanitizedDescription}.sql`;
|
|
91
154
|
const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
|
|
92
155
|
|
|
93
156
|
await createFile(path.join(migrationsPath, filename), content);
|
|
94
|
-
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(
|
|
157
|
+
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(combinedSnapshot, null, 2));
|
|
95
158
|
|
|
96
159
|
log.db(`Migration generated: ${filename}`);
|
|
97
160
|
|
|
98
|
-
return { filename, content, snapshot:
|
|
161
|
+
return { filename, content, snapshot: combinedSnapshot };
|
|
99
162
|
}
|
|
100
163
|
|
|
101
164
|
export async function loadLatestSnapshot(migrationsPath) {
|
|
@@ -186,3 +249,38 @@ export function detectSchemaDrift(schemas, snapshot) {
|
|
|
186
249
|
const current = schemasToSnapshot(schemas);
|
|
187
250
|
return diffSnapshots(snapshot, current);
|
|
188
251
|
}
|
|
252
|
+
|
|
253
|
+
export function extractViewsFromSnapshot(snapshot) {
|
|
254
|
+
const views = {};
|
|
255
|
+
for (const [name, entry] of Object.entries(snapshot)) {
|
|
256
|
+
if (entry.isView) views[name] = entry;
|
|
257
|
+
}
|
|
258
|
+
return views;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function diffViewSnapshots(previous, current) {
|
|
262
|
+
const addedViews = [];
|
|
263
|
+
const removedViews = [];
|
|
264
|
+
const changedViews = [];
|
|
265
|
+
|
|
266
|
+
for (const name of Object.keys(current)) {
|
|
267
|
+
if (!previous[name]) {
|
|
268
|
+
addedViews.push(name);
|
|
269
|
+
} else if (
|
|
270
|
+
current[name].viewQuery !== previous[name].viewQuery ||
|
|
271
|
+
current[name].source !== previous[name].source
|
|
272
|
+
) {
|
|
273
|
+
changedViews.push(name);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const name of Object.keys(previous)) {
|
|
278
|
+
if (!current[name]) {
|
|
279
|
+
removedViews.push(name);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const hasChanges = addedViews.length > 0 || removedViews.length > 0 || changedViews.length > 0;
|
|
284
|
+
|
|
285
|
+
return { hasChanges, addedViews, removedViews, changedViews };
|
|
286
|
+
}
|
package/src/mysql/mysql-db.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getPool, closePool } from './connection.js';
|
|
2
2
|
import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } from './migration-runner.js';
|
|
3
|
-
import { introspectModels, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
|
|
3
|
+
import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
|
|
4
4
|
import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
|
|
5
5
|
import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
|
|
6
6
|
import { createRecord, store } from '@stonyx/orm';
|
|
@@ -14,7 +14,7 @@ import path from 'path';
|
|
|
14
14
|
const defaultDeps = {
|
|
15
15
|
getPool, closePool, ensureMigrationsTable, getAppliedMigrations,
|
|
16
16
|
getMigrationFiles, applyMigration, parseMigrationFile,
|
|
17
|
-
introspectModels, getTopologicalOrder, schemasToSnapshot,
|
|
17
|
+
introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
|
|
18
18
|
loadLatestSnapshot, detectSchemaDrift,
|
|
19
19
|
buildInsert, buildUpdate, buildDelete, buildSelect,
|
|
20
20
|
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
@@ -148,6 +148,35 @@ export default class MysqlDB {
|
|
|
148
148
|
throw error;
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
|
|
152
|
+
// Load views with memory: true
|
|
153
|
+
const viewSchemas = this.deps.introspectViews();
|
|
154
|
+
|
|
155
|
+
for (const [viewName, viewSchema] of Object.entries(viewSchemas)) {
|
|
156
|
+
const { modelClass: viewClass } = Orm.instance.getRecordClasses(viewName);
|
|
157
|
+
if (viewClass?.memory !== true) {
|
|
158
|
+
this.deps.log.db(`Skipping memory load for view '${viewName}' (memory: false)`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
|
|
163
|
+
const { sql, values } = this.deps.buildSelect(schema.table);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const [rows] = await this.pool.execute(sql, values);
|
|
167
|
+
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const rawData = this._rowToRawData(row, schema);
|
|
170
|
+
this.deps.createRecord(viewName, rawData, { isDbRecord: true, serialize: false, transform: false });
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code === 'ER_NO_SUCH_TABLE') {
|
|
174
|
+
this.deps.log.db(`View '${viewSchema.viewName}' does not exist yet. Skipping load for '${viewName}'.`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
151
180
|
}
|
|
152
181
|
|
|
153
182
|
/**
|
|
@@ -166,7 +195,16 @@ export default class MysqlDB {
|
|
|
166
195
|
*/
|
|
167
196
|
async findRecord(modelName, id) {
|
|
168
197
|
const schemas = this.deps.introspectModels();
|
|
169
|
-
|
|
198
|
+
let schema = schemas[modelName];
|
|
199
|
+
|
|
200
|
+
// Check views if not found in models
|
|
201
|
+
if (!schema) {
|
|
202
|
+
const viewSchemas = this.deps.introspectViews();
|
|
203
|
+
const viewSchema = viewSchemas[modelName];
|
|
204
|
+
if (viewSchema) {
|
|
205
|
+
schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
170
208
|
|
|
171
209
|
if (!schema) return undefined;
|
|
172
210
|
|
|
@@ -199,7 +237,16 @@ export default class MysqlDB {
|
|
|
199
237
|
*/
|
|
200
238
|
async findAll(modelName, conditions) {
|
|
201
239
|
const schemas = this.deps.introspectModels();
|
|
202
|
-
|
|
240
|
+
let schema = schemas[modelName];
|
|
241
|
+
|
|
242
|
+
// Check views if not found in models
|
|
243
|
+
if (!schema) {
|
|
244
|
+
const viewSchemas = this.deps.introspectViews();
|
|
245
|
+
const viewSchema = viewSchemas[modelName];
|
|
246
|
+
if (viewSchema) {
|
|
247
|
+
schema = { table: viewSchema.viewName, columns: viewSchema.columns || {}, foreignKeys: viewSchema.foreignKeys || {} };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
203
250
|
|
|
204
251
|
if (!schema) return [];
|
|
205
252
|
|
|
@@ -277,6 +324,10 @@ export default class MysqlDB {
|
|
|
277
324
|
}
|
|
278
325
|
|
|
279
326
|
async persist(operation, modelName, context, response) {
|
|
327
|
+
// Views are read-only — no-op for all write operations
|
|
328
|
+
const Orm = (await import('@stonyx/orm')).default;
|
|
329
|
+
if (Orm.instance?.isView?.(modelName)) return;
|
|
330
|
+
|
|
280
331
|
switch (operation) {
|
|
281
332
|
case 'create':
|
|
282
333
|
return this._persistCreate(modelName, context, response);
|
|
@@ -3,22 +3,18 @@ import { getMysqlType } from './type-map.js';
|
|
|
3
3
|
import { camelCaseToKebabCase } from '@stonyx/utils/string';
|
|
4
4
|
import { getPluralName } from '../plural-registry.js';
|
|
5
5
|
import { dbKey } from '../db.js';
|
|
6
|
+
import { AggregateProperty } from '../aggregates.js';
|
|
6
7
|
|
|
7
8
|
function getRelationshipInfo(property) {
|
|
8
9
|
if (typeof property !== 'function') return null;
|
|
9
10
|
const fnStr = property.toString();
|
|
10
|
-
const modelName = property.__relatedModelName || null;
|
|
11
11
|
|
|
12
|
-
if (fnStr.includes(`getRelationships('belongsTo',`)) return
|
|
13
|
-
if (fnStr.includes(`getRelationships('hasMany',`)) return
|
|
12
|
+
if (fnStr.includes(`getRelationships('belongsTo',`)) return 'belongsTo';
|
|
13
|
+
if (fnStr.includes(`getRelationships('hasMany',`)) return 'hasMany';
|
|
14
14
|
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
function sanitizeTableName(name) {
|
|
19
|
-
return name.replace(/\//g, '_');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
18
|
export function introspectModels() {
|
|
23
19
|
const { models } = Orm.instance;
|
|
24
20
|
const schemas = {};
|
|
@@ -39,12 +35,12 @@ export function introspectModels() {
|
|
|
39
35
|
for (const [key, property] of Object.entries(model)) {
|
|
40
36
|
if (key.startsWith('__')) continue;
|
|
41
37
|
|
|
42
|
-
const
|
|
38
|
+
const relType = getRelationshipInfo(property);
|
|
43
39
|
|
|
44
|
-
if (
|
|
45
|
-
relationships.belongsTo[key] =
|
|
46
|
-
} else if (
|
|
47
|
-
relationships.hasMany[key] =
|
|
40
|
+
if (relType === 'belongsTo') {
|
|
41
|
+
relationships.belongsTo[key] = true;
|
|
42
|
+
} else if (relType === 'hasMany') {
|
|
43
|
+
relationships.hasMany[key] = true;
|
|
48
44
|
} else if (property?.constructor?.name === 'ModelProperty') {
|
|
49
45
|
if (key === 'id') {
|
|
50
46
|
idType = property.type;
|
|
@@ -55,16 +51,17 @@ export function introspectModels() {
|
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
// Build foreign keys from belongsTo relationships
|
|
58
|
-
for (const
|
|
54
|
+
for (const relName of Object.keys(relationships.belongsTo)) {
|
|
55
|
+
const modelName = camelCaseToKebabCase(relName);
|
|
59
56
|
const fkColumn = `${relName}_id`;
|
|
60
57
|
foreignKeys[fkColumn] = {
|
|
61
|
-
references:
|
|
58
|
+
references: getPluralName(modelName),
|
|
62
59
|
column: 'id',
|
|
63
60
|
};
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
schemas[name] = {
|
|
67
|
-
table:
|
|
64
|
+
table: getPluralName(name),
|
|
68
65
|
idType,
|
|
69
66
|
columns,
|
|
70
67
|
foreignKeys,
|
|
@@ -77,8 +74,7 @@ export function introspectModels() {
|
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
80
|
-
const { idType, columns, foreignKeys } = schema;
|
|
81
|
-
const table = sanitizeTableName(schema.table);
|
|
77
|
+
const { table, idType, columns, foreignKeys } = schema;
|
|
82
78
|
const lines = [];
|
|
83
79
|
|
|
84
80
|
// Primary key
|
|
@@ -105,8 +101,7 @@ export function buildTableDDL(name, schema, allSchemas = {}) {
|
|
|
105
101
|
|
|
106
102
|
// Foreign key constraints
|
|
107
103
|
for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
|
|
108
|
-
|
|
109
|
-
lines.push(` FOREIGN KEY (\`${fkCol}\`) REFERENCES \`${refTable}\`(\`${fkDef.column}\`) ON DELETE SET NULL`);
|
|
104
|
+
lines.push(` FOREIGN KEY (\`${fkCol}\`) REFERENCES \`${fkDef.references}\`(\`${fkDef.column}\`) ON DELETE SET NULL`);
|
|
110
105
|
}
|
|
111
106
|
|
|
112
107
|
return `CREATE TABLE IF NOT EXISTS \`${table}\` (\n${lines.join(',\n')}\n)`;
|
|
@@ -136,8 +131,8 @@ export function getTopologicalOrder(schemas) {
|
|
|
136
131
|
if (!schema) return;
|
|
137
132
|
|
|
138
133
|
// Visit dependencies (belongsTo targets) first
|
|
139
|
-
for (const
|
|
140
|
-
visit(
|
|
134
|
+
for (const relName of Object.keys(schema.relationships.belongsTo)) {
|
|
135
|
+
visit(camelCaseToKebabCase(relName));
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
order.push(name);
|
|
@@ -150,6 +145,166 @@ export function getTopologicalOrder(schemas) {
|
|
|
150
145
|
return order;
|
|
151
146
|
}
|
|
152
147
|
|
|
148
|
+
export function introspectViews() {
|
|
149
|
+
const orm = Orm.instance;
|
|
150
|
+
if (!orm.views) return {};
|
|
151
|
+
|
|
152
|
+
const schemas = {};
|
|
153
|
+
|
|
154
|
+
for (const [viewKey, viewClass] of Object.entries(orm.views)) {
|
|
155
|
+
const name = camelCaseToKebabCase(viewKey.slice(0, -4)); // Remove 'View' suffix
|
|
156
|
+
|
|
157
|
+
const source = viewClass.source;
|
|
158
|
+
if (!source) continue;
|
|
159
|
+
|
|
160
|
+
const model = new viewClass(name);
|
|
161
|
+
const columns = {};
|
|
162
|
+
const foreignKeys = {};
|
|
163
|
+
const aggregates = {};
|
|
164
|
+
const relationships = { belongsTo: {}, hasMany: {} };
|
|
165
|
+
|
|
166
|
+
for (const [key, property] of Object.entries(model)) {
|
|
167
|
+
if (key.startsWith('__')) continue;
|
|
168
|
+
if (key === 'id') continue;
|
|
169
|
+
|
|
170
|
+
if (property instanceof AggregateProperty) {
|
|
171
|
+
aggregates[key] = property;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const relType = getRelationshipInfo(property);
|
|
176
|
+
|
|
177
|
+
if (relType === 'belongsTo') {
|
|
178
|
+
relationships.belongsTo[key] = true;
|
|
179
|
+
const modelName = camelCaseToKebabCase(key);
|
|
180
|
+
const fkColumn = `${key}_id`;
|
|
181
|
+
foreignKeys[fkColumn] = {
|
|
182
|
+
references: getPluralName(modelName),
|
|
183
|
+
column: 'id',
|
|
184
|
+
};
|
|
185
|
+
} else if (relType === 'hasMany') {
|
|
186
|
+
relationships.hasMany[key] = true;
|
|
187
|
+
} else if (property?.constructor?.name === 'ModelProperty') {
|
|
188
|
+
const transforms = Orm.instance.transforms;
|
|
189
|
+
columns[key] = getMysqlType(property.type, transforms[property.type]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
schemas[name] = {
|
|
194
|
+
viewName: getPluralName(name),
|
|
195
|
+
source,
|
|
196
|
+
groupBy: viewClass.groupBy || undefined,
|
|
197
|
+
columns,
|
|
198
|
+
foreignKeys,
|
|
199
|
+
aggregates,
|
|
200
|
+
relationships,
|
|
201
|
+
isView: true,
|
|
202
|
+
memory: viewClass.memory !== false ? false : false, // Views default to memory:false
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return schemas;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
|
|
210
|
+
if (!viewSchema.source) {
|
|
211
|
+
throw new Error(`View '${name}' must define a source model`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const sourceModelName = viewSchema.source;
|
|
215
|
+
const sourceSchema = modelSchemas[sourceModelName];
|
|
216
|
+
const sourceTable = sourceSchema
|
|
217
|
+
? sourceSchema.table
|
|
218
|
+
: getPluralName(sourceModelName);
|
|
219
|
+
|
|
220
|
+
const selectColumns = [];
|
|
221
|
+
const joins = [];
|
|
222
|
+
const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
|
|
223
|
+
const groupByField = viewSchema.groupBy;
|
|
224
|
+
|
|
225
|
+
// ID column: groupBy field or source table PK
|
|
226
|
+
if (groupByField) {
|
|
227
|
+
selectColumns.push(`\`${sourceTable}\`.\`${groupByField}\` AS \`id\``);
|
|
228
|
+
} else {
|
|
229
|
+
selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Aggregate columns
|
|
233
|
+
for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
|
|
234
|
+
if (aggProp.relationship === undefined) {
|
|
235
|
+
// Field-level aggregate (groupBy views)
|
|
236
|
+
if (aggProp.aggregateType === 'count') {
|
|
237
|
+
selectColumns.push(`COUNT(*) AS \`${key}\``);
|
|
238
|
+
} else {
|
|
239
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${sourceTable}\`.\`${aggProp.field}\`) AS \`${key}\``);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Relationship aggregate
|
|
243
|
+
const relName = aggProp.relationship;
|
|
244
|
+
const relModelName = camelCaseToKebabCase(relName);
|
|
245
|
+
const relTable = getPluralName(relModelName);
|
|
246
|
+
|
|
247
|
+
if (aggProp.aggregateType === 'count') {
|
|
248
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`id\`) AS \`${key}\``);
|
|
249
|
+
} else {
|
|
250
|
+
const field = aggProp.field;
|
|
251
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Add LEFT JOIN for the relationship if not already added
|
|
255
|
+
const joinKey = `${relTable}`;
|
|
256
|
+
if (!joins.find(j => j.table === joinKey)) {
|
|
257
|
+
const fkColumn = `${sourceModelName}_id`;
|
|
258
|
+
joins.push({
|
|
259
|
+
table: relTable,
|
|
260
|
+
condition: `\`${relTable}\`.\`${fkColumn}\` = \`${sourceTable}\`.\`id\``
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Regular columns (from resolve map string paths or direct attr fields)
|
|
267
|
+
for (const [key, mysqlType] of Object.entries(viewSchema.columns || {})) {
|
|
268
|
+
selectColumns.push(`\`${sourceTable}\`.\`${key}\` AS \`${key}\``);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Build JOIN clauses
|
|
272
|
+
const joinClauses = joins.map(j =>
|
|
273
|
+
`LEFT JOIN \`${j.table}\` ON ${j.condition}`
|
|
274
|
+
).join('\n ');
|
|
275
|
+
|
|
276
|
+
// Build GROUP BY
|
|
277
|
+
let groupBy = '';
|
|
278
|
+
if (groupByField) {
|
|
279
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`${groupByField}\``;
|
|
280
|
+
} else if (hasAggregates) {
|
|
281
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`id\``;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const viewName = viewSchema.viewName;
|
|
285
|
+
const sql = `CREATE OR REPLACE VIEW \`${viewName}\` AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM \`${sourceTable}\`${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
|
|
286
|
+
|
|
287
|
+
return sql;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function viewSchemasToSnapshot(viewSchemas) {
|
|
291
|
+
const snapshot = {};
|
|
292
|
+
|
|
293
|
+
for (const [name, schema] of Object.entries(viewSchemas)) {
|
|
294
|
+
snapshot[name] = {
|
|
295
|
+
viewName: schema.viewName,
|
|
296
|
+
source: schema.source,
|
|
297
|
+
...(schema.groupBy ? { groupBy: schema.groupBy } : {}),
|
|
298
|
+
columns: { ...schema.columns },
|
|
299
|
+
foreignKeys: { ...schema.foreignKeys },
|
|
300
|
+
isView: true,
|
|
301
|
+
viewQuery: buildViewDDL(name, schema),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return snapshot;
|
|
306
|
+
}
|
|
307
|
+
|
|
153
308
|
export function schemasToSnapshot(schemas) {
|
|
154
309
|
const snapshot = {};
|
|
155
310
|
|
package/src/orm-request.js
CHANGED
|
@@ -323,21 +323,27 @@ export default class OrmRequest extends Request {
|
|
|
323
323
|
};
|
|
324
324
|
|
|
325
325
|
// Wrap handlers with hooks
|
|
326
|
+
const isView = Orm.instance?.isView?.(model);
|
|
327
|
+
|
|
326
328
|
this.handlers = {
|
|
327
329
|
get: {
|
|
328
330
|
'/': this._withHooks('list', getCollectionHandler),
|
|
329
331
|
'/:id': this._withHooks('get', getSingleHandler),
|
|
330
332
|
...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
|
|
331
333
|
},
|
|
332
|
-
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Views are read-only — no write endpoints
|
|
337
|
+
if (!isView) {
|
|
338
|
+
this.handlers.patch = {
|
|
333
339
|
'/:id': this._withHooks('update', updateHandler)
|
|
334
|
-
}
|
|
335
|
-
post
|
|
340
|
+
};
|
|
341
|
+
this.handlers.post = {
|
|
336
342
|
'/': this._withHooks('create', createHandler)
|
|
337
|
-
}
|
|
338
|
-
delete
|
|
343
|
+
};
|
|
344
|
+
this.handlers.delete = {
|
|
339
345
|
'/:id': this._withHooks('delete', deleteHandler)
|
|
340
|
-
}
|
|
346
|
+
};
|
|
341
347
|
}
|
|
342
348
|
}
|
|
343
349
|
|
package/src/serializer.js
CHANGED
|
@@ -86,6 +86,13 @@ export default class Serializer {
|
|
|
86
86
|
continue;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Aggregate property handling — use the rawData value, not the aggregate descriptor
|
|
90
|
+
if (handler?.constructor?.name === 'AggregateProperty') {
|
|
91
|
+
parsedData[key] = data;
|
|
92
|
+
record[key] = data;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
89
96
|
// Direct assignment handling
|
|
90
97
|
if (handler?.constructor?.name !== 'ModelProperty') {
|
|
91
98
|
parsedData[key] = handler;
|
package/src/setup-rest-server.js
CHANGED
|
@@ -41,7 +41,7 @@ export default async function(route, accessPath, metaRoute) {
|
|
|
41
41
|
// Remove "/" prefix and name mount point accordingly
|
|
42
42
|
const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
|
|
43
43
|
|
|
44
|
-
// Configure endpoints for models with access configuration
|
|
44
|
+
// Configure endpoints for models and views with access configuration
|
|
45
45
|
for (const [model, access] of Object.entries(accessFiles)) {
|
|
46
46
|
const pluralizedModel = getPluralName(model);
|
|
47
47
|
const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
|