@stonyx/orm 0.2.1-alpha.11 → 0.2.1-alpha.13
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 +3 -14
- package/.claude/usage-patterns.md +0 -66
- package/config/environment.js +1 -3
- package/package.json +6 -6
- package/src/index.js +1 -4
- package/src/main.js +2 -31
- package/src/manage-record.js +0 -11
- package/src/model-property.js +2 -2
- package/src/mysql/migration-generator.js +5 -103
- package/src/mysql/mysql-db.js +4 -55
- package/src/mysql/schema-introspector.js +0 -140
- package/src/orm-request.js +6 -12
- package/src/serializer.js +2 -9
- package/src/setup-rest-server.js +1 -1
- package/src/store.js +1 -25
- package/.claude/views.md +0 -221
- package/src/aggregates.js +0 -81
- package/src/view-resolver.js +0 -103
- package/src/view.js +0 -20
package/.claude/index.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
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
|
|
7
6
|
- [Middleware Hooks System](hooks.md) — Before/after hooks for CRUD operations, halting, context object, change detection, and testing
|
|
8
7
|
- [Code Style Rules](code-style-rules.md) — Strict prettier/eslint rules to apply across all Stonyx projects
|
|
9
8
|
|
|
@@ -22,7 +21,6 @@
|
|
|
22
21
|
5. **REST API Generation**: Auto-generated RESTful endpoints with access control
|
|
23
22
|
6. **Data Transformation**: Custom type conversion and formatting
|
|
24
23
|
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)
|
|
26
24
|
|
|
27
25
|
---
|
|
28
26
|
|
|
@@ -40,9 +38,6 @@
|
|
|
40
38
|
8. **Include Logic** (inline in [src/orm-request.js](src/orm-request.js)) - Parses include query params, traverses relationships, collects and deduplicates included records
|
|
41
39
|
9. **Hooks** ([src/hooks.js](src/hooks.js)) - Middleware-based hook registry for CRUD lifecycle
|
|
42
40
|
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)
|
|
46
41
|
|
|
47
42
|
### Project Structure
|
|
48
43
|
|
|
@@ -71,9 +66,6 @@ stonyx-orm/
|
|
|
71
66
|
│ ├── commands.js # CLI commands (db:migrate-*, etc.)
|
|
72
67
|
│ ├── utils.js # Pluralize wrapper for dasherized names
|
|
73
68
|
│ ├── 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
|
|
77
69
|
│ ├── exports/
|
|
78
70
|
│ │ └── db.js # Convenience re-export of DB instance
|
|
79
71
|
│ └── mysql/
|
|
@@ -94,7 +86,6 @@ stonyx-orm/
|
|
|
94
86
|
│ ├── serializers/ # Example serializers
|
|
95
87
|
│ ├── transforms/ # Custom transforms
|
|
96
88
|
│ ├── access/ # Access control
|
|
97
|
-
│ ├── views/ # Example views
|
|
98
89
|
│ ├── db-schema.js # DB schema
|
|
99
90
|
│ └── payload.js # Test data
|
|
100
91
|
└── package.json
|
|
@@ -112,8 +103,7 @@ config.orm = {
|
|
|
112
103
|
model: './models',
|
|
113
104
|
serializer: './serializers',
|
|
114
105
|
transform: './transforms',
|
|
115
|
-
access: './access'
|
|
116
|
-
view: './views'
|
|
106
|
+
access: './access'
|
|
117
107
|
},
|
|
118
108
|
db: {
|
|
119
109
|
autosave: 'false',
|
|
@@ -239,10 +229,9 @@ The ORM supports two storage modes, configured via `db.mode`:
|
|
|
239
229
|
**Import the ORM:**
|
|
240
230
|
```javascript
|
|
241
231
|
import {
|
|
242
|
-
Orm, Model,
|
|
232
|
+
Orm, Model, Serializer, attr, hasMany, belongsTo,
|
|
243
233
|
createRecord, updateRecord, store,
|
|
244
|
-
beforeHook, afterHook, clearHook, clearAllHooks
|
|
245
|
-
count, avg, sum, min, max
|
|
234
|
+
beforeHook, afterHook, clearHook, clearAllHooks
|
|
246
235
|
} from '@stonyx/orm';
|
|
247
236
|
```
|
|
248
237
|
|
|
@@ -232,69 +232,3 @@ 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.
|
package/config/environment.js
CHANGED
|
@@ -4,7 +4,6 @@ const {
|
|
|
4
4
|
ORM_REST_ROUTE,
|
|
5
5
|
ORM_SERIALIZER_PATH,
|
|
6
6
|
ORM_TRANSFORM_PATH,
|
|
7
|
-
ORM_VIEW_PATH,
|
|
8
7
|
ORM_USE_REST_SERVER,
|
|
9
8
|
DB_AUTO_SAVE,
|
|
10
9
|
DB_FILE,
|
|
@@ -37,8 +36,7 @@ export default {
|
|
|
37
36
|
access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
|
|
38
37
|
model: ORM_MODEL_PATH ?? './models',
|
|
39
38
|
serializer: ORM_SERIALIZER_PATH ?? './serializers',
|
|
40
|
-
transform: ORM_TRANSFORM_PATH ?? './transforms'
|
|
41
|
-
view: ORM_VIEW_PATH ?? './views'
|
|
39
|
+
transform: ORM_TRANSFORM_PATH ?? './transforms'
|
|
42
40
|
},
|
|
43
41
|
mysql: MYSQL_HOST ? {
|
|
44
42
|
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.
|
|
7
|
+
"version": "0.2.1-alpha.13",
|
|
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.
|
|
37
|
-
"@stonyx/events": "0.1.1-beta.
|
|
38
|
-
"@stonyx/cron": "0.2.1-beta.
|
|
36
|
+
"stonyx": "0.2.3-beta.6",
|
|
37
|
+
"@stonyx/events": "0.1.1-beta.7",
|
|
38
|
+
"@stonyx/cron": "0.2.1-beta.12"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"mysql2": "^3.0.0"
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
}
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@stonyx/rest-server": "0.2.1-beta.
|
|
50
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
49
|
+
"@stonyx/rest-server": "0.2.1-beta.16",
|
|
50
|
+
"@stonyx/utils": "0.2.3-beta.5",
|
|
51
51
|
"qunit": "^2.24.1",
|
|
52
52
|
"sinon": "^21.0.0"
|
|
53
53
|
},
|
package/src/index.js
CHANGED
|
@@ -15,20 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import Model from './model.js';
|
|
18
|
-
import View from './view.js';
|
|
19
18
|
import Serializer from './serializer.js';
|
|
20
19
|
|
|
21
20
|
import attr from './attr.js';
|
|
22
21
|
import belongsTo from './belongs-to.js';
|
|
23
22
|
import hasMany from './has-many.js';
|
|
24
23
|
import { createRecord, updateRecord } from './manage-record.js';
|
|
25
|
-
import { count, avg, sum, min, max } from './aggregates.js';
|
|
26
24
|
|
|
27
25
|
export { default } from './main.js';
|
|
28
26
|
export { store, relationships } from './main.js';
|
|
29
|
-
export { Model,
|
|
27
|
+
export { Model, Serializer }; // base classes
|
|
30
28
|
export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
|
|
31
|
-
export { count, avg, sum, min, max }; // aggregate helpers
|
|
32
29
|
export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
|
|
33
30
|
|
|
34
31
|
// Store API:
|
package/src/main.js
CHANGED
|
@@ -37,7 +37,6 @@ export default class Orm {
|
|
|
37
37
|
|
|
38
38
|
models = {};
|
|
39
39
|
serializers = {};
|
|
40
|
-
views = {};
|
|
41
40
|
transforms = { ...baseTransforms };
|
|
42
41
|
warnings = new Set();
|
|
43
42
|
|
|
@@ -80,28 +79,14 @@ export default class Orm {
|
|
|
80
79
|
// Wait for imports before db & rest server setup
|
|
81
80
|
await Promise.all(promises);
|
|
82
81
|
|
|
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
|
-
|
|
93
82
|
// Setup event names for hooks after models are loaded
|
|
94
83
|
const eventNames = [];
|
|
95
84
|
const operations = ['list', 'get', 'create', 'update', 'delete'];
|
|
96
|
-
const viewOperations = ['list', 'get'];
|
|
97
85
|
const timings = ['before', 'after'];
|
|
98
86
|
|
|
99
87
|
for (const modelName of Orm.store.data.keys()) {
|
|
100
|
-
const isView = this.isView(modelName);
|
|
101
|
-
const ops = isView ? viewOperations : operations;
|
|
102
|
-
|
|
103
88
|
for (const timing of timings) {
|
|
104
|
-
for (const operation of
|
|
89
|
+
for (const operation of operations) {
|
|
105
90
|
eventNames.push(`${timing}:${operation}:${modelName}`);
|
|
106
91
|
}
|
|
107
92
|
}
|
|
@@ -156,27 +141,13 @@ export default class Orm {
|
|
|
156
141
|
|
|
157
142
|
getRecordClasses(modelName) {
|
|
158
143
|
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
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
|
-
|
|
144
|
+
|
|
169
145
|
return {
|
|
170
146
|
modelClass: this.models[`${modelClassPrefix}Model`],
|
|
171
147
|
serializerClass: this.serializers[`${modelClassPrefix}Serializer`] || Serializer
|
|
172
148
|
};
|
|
173
149
|
}
|
|
174
150
|
|
|
175
|
-
isView(modelName) {
|
|
176
|
-
const modelClassPrefix = kebabCaseToPascalCase(modelName);
|
|
177
|
-
return !!this.views[`${modelClassPrefix}View`];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
151
|
// Queue warnings to avoid the same error from being logged in the same iteration
|
|
181
152
|
warn(message) {
|
|
182
153
|
this.warnings.add(message);
|
package/src/manage-record.js
CHANGED
|
@@ -14,11 +14,6 @@ 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
|
-
|
|
22
17
|
const modelStore = store.get(modelName);
|
|
23
18
|
const globalRelationships = relationships.get('global');
|
|
24
19
|
const pendingRelationships = relationships.get('pending');
|
|
@@ -88,12 +83,6 @@ export function createRecord(modelName, rawData={}, userOptions={}) {
|
|
|
88
83
|
export function updateRecord(record, rawData, userOptions={}) {
|
|
89
84
|
if (!rawData) throw new Error('rawData must be passed in to updateRecord call');
|
|
90
85
|
|
|
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
|
-
|
|
97
86
|
const options = { ...defaultOptions, ...userOptions, update:true };
|
|
98
87
|
|
|
99
88
|
record.serialize(rawData, options);
|
package/src/model-property.js
CHANGED
|
@@ -22,8 +22,8 @@ export default class ModelProperty {
|
|
|
22
22
|
return this._value = newValue;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
if (newValue === undefined
|
|
25
|
+
if (newValue === undefined) return;
|
|
26
26
|
|
|
27
|
-
this._value = Orm.instance.transforms[this.type](newValue);
|
|
27
|
+
this._value = newValue === null ? null : Orm.instance.transforms[this.type](newValue);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { introspectModels,
|
|
1
|
+
import { introspectModels, buildTableDDL, schemasToSnapshot, 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,18 +16,9 @@ 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
|
|
20
19
|
if (!diff.hasChanges) {
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
}
|
|
20
|
+
log.db('No schema changes detected.');
|
|
21
|
+
return null;
|
|
31
22
|
}
|
|
32
23
|
|
|
33
24
|
const upStatements = [];
|
|
@@ -94,71 +85,17 @@ export async function generateMigration(description = 'migration') {
|
|
|
94
85
|
downStatements.push(`ALTER TABLE \`${table}\` ADD FOREIGN KEY (\`${column}\`) REFERENCES \`${references.references}\`(\`${references.column}\`) ON DELETE SET NULL;`);
|
|
95
86
|
}
|
|
96
87
|
|
|
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
|
-
|
|
151
88
|
const sanitizedDescription = description.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
|
|
152
89
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
153
90
|
const filename = `${timestamp}_${sanitizedDescription}.sql`;
|
|
154
91
|
const content = `-- UP\n${upStatements.join('\n')}\n\n-- DOWN\n${downStatements.join('\n')}\n`;
|
|
155
92
|
|
|
156
93
|
await createFile(path.join(migrationsPath, filename), content);
|
|
157
|
-
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(
|
|
94
|
+
await createFile(path.join(migrationsPath, '.snapshot.json'), JSON.stringify(currentSnapshot, null, 2));
|
|
158
95
|
|
|
159
96
|
log.db(`Migration generated: ${filename}`);
|
|
160
97
|
|
|
161
|
-
return { filename, content, snapshot:
|
|
98
|
+
return { filename, content, snapshot: currentSnapshot };
|
|
162
99
|
}
|
|
163
100
|
|
|
164
101
|
export async function loadLatestSnapshot(migrationsPath) {
|
|
@@ -249,38 +186,3 @@ export function detectSchemaDrift(schemas, snapshot) {
|
|
|
249
186
|
const current = schemasToSnapshot(schemas);
|
|
250
187
|
return diffSnapshots(snapshot, current);
|
|
251
188
|
}
|
|
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,
|
|
3
|
+
import { introspectModels, 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,
|
|
17
|
+
introspectModels, getTopologicalOrder, schemasToSnapshot,
|
|
18
18
|
loadLatestSnapshot, detectSchemaDrift,
|
|
19
19
|
buildInsert, buildUpdate, buildDelete, buildSelect,
|
|
20
20
|
createRecord, store, confirm, readFile, getPluralName, config, log, path
|
|
@@ -148,35 +148,6 @@ 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
|
-
}
|
|
180
151
|
}
|
|
181
152
|
|
|
182
153
|
/**
|
|
@@ -195,16 +166,7 @@ export default class MysqlDB {
|
|
|
195
166
|
*/
|
|
196
167
|
async findRecord(modelName, id) {
|
|
197
168
|
const schemas = this.deps.introspectModels();
|
|
198
|
-
|
|
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
|
-
}
|
|
169
|
+
const schema = schemas[modelName];
|
|
208
170
|
|
|
209
171
|
if (!schema) return undefined;
|
|
210
172
|
|
|
@@ -237,16 +199,7 @@ export default class MysqlDB {
|
|
|
237
199
|
*/
|
|
238
200
|
async findAll(modelName, conditions) {
|
|
239
201
|
const schemas = this.deps.introspectModels();
|
|
240
|
-
|
|
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
|
-
}
|
|
202
|
+
const schema = schemas[modelName];
|
|
250
203
|
|
|
251
204
|
if (!schema) return [];
|
|
252
205
|
|
|
@@ -324,10 +277,6 @@ export default class MysqlDB {
|
|
|
324
277
|
}
|
|
325
278
|
|
|
326
279
|
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
|
-
|
|
331
280
|
switch (operation) {
|
|
332
281
|
case 'create':
|
|
333
282
|
return this._persistCreate(modelName, context, response);
|
|
@@ -3,7 +3,6 @@ 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';
|
|
7
6
|
|
|
8
7
|
function getRelationshipInfo(property) {
|
|
9
8
|
if (typeof property !== 'function') return null;
|
|
@@ -145,145 +144,6 @@ export function getTopologicalOrder(schemas) {
|
|
|
145
144
|
return order;
|
|
146
145
|
}
|
|
147
146
|
|
|
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
|
-
|
|
287
147
|
export function schemasToSnapshot(schemas) {
|
|
288
148
|
const snapshot = {};
|
|
289
149
|
|
package/src/orm-request.js
CHANGED
|
@@ -323,27 +323,21 @@ export default class OrmRequest extends Request {
|
|
|
323
323
|
};
|
|
324
324
|
|
|
325
325
|
// Wrap handlers with hooks
|
|
326
|
-
const isView = Orm.instance?.isView?.(model);
|
|
327
|
-
|
|
328
326
|
this.handlers = {
|
|
329
327
|
get: {
|
|
330
328
|
'/': this._withHooks('list', getCollectionHandler),
|
|
331
329
|
'/:id': this._withHooks('get', getSingleHandler),
|
|
332
330
|
...this._generateRelationshipRoutes(model, pluralizedModel, modelRelationships)
|
|
333
331
|
},
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
// Views are read-only — no write endpoints
|
|
337
|
-
if (!isView) {
|
|
338
|
-
this.handlers.patch = {
|
|
332
|
+
patch: {
|
|
339
333
|
'/:id': this._withHooks('update', updateHandler)
|
|
340
|
-
}
|
|
341
|
-
|
|
334
|
+
},
|
|
335
|
+
post: {
|
|
342
336
|
'/': this._withHooks('create', createHandler)
|
|
343
|
-
}
|
|
344
|
-
|
|
337
|
+
},
|
|
338
|
+
delete: {
|
|
345
339
|
'/:id': this._withHooks('delete', deleteHandler)
|
|
346
|
-
}
|
|
340
|
+
}
|
|
347
341
|
}
|
|
348
342
|
}
|
|
349
343
|
|
package/src/serializer.js
CHANGED
|
@@ -71,8 +71,8 @@ export default class Serializer {
|
|
|
71
71
|
const handler = model[key];
|
|
72
72
|
const data = query(rawData, pathPrefix, subPath);
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
if (
|
|
74
|
+
// Skip fields not present in the update payload (undefined = not provided)
|
|
75
|
+
if (data === undefined && options.update) continue;
|
|
76
76
|
|
|
77
77
|
// Relationship handling
|
|
78
78
|
if (typeof handler === 'function') {
|
|
@@ -86,13 +86,6 @@ 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
|
-
|
|
96
89
|
// Direct assignment handling
|
|
97
90
|
if (handler?.constructor?.name !== 'ModelProperty') {
|
|
98
91
|
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
|
|
44
|
+
// Configure endpoints for models 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,6 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { relationships } from '@stonyx/orm';
|
|
2
2
|
import { TYPES } from './relationships.js';
|
|
3
|
-
import ViewResolver from './view-resolver.js';
|
|
4
3
|
|
|
5
4
|
export default class Store {
|
|
6
5
|
constructor() {
|
|
@@ -29,12 +28,6 @@ export default class Store {
|
|
|
29
28
|
* @returns {Promise<Record|undefined>}
|
|
30
29
|
*/
|
|
31
30
|
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
|
-
|
|
38
31
|
// For memory: true models, the store is authoritative
|
|
39
32
|
if (this._isMemoryModel(modelName)) {
|
|
40
33
|
return this.get(modelName, id);
|
|
@@ -57,18 +50,6 @@ export default class Store {
|
|
|
57
50
|
* @returns {Promise<Record[]>}
|
|
58
51
|
*/
|
|
59
52
|
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
|
-
|
|
72
53
|
// For memory: true models without conditions, return from store
|
|
73
54
|
if (this._isMemoryModel(modelName) && !conditions) {
|
|
74
55
|
const modelStore = this.get(modelName);
|
|
@@ -144,11 +125,6 @@ export default class Store {
|
|
|
144
125
|
}
|
|
145
126
|
|
|
146
127
|
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
|
-
|
|
152
128
|
if (id) return this.unloadRecord(key, id);
|
|
153
129
|
|
|
154
130
|
this.unloadAllRecords(key);
|
package/.claude/views.md
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
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()`
|
package/src/aggregates.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
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/view-resolver.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|