@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/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 operations) {
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);
@@ -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
- log.db('No schema changes detected.');
21
- return null;
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(currentSnapshot, null, 2));
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: currentSnapshot };
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
+ }
@@ -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
- const schema = schemas[modelName];
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
- const schema = schemas[modelName];
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 { type: 'belongsTo', modelName };
13
- if (fnStr.includes(`getRelationships('hasMany',`)) return { type: 'hasMany', modelName };
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 relInfo = getRelationshipInfo(property);
38
+ const relType = getRelationshipInfo(property);
43
39
 
44
- if (relInfo?.type === 'belongsTo') {
45
- relationships.belongsTo[key] = relInfo.modelName;
46
- } else if (relInfo?.type === 'hasMany') {
47
- relationships.hasMany[key] = relInfo.modelName;
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 [relName, targetModelName] of Object.entries(relationships.belongsTo)) {
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: sanitizeTableName(getPluralName(targetModelName)),
58
+ references: getPluralName(modelName),
62
59
  column: 'id',
63
60
  };
64
61
  }
65
62
 
66
63
  schemas[name] = {
67
- table: sanitizeTableName(getPluralName(name)),
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
- const refTable = sanitizeTableName(fkDef.references);
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 targetModelName of Object.values(schema.relationships.belongsTo)) {
140
- visit(targetModelName);
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
 
@@ -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
- patch: {
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;
@@ -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}`;