@stonyx/orm 0.2.1-beta.30 → 0.2.1-beta.32
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/config/environment.js +3 -1
- package/package.json +6 -2
- package/src/aggregates.js +93 -0
- 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 +161 -0
- 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
|
@@ -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,6 +3,7 @@ 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;
|
|
@@ -144,6 +145,166 @@ export function getTopologicalOrder(schemas) {
|
|
|
144
145
|
return order;
|
|
145
146
|
}
|
|
146
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
|
+
|
|
147
308
|
export function schemasToSnapshot(schemas) {
|
|
148
309
|
const snapshot = {};
|
|
149
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}`;
|
package/src/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { relationships } from '@stonyx/orm';
|
|
1
|
+
import Orm, { relationships } from '@stonyx/orm';
|
|
2
2
|
import { TYPES } from './relationships.js';
|
|
3
|
+
import ViewResolver from './view-resolver.js';
|
|
3
4
|
|
|
4
5
|
export default class Store {
|
|
5
6
|
constructor() {
|
|
@@ -28,6 +29,12 @@ export default class Store {
|
|
|
28
29
|
* @returns {Promise<Record|undefined>}
|
|
29
30
|
*/
|
|
30
31
|
async find(modelName, id) {
|
|
32
|
+
// For views in non-MySQL mode, use view resolver
|
|
33
|
+
if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
|
|
34
|
+
const resolver = new ViewResolver(modelName);
|
|
35
|
+
return resolver.resolveOne(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
// For memory: true models, the store is authoritative
|
|
32
39
|
if (this._isMemoryModel(modelName)) {
|
|
33
40
|
return this.get(modelName, id);
|
|
@@ -50,6 +57,18 @@ export default class Store {
|
|
|
50
57
|
* @returns {Promise<Record[]>}
|
|
51
58
|
*/
|
|
52
59
|
async findAll(modelName, conditions) {
|
|
60
|
+
// For views in non-MySQL mode, use view resolver
|
|
61
|
+
if (Orm.instance?.isView?.(modelName) && !this._mysqlDb) {
|
|
62
|
+
const resolver = new ViewResolver(modelName);
|
|
63
|
+
const records = await resolver.resolveAll();
|
|
64
|
+
|
|
65
|
+
if (!conditions || Object.keys(conditions).length === 0) return records;
|
|
66
|
+
|
|
67
|
+
return records.filter(record =>
|
|
68
|
+
Object.entries(conditions).every(([key, value]) => record.__data[key] === value)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
// For memory: true models without conditions, return from store
|
|
54
73
|
if (this._isMemoryModel(modelName) && !conditions) {
|
|
55
74
|
const modelStore = this.get(modelName);
|
|
@@ -125,6 +144,11 @@ export default class Store {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
remove(key, id) {
|
|
147
|
+
// Guard: read-only views cannot have records removed
|
|
148
|
+
if (Orm.instance?.isView?.(key)) {
|
|
149
|
+
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
if (id) return this.unloadRecord(key, id);
|
|
129
153
|
|
|
130
154
|
this.unloadAllRecords(key);
|