@stonyx/orm 0.2.1-alpha.10 → 0.2.1-alpha.11

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 CHANGED
@@ -3,6 +3,7 @@
3
3
  ## Detailed Guides
4
4
 
5
5
  - [Usage Patterns](usage-patterns.md) — Model definitions, serializers, transforms, CRUD, DB schema, persistence, access control, REST API, and include parameters
6
+ - [Views](views.md) — Read-only computed views with aggregate helpers, in-memory resolver, and MySQL VIEW auto-generation
6
7
  - [Middleware Hooks System](hooks.md) — Before/after hooks for CRUD operations, halting, context object, change detection, and testing
7
8
  - [Code Style Rules](code-style-rules.md) — Strict prettier/eslint rules to apply across all Stonyx projects
8
9
 
@@ -21,6 +22,7 @@
21
22
  5. **REST API Generation**: Auto-generated RESTful endpoints with access control
22
23
  6. **Data Transformation**: Custom type conversion and formatting
23
24
  7. **Middleware Hooks**: Before/after hooks for all CRUD operations with halting capability
25
+ 8. **Views**: Read-only computed projections over model data with aggregate helpers (count, avg, sum, min, max)
24
26
 
25
27
  ---
26
28
 
@@ -38,6 +40,9 @@
38
40
  8. **Include Logic** (inline in [src/orm-request.js](src/orm-request.js)) - Parses include query params, traverses relationships, collects and deduplicates included records
39
41
  9. **Hooks** ([src/hooks.js](src/hooks.js)) - Middleware-based hook registry for CRUD lifecycle
40
42
  10. **MySQL Driver** ([src/mysql/mysql-db.js](src/mysql/mysql-db.js)) - MySQL persistence, migrations, schema introspection. Loads records in topological order. `_rowToRawData()` converts TINYINT(1) → boolean, remaps FK columns, strips timestamps.
43
+ 11. **View** ([src/view.js](src/view.js)) - Read-only base class for computed views (does NOT extend Model)
44
+ 12. **Aggregates** ([src/aggregates.js](src/aggregates.js)) - AggregateProperty class + helper functions (count, avg, sum, min, max)
45
+ 13. **ViewResolver** ([src/view-resolver.js](src/view-resolver.js)) - In-memory view resolver (iterates source model, computes aggregates + resolve map)
41
46
 
42
47
  ### Project Structure
43
48
 
@@ -66,6 +71,9 @@ stonyx-orm/
66
71
  │ ├── commands.js # CLI commands (db:migrate-*, etc.)
67
72
  │ ├── utils.js # Pluralize wrapper for dasherized names
68
73
  │ ├── plural-registry.js # Plural name registry (populated at init, supports Model.pluralName overrides)
74
+ │ ├── view.js # View base class (read-only, source, resolve, memory)
75
+ │ ├── aggregates.js # AggregateProperty + count/avg/sum/min/max helpers
76
+ │ ├── view-resolver.js # In-memory view resolver
69
77
  │ ├── exports/
70
78
  │ │ └── db.js # Convenience re-export of DB instance
71
79
  │ └── mysql/
@@ -86,6 +94,7 @@ stonyx-orm/
86
94
  │ ├── serializers/ # Example serializers
87
95
  │ ├── transforms/ # Custom transforms
88
96
  │ ├── access/ # Access control
97
+ │ ├── views/ # Example views
89
98
  │ ├── db-schema.js # DB schema
90
99
  │ └── payload.js # Test data
91
100
  └── package.json
@@ -103,7 +112,8 @@ config.orm = {
103
112
  model: './models',
104
113
  serializer: './serializers',
105
114
  transform: './transforms',
106
- access: './access'
115
+ access: './access',
116
+ view: './views'
107
117
  },
108
118
  db: {
109
119
  autosave: 'false',
@@ -229,9 +239,10 @@ The ORM supports two storage modes, configured via `db.mode`:
229
239
  **Import the ORM:**
230
240
  ```javascript
231
241
  import {
232
- Orm, Model, Serializer, attr, hasMany, belongsTo,
242
+ Orm, Model, View, Serializer, attr, hasMany, belongsTo,
233
243
  createRecord, updateRecord, store,
234
- beforeHook, afterHook, clearHook, clearAllHooks
244
+ beforeHook, afterHook, clearHook, clearAllHooks,
245
+ count, avg, sum, min, max
235
246
  } from '@stonyx/orm';
236
247
  ```
237
248
 
@@ -232,3 +232,69 @@ GET /scenes/e001-s001?include=slides.dialogue.character
232
232
  - `traverseIncludePath()` - Recursively traverses relationship paths
233
233
  - `collectIncludedRecords()` - Orchestrates traversal and deduplication
234
234
  - All implemented in [src/orm-request.js](src/orm-request.js)
235
+
236
+ ## 10. Views (Read-Only Computed Data)
237
+
238
+ Views are read-only projections that compute derived data from existing models. They work in both JSON mode (in-memory) and MySQL mode (auto-generated SQL VIEWs). See the full guide at [views.md](views.md).
239
+
240
+ ### Defining a View
241
+
242
+ ```javascript
243
+ // views/owner-stats.js
244
+ import { View, attr, belongsTo, count, avg } from '@stonyx/orm';
245
+
246
+ export default class OwnerStatsView extends View {
247
+ static source = 'owner'; // Required: model whose records produce view records
248
+
249
+ animalCount = count('pets'); // COUNT of hasMany relationship
250
+ averageAge = avg('pets', 'age'); // AVG of a field on related records
251
+ owner = belongsTo('owner'); // Link back to source record
252
+ }
253
+ ```
254
+
255
+ ### Aggregate Helpers
256
+
257
+ | Helper | Example | JS Behavior | MySQL |
258
+ |--------|---------|-------------|-------|
259
+ | `count(rel)` | `count('pets')` | `records.length` | `COUNT(table.id)` |
260
+ | `avg(rel, field)` | `avg('pets', 'age')` | Average of values | `AVG(table.field)` |
261
+ | `sum(rel, field)` | `sum('pets', 'age')` | Sum of values | `SUM(table.field)` |
262
+ | `min(rel, field)` | `min('pets', 'age')` | Minimum value | `MIN(table.field)` |
263
+ | `max(rel, field)` | `max('pets', 'age')` | Maximum value | `MAX(table.field)` |
264
+
265
+ ### Resolve Map (Escape Hatch)
266
+
267
+ For fields that can't be expressed as aggregates:
268
+
269
+ ```javascript
270
+ export default class OwnerStatsView extends View {
271
+ static source = 'owner';
272
+ static resolve = {
273
+ gender: 'gender', // String path from source data
274
+ score: (owner) => owner.__data.age * 10, // Function
275
+ };
276
+
277
+ gender = attr('string'); // Must also define as attr()
278
+ score = attr('number');
279
+ animalCount = count('pets');
280
+ }
281
+ ```
282
+
283
+ ### Querying Views
284
+
285
+ ```javascript
286
+ const stats = await store.findAll('owner-stats');
287
+ const stat = await store.find('owner-stats', ownerId);
288
+ ```
289
+
290
+ ### Read-Only Enforcement
291
+
292
+ ```javascript
293
+ createRecord('owner-stats', data); // Throws: Cannot create records for read-only view
294
+ updateRecord(viewRecord, data); // Throws: Cannot update records for read-only view
295
+ store.remove('owner-stats', id); // Throws: Cannot remove records from read-only view
296
+ ```
297
+
298
+ ### REST API
299
+
300
+ Only GET endpoints are mounted for views — no POST, PATCH, or DELETE.
@@ -0,0 +1,221 @@
1
+ # Views
2
+
3
+ Views are read-only, model-like structures that compute derived data from existing models. They work in both JSON file mode (in-memory computation) and MySQL mode (auto-generated SQL VIEWs).
4
+
5
+ ## What Views Are
6
+
7
+ A View defines a read-only projection over source model data. Use views when you need:
8
+ - Aggregated data (counts, averages, sums) derived from model relationships
9
+ - Computed read-only summaries that shouldn't be persisted as separate records
10
+ - MySQL VIEWs that are auto-generated from your JavaScript definition
11
+
12
+ ## Defining a View
13
+
14
+ Views extend the `View` base class and are placed in the `views/` directory (configurable via `paths.view`).
15
+
16
+ ```javascript
17
+ // views/owner-stats.js
18
+ import { View, attr, belongsTo, count, avg } from '@stonyx/orm';
19
+
20
+ export default class OwnerStatsView extends View {
21
+ static source = 'owner'; // The model whose records are iterated
22
+
23
+ animalCount = count('pets'); // COUNT of hasMany relationship
24
+ averageAge = avg('pets', 'age'); // AVG of a field on related records
25
+ owner = belongsTo('owner'); // Link back to the source record
26
+ }
27
+ ```
28
+
29
+ ### File Naming
30
+
31
+ - File: `owner-stats.js` → Class: `OwnerStatsView` → Store name: `'owner-stats'`
32
+ - Directory: configured via `paths.view` (default: `'./views'`)
33
+ - Environment variable: `ORM_VIEW_PATH`
34
+
35
+ ### Key Static Properties
36
+
37
+ | Property | Default | Description |
38
+ |----------|---------|-------------|
39
+ | `source` | `undefined` | **(Required)** The model name whose records produce view records |
40
+ | `resolve` | `undefined` | Optional escape-hatch map for custom derivations |
41
+ | `memory` | `false` | `false` = computed on demand; `true` = cached on startup |
42
+ | `readOnly` | `true` | **Enforced** — cannot be overridden to `false` |
43
+ | `pluralName` | `undefined` | Custom plural name (same as Model) |
44
+
45
+ ## Aggregate Helpers
46
+
47
+ Aggregate helpers define fields that compute values from related records. Each helper knows both its JavaScript computation logic and its MySQL SQL translation.
48
+
49
+ | Helper | JS Behavior | MySQL Translation |
50
+ |--------|-------------|-------------------|
51
+ | `count(relationship)` | `relatedRecords.length` | `COUNT(table.id)` |
52
+ | `avg(relationship, field)` | Average of field values | `AVG(table.field)` |
53
+ | `sum(relationship, field)` | Sum of field values | `SUM(table.field)` |
54
+ | `min(relationship, field)` | Minimum field value | `MIN(table.field)` |
55
+ | `max(relationship, field)` | Maximum field value | `MAX(table.field)` |
56
+
57
+ ### Empty/Null Handling
58
+
59
+ - `count` with empty/null relationship → `0`
60
+ - `avg` with empty/null relationship → `0`
61
+ - `sum` with empty/null relationship → `0`
62
+ - `min` with empty/null relationship → `null`
63
+ - `max` with empty/null relationship → `null`
64
+ - Non-numeric values are filtered/treated as 0
65
+
66
+ ## Resolve Map (Escape Hatch)
67
+
68
+ For computed fields that can't be expressed as aggregates, use `static resolve`:
69
+
70
+ ```javascript
71
+ export default class OwnerStatsView extends View {
72
+ static source = 'owner';
73
+
74
+ static resolve = {
75
+ gender: 'gender', // String path: maps from source record data
76
+ score: (owner) => { // Function: custom computation
77
+ return owner.__data.age * 10;
78
+ },
79
+ nestedVal: 'details.nested', // Nested string paths supported
80
+ };
81
+
82
+ // Must also define as attr() for the serializer to process
83
+ gender = attr('string');
84
+ score = attr('number');
85
+ nestedVal = attr('passthrough');
86
+
87
+ animalCount = count('pets');
88
+ }
89
+ ```
90
+
91
+ **Important:** Each resolve map entry needs a corresponding `attr()` field definition on the view.
92
+
93
+ ## Querying Views
94
+
95
+ Views use the same store API as models:
96
+
97
+ ```javascript
98
+ // All view records (computed fresh each call in JSON mode)
99
+ const stats = await store.findAll('owner-stats');
100
+
101
+ // Single view record by source ID
102
+ const stat = await store.find('owner-stats', ownerId);
103
+
104
+ // With conditions
105
+ const filtered = await store.findAll('owner-stats', { animalCount: 5 });
106
+ ```
107
+
108
+ ## Read-Only Enforcement
109
+
110
+ Views are strictly read-only at all layers:
111
+
112
+ ```javascript
113
+ // All of these throw errors:
114
+ createRecord('owner-stats', data); // Error: Cannot create records for read-only view
115
+ updateRecord(viewRecord, data); // Error: Cannot update records for read-only view
116
+ store.remove('owner-stats', id); // Error: Cannot remove records from read-only view
117
+
118
+ // Internal use only — bypasses guard:
119
+ createRecord('owner-stats', data, { isDbRecord: true }); // Used by resolver/loader
120
+ ```
121
+
122
+ ## REST API Behavior
123
+
124
+ When a view is included in an access configuration, only GET endpoints are mounted:
125
+
126
+ - `GET /view-plural-name` — Returns list of view records
127
+ - `GET /view-plural-name/:id` — Returns single view record
128
+ - `GET /view-plural-name/:id/{relationship}` — Related resources
129
+ - No POST, PATCH, DELETE endpoints
130
+
131
+ ## JSON Mode (In-Memory Resolver)
132
+
133
+ In JSON/non-MySQL mode, the ViewResolver:
134
+
135
+ 1. Iterates all records of the `source` model from the store
136
+ 2. For each source record:
137
+ - Computes aggregate properties from relationships
138
+ - Applies resolve map entries (string paths or functions)
139
+ - Maps regular attr fields from source data
140
+ 3. Creates view records via `createRecord` with `isDbRecord: true`
141
+ 4. Returns computed array
142
+
143
+ ## MySQL Mode
144
+
145
+ In MySQL mode:
146
+
147
+ 1. **Schema introspection** generates VIEW metadata via `introspectViews()`
148
+ 2. **DDL generation** creates `CREATE OR REPLACE VIEW` SQL from aggregates and relationships
149
+ 3. **Queries** use `SELECT * FROM \`view_name\`` just like tables
150
+ 4. **Migrations** include `CREATE OR REPLACE VIEW` after table statements
151
+ 5. **persist()** is a no-op for views
152
+ 6. **loadMemoryRecords()** loads views with `memory: true` from the MySQL VIEW
153
+
154
+ ### Generated SQL Example
155
+
156
+ For `OwnerStatsView` with `count('pets')` and `avg('pets', 'age')`:
157
+
158
+ ```sql
159
+ CREATE OR REPLACE VIEW `owner-stats` AS
160
+ SELECT
161
+ `owners`.`id` AS `id`,
162
+ COUNT(`animals`.`id`) AS `animalCount`,
163
+ AVG(`animals`.`age`) AS `avgAge`
164
+ FROM `owners`
165
+ LEFT JOIN `animals` ON `animals`.`owner_id` = `owners`.`id`
166
+ GROUP BY `owners`.`id`
167
+ ```
168
+
169
+ ## Migration Support
170
+
171
+ Views are handled in migrations alongside tables:
172
+
173
+ - **Added views** → `CREATE OR REPLACE VIEW ...` in UP, `DROP VIEW IF EXISTS ...` in DOWN
174
+ - **Removed views** → Commented `DROP VIEW` warning in UP (matching model removal pattern)
175
+ - **Changed views** → `CREATE OR REPLACE VIEW ...` in UP (replaces automatically)
176
+ - Views appear AFTER table statements in migrations (dependency order)
177
+ - Snapshots include view entries with `isView: true` and `viewQuery`
178
+
179
+ ## Memory Flag
180
+
181
+ - `static memory = false` (default) — View records are computed fresh on each query
182
+ - `static memory = true` — View records are loaded from MySQL VIEW on startup and cached
183
+
184
+ ## Testing Views
185
+
186
+ ```javascript
187
+ import QUnit from 'qunit';
188
+ import { store } from '@stonyx/orm';
189
+
190
+ QUnit.test('view returns computed data', async function(assert) {
191
+ // Create source data
192
+ createRecord('owner', { id: 1, name: 'Alice' }, { serialize: false });
193
+ createRecord('animal', { id: 1, age: 3, owner: 1 }, { serialize: false });
194
+
195
+ // Query the view
196
+ const results = await store.findAll('owner-stats');
197
+ const stat = results.find(r => r.id === 1);
198
+
199
+ assert.strictEqual(stat.__data.animalCount, 1);
200
+ });
201
+ ```
202
+
203
+ ## Architecture
204
+
205
+ ### Source Files
206
+
207
+ | File | Purpose |
208
+ |------|---------|
209
+ | `src/view.js` | View base class |
210
+ | `src/aggregates.js` | AggregateProperty class + helper functions |
211
+ | `src/view-resolver.js` | In-memory view resolver |
212
+ | `src/mysql/schema-introspector.js` | `introspectViews()`, `buildViewDDL()` |
213
+ | `src/mysql/migration-generator.js` | `diffViewSnapshots()`, view migration generation |
214
+
215
+ ### Key Design Decisions
216
+
217
+ 1. **View does NOT extend Model** — conceptually separate; shared behavior is minimal
218
+ 2. **Driver-agnostic API** — No SQL in view definitions; MySQL driver generates SQL automatically
219
+ 3. **Aggregate helpers follow the transform pattern** — Each knows both JS computation and MySQL mapping
220
+ 4. **Resolve map mirrors Serializer.map** — String paths or function resolvers
221
+ 5. **View schemas are separate** — `introspectViews()` is separate from `introspectModels()`
@@ -4,6 +4,7 @@ const {
4
4
  ORM_REST_ROUTE,
5
5
  ORM_SERIALIZER_PATH,
6
6
  ORM_TRANSFORM_PATH,
7
+ ORM_VIEW_PATH,
7
8
  ORM_USE_REST_SERVER,
8
9
  DB_AUTO_SAVE,
9
10
  DB_FILE,
@@ -36,7 +37,8 @@ export default {
36
37
  access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
37
38
  model: ORM_MODEL_PATH ?? './models',
38
39
  serializer: ORM_SERIALIZER_PATH ?? './serializers',
39
- transform: ORM_TRANSFORM_PATH ?? './transforms'
40
+ transform: ORM_TRANSFORM_PATH ?? './transforms',
41
+ view: ORM_VIEW_PATH ?? './views'
40
42
  },
41
43
  mysql: MYSQL_HOST ? {
42
44
  host: MYSQL_HOST ?? 'localhost',
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-alpha.10",
7
+ "version": "0.2.1-alpha.11",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  },
34
34
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
35
35
  "dependencies": {
36
- "stonyx": "0.2.3-beta.4",
37
- "@stonyx/events": "0.1.1-beta.5",
38
- "@stonyx/cron": "0.2.1-beta.8"
36
+ "stonyx": "0.2.3-beta.5",
37
+ "@stonyx/events": "0.1.1-beta.6",
38
+ "@stonyx/cron": "0.2.1-beta.10"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "mysql2": "^3.0.0"
@@ -46,7 +46,7 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
- "@stonyx/rest-server": "0.2.1-beta.10",
49
+ "@stonyx/rest-server": "0.2.1-beta.14",
50
50
  "@stonyx/utils": "0.2.3-beta.4",
51
51
  "qunit": "^2.24.1",
52
52
  "sinon": "^21.0.0"
@@ -0,0 +1,81 @@
1
+ export class AggregateProperty {
2
+ constructor(aggregateType, relationship, field) {
3
+ this.aggregateType = aggregateType;
4
+ this.relationship = relationship;
5
+ this.field = field;
6
+ this.mysqlFunction = aggregateType.toUpperCase();
7
+ this.resultType = aggregateType === 'avg' ? 'float' : 'number';
8
+ }
9
+
10
+ compute(relatedRecords) {
11
+ if (!relatedRecords || !Array.isArray(relatedRecords) || relatedRecords.length === 0) {
12
+ if (this.aggregateType === 'min' || this.aggregateType === 'max') return null;
13
+ return 0;
14
+ }
15
+
16
+ switch (this.aggregateType) {
17
+ case 'count':
18
+ return relatedRecords.length;
19
+
20
+ case 'sum':
21
+ return relatedRecords.reduce((acc, record) => {
22
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
23
+ return acc + (isNaN(val) ? 0 : val);
24
+ }, 0);
25
+
26
+ case 'avg': {
27
+ let sum = 0;
28
+ let count = 0;
29
+ for (const record of relatedRecords) {
30
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
31
+ if (!isNaN(val)) {
32
+ sum += val;
33
+ count++;
34
+ }
35
+ }
36
+ return count === 0 ? 0 : sum / count;
37
+ }
38
+
39
+ case 'min': {
40
+ let min = null;
41
+ for (const record of relatedRecords) {
42
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
43
+ if (!isNaN(val) && (min === null || val < min)) min = val;
44
+ }
45
+ return min;
46
+ }
47
+
48
+ case 'max': {
49
+ let max = null;
50
+ for (const record of relatedRecords) {
51
+ const val = parseFloat(record?.__data?.[this.field] ?? record?.[this.field]);
52
+ if (!isNaN(val) && (max === null || val > max)) max = val;
53
+ }
54
+ return max;
55
+ }
56
+
57
+ default:
58
+ return null;
59
+ }
60
+ }
61
+ }
62
+
63
+ export function count(relationship) {
64
+ return new AggregateProperty('count', relationship);
65
+ }
66
+
67
+ export function avg(relationship, field) {
68
+ return new AggregateProperty('avg', relationship, field);
69
+ }
70
+
71
+ export function sum(relationship, field) {
72
+ return new AggregateProperty('sum', relationship, field);
73
+ }
74
+
75
+ export function min(relationship, field) {
76
+ return new AggregateProperty('min', relationship, field);
77
+ }
78
+
79
+ export function max(relationship, field) {
80
+ return new AggregateProperty('max', relationship, field);
81
+ }
package/src/index.js CHANGED
@@ -15,17 +15,20 @@
15
15
  */
16
16
 
17
17
  import Model from './model.js';
18
+ import View from './view.js';
18
19
  import Serializer from './serializer.js';
19
20
 
20
21
  import attr from './attr.js';
21
22
  import belongsTo from './belongs-to.js';
22
23
  import hasMany from './has-many.js';
23
24
  import { createRecord, updateRecord } from './manage-record.js';
25
+ import { count, avg, sum, min, max } from './aggregates.js';
24
26
 
25
27
  export { default } from './main.js';
26
28
  export { store, relationships } from './main.js';
27
- export { Model, Serializer }; // base classes
29
+ export { Model, View, Serializer }; // base classes
28
30
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
31
+ export { count, avg, sum, min, max }; // aggregate helpers
29
32
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
30
33
 
31
34
  // Store API:
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,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,145 @@ 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
+ columns,
197
+ foreignKeys,
198
+ aggregates,
199
+ relationships,
200
+ isView: true,
201
+ memory: viewClass.memory !== false ? false : false, // Views default to memory:false
202
+ };
203
+ }
204
+
205
+ return schemas;
206
+ }
207
+
208
+ export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
209
+ if (!viewSchema.source) {
210
+ throw new Error(`View '${name}' must define a source model`);
211
+ }
212
+
213
+ const sourceModelName = viewSchema.source;
214
+ const sourceSchema = modelSchemas[sourceModelName];
215
+ const sourceTable = sourceSchema
216
+ ? sourceSchema.table
217
+ : getPluralName(sourceModelName);
218
+
219
+ const selectColumns = [];
220
+ const joins = [];
221
+ const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
222
+
223
+ // Source table primary key
224
+ selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
225
+
226
+ // Aggregate columns
227
+ for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
228
+ const relName = aggProp.relationship;
229
+ const relModelName = camelCaseToKebabCase(relName);
230
+ const relTable = getPluralName(relModelName);
231
+
232
+ if (aggProp.aggregateType === 'count') {
233
+ selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`id\`) AS \`${key}\``);
234
+ } else {
235
+ const field = aggProp.field;
236
+ selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
237
+ }
238
+
239
+ // Add LEFT JOIN for the relationship if not already added
240
+ const joinKey = `${relTable}`;
241
+ if (!joins.find(j => j.table === joinKey)) {
242
+ // Determine the FK column: the related table has a belongsTo back to the source
243
+ const fkColumn = `${sourceModelName}_id`;
244
+ joins.push({
245
+ table: relTable,
246
+ condition: `\`${relTable}\`.\`${fkColumn}\` = \`${sourceTable}\`.\`id\``
247
+ });
248
+ }
249
+ }
250
+
251
+ // Regular columns (from resolve map string paths or direct attr fields)
252
+ for (const [key, mysqlType] of Object.entries(viewSchema.columns || {})) {
253
+ selectColumns.push(`\`${sourceTable}\`.\`${key}\` AS \`${key}\``);
254
+ }
255
+
256
+ // Build JOIN clauses
257
+ const joinClauses = joins.map(j =>
258
+ `LEFT JOIN \`${j.table}\` ON ${j.condition}`
259
+ ).join('\n ');
260
+
261
+ // Build GROUP BY
262
+ const groupBy = hasAggregates ? `\nGROUP BY \`${sourceTable}\`.\`id\`` : '';
263
+
264
+ const viewName = viewSchema.viewName;
265
+ const sql = `CREATE OR REPLACE VIEW \`${viewName}\` AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM \`${sourceTable}\`${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
266
+
267
+ return sql;
268
+ }
269
+
270
+ export function viewSchemasToSnapshot(viewSchemas) {
271
+ const snapshot = {};
272
+
273
+ for (const [name, schema] of Object.entries(viewSchemas)) {
274
+ snapshot[name] = {
275
+ viewName: schema.viewName,
276
+ source: schema.source,
277
+ columns: { ...schema.columns },
278
+ foreignKeys: { ...schema.foreignKeys },
279
+ isView: true,
280
+ viewQuery: buildViewDDL(name, schema),
281
+ };
282
+ }
283
+
284
+ return snapshot;
285
+ }
286
+
147
287
  export function schemasToSnapshot(schemas) {
148
288
  const snapshot = {};
149
289
 
@@ -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}`;
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);
@@ -0,0 +1,103 @@
1
+ import Orm, { createRecord, store } from '@stonyx/orm';
2
+ import { AggregateProperty } from './aggregates.js';
3
+ import { get } from '@stonyx/utils/object';
4
+
5
+ export default class ViewResolver {
6
+ constructor(viewName) {
7
+ this.viewName = viewName;
8
+ }
9
+
10
+ async resolveAll() {
11
+ const orm = Orm.instance;
12
+ const { modelClass: viewClass } = orm.getRecordClasses(this.viewName);
13
+
14
+ if (!viewClass) return [];
15
+
16
+ const source = viewClass.source;
17
+ if (!source) return [];
18
+
19
+ const sourceRecords = await store.findAll(source);
20
+ if (!sourceRecords || sourceRecords.length === 0) {
21
+ return [];
22
+ }
23
+
24
+ const resolveMap = viewClass.resolve || {};
25
+ const viewInstance = new viewClass(this.viewName);
26
+ const aggregateFields = {};
27
+ const regularFields = {};
28
+
29
+ // Categorize fields on the view instance
30
+ for (const [key, value] of Object.entries(viewInstance)) {
31
+ if (key.startsWith('__')) continue;
32
+ if (key === 'id') continue;
33
+
34
+ if (value instanceof AggregateProperty) {
35
+ aggregateFields[key] = value;
36
+ } else if (typeof value !== 'function') {
37
+ // Regular attr or direct value — not a relationship handler
38
+ regularFields[key] = value;
39
+ }
40
+ }
41
+
42
+ const results = [];
43
+
44
+ for (const sourceRecord of sourceRecords) {
45
+ const rawData = { id: sourceRecord.id };
46
+
47
+ // Compute aggregate fields from source record's relationships
48
+ for (const [key, aggProp] of Object.entries(aggregateFields)) {
49
+ const relatedRecords = sourceRecord.__relationships?.[aggProp.relationship]
50
+ || sourceRecord[aggProp.relationship];
51
+ const relArray = Array.isArray(relatedRecords) ? relatedRecords : [];
52
+ rawData[key] = aggProp.compute(relArray);
53
+ }
54
+
55
+ // Apply resolve map entries
56
+ for (const [key, resolver] of Object.entries(resolveMap)) {
57
+ if (typeof resolver === 'function') {
58
+ rawData[key] = resolver(sourceRecord);
59
+ } else if (typeof resolver === 'string') {
60
+ rawData[key] = get(sourceRecord.__data || sourceRecord, resolver)
61
+ ?? get(sourceRecord, resolver);
62
+ }
63
+ }
64
+
65
+ // Map regular attr fields from source record if not already set
66
+ for (const key of Object.keys(regularFields)) {
67
+ if (rawData[key] !== undefined) continue;
68
+
69
+ const sourceValue = sourceRecord.__data?.[key] ?? sourceRecord[key];
70
+ if (sourceValue !== undefined) {
71
+ rawData[key] = sourceValue;
72
+ }
73
+ }
74
+
75
+ // Set belongsTo source relationship
76
+ const viewInstanceForRel = new viewClass(this.viewName);
77
+ for (const [key, value] of Object.entries(viewInstanceForRel)) {
78
+ if (typeof value === 'function' && key !== 'id') {
79
+ // This is a relationship handler — pass the source record id
80
+ rawData[key] = sourceRecord.id;
81
+ }
82
+ }
83
+
84
+ // Clear existing record from store to allow re-resolution
85
+ const viewStore = store.get(this.viewName);
86
+ if (viewStore?.has(rawData.id)) {
87
+ viewStore.delete(rawData.id);
88
+ }
89
+
90
+ const record = createRecord(this.viewName, rawData, { isDbRecord: true });
91
+ results.push(record);
92
+ }
93
+
94
+ return results;
95
+ }
96
+
97
+ async resolveOne(id) {
98
+ const all = await this.resolveAll();
99
+ return all.find(record => {
100
+ return record.id === id || record.id == id;
101
+ });
102
+ }
103
+ }
package/src/view.js ADDED
@@ -0,0 +1,20 @@
1
+ import { attr } from '@stonyx/orm';
2
+
3
+ export default class View {
4
+ static memory = false;
5
+ static readOnly = true;
6
+ static pluralName = undefined;
7
+ static source = undefined;
8
+ static resolve = undefined;
9
+
10
+ id = attr('number');
11
+
12
+ constructor(name) {
13
+ this.__name = name;
14
+
15
+ // Enforce readOnly — cannot be overridden to false
16
+ if (this.constructor.readOnly !== true) {
17
+ throw new Error(`View '${name}' cannot override readOnly to false`);
18
+ }
19
+ }
20
+ }