@stonyx/orm 0.2.1-alpha.2 → 0.2.1-alpha.21
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/code-style-rules.md +44 -0
- package/.claude/hooks.md +250 -0
- package/.claude/index.md +292 -0
- package/.claude/usage-patterns.md +300 -0
- package/.claude/views.md +292 -0
- package/.github/workflows/ci.yml +5 -25
- package/.github/workflows/publish.yml +24 -116
- package/README.md +461 -15
- package/config/environment.js +29 -6
- package/improvements.md +139 -0
- package/package.json +24 -8
- package/project-structure.md +343 -0
- package/scripts/setup-test-db.sh +21 -0
- package/src/aggregates.js +93 -0
- package/src/belongs-to.js +4 -1
- package/src/commands.js +170 -0
- package/src/db.js +132 -6
- package/src/has-many.js +4 -1
- package/src/hooks.js +124 -0
- package/src/index.js +12 -2
- package/src/main.js +77 -4
- package/src/manage-record.js +30 -4
- package/src/migrate.js +72 -0
- package/src/model-property.js +2 -2
- package/src/model.js +11 -0
- package/src/mysql/connection.js +28 -0
- package/src/mysql/migration-generator.js +286 -0
- package/src/mysql/migration-runner.js +110 -0
- package/src/mysql/mysql-db.js +473 -0
- package/src/mysql/query-builder.js +64 -0
- package/src/mysql/schema-introspector.js +325 -0
- package/src/mysql/type-map.js +37 -0
- package/src/orm-request.js +313 -53
- package/src/plural-registry.js +12 -0
- package/src/record.js +35 -8
- package/src/serializer.js +9 -2
- package/src/setup-rest-server.js +5 -2
- package/src/store.js +130 -1
- package/src/utils.js +1 -1
- package/src/view-resolver.js +183 -0
- package/src/view.js +21 -0
- package/test-events-setup.js +41 -0
- package/test-hooks-manual.js +54 -0
- package/test-hooks-with-logging.js +52 -0
- package/.claude/project-structure.md +0 -578
- package/stonyx-bootstrap.cjs +0 -30
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Usage Patterns
|
|
2
|
+
|
|
3
|
+
## 1. Model Definition
|
|
4
|
+
|
|
5
|
+
Models extend `Model` and use decorators for attributes and relationships:
|
|
6
|
+
|
|
7
|
+
```javascript
|
|
8
|
+
// test/sample/models/animal.js
|
|
9
|
+
import { Model, attr, belongsTo, hasMany } from '@stonyx/orm';
|
|
10
|
+
|
|
11
|
+
export default class AnimalModel extends Model {
|
|
12
|
+
// Attributes with type transforms
|
|
13
|
+
type = attr('animal'); // Custom transform
|
|
14
|
+
age = attr('number'); // Built-in transform
|
|
15
|
+
size = attr('string');
|
|
16
|
+
|
|
17
|
+
// Relationships
|
|
18
|
+
owner = belongsTo('owner'); // Many-to-one
|
|
19
|
+
traits = hasMany('trait'); // One-to-many
|
|
20
|
+
|
|
21
|
+
// Computed properties
|
|
22
|
+
get tag() {
|
|
23
|
+
return `${this.owner.id}'s ${this.size} animal`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Key Points:**
|
|
29
|
+
- Use `attr(type)` for simple attributes
|
|
30
|
+
- Use `belongsTo(modelName)` for many-to-one
|
|
31
|
+
- Use `hasMany(modelName)` for one-to-many
|
|
32
|
+
- Getters work as computed properties
|
|
33
|
+
- Relationships auto-establish bidirectionally
|
|
34
|
+
- Override auto-pluralization with `static pluralName` (see [Overriding Plural Names](#overriding-plural-names))
|
|
35
|
+
|
|
36
|
+
### Overriding Plural Names
|
|
37
|
+
|
|
38
|
+
By default, model names are auto-pluralized (e.g., `animal` → `animals`) for REST routes, JSON:API URLs, and DB table names. When auto-pluralization produces the wrong result, override it with `static pluralName`:
|
|
39
|
+
|
|
40
|
+
```javascript
|
|
41
|
+
import { Model, attr } from '@stonyx/orm';
|
|
42
|
+
|
|
43
|
+
export default class PersonModel extends Model {
|
|
44
|
+
static pluralName = 'people';
|
|
45
|
+
|
|
46
|
+
name = attr('string');
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The override is picked up automatically during ORM initialization — no additional registration is needed. All internal call sites (REST routes, JSON:API type references, MySQL table names, foreign key references) use the overridden value.
|
|
51
|
+
|
|
52
|
+
## 2. Serializers (Data Transformation)
|
|
53
|
+
|
|
54
|
+
Serializers map raw data paths to model properties:
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
// test/sample/serializers/animal.js
|
|
58
|
+
import { Serializer } from '@stonyx/orm';
|
|
59
|
+
|
|
60
|
+
export default class AnimalSerializer extends Serializer {
|
|
61
|
+
map = {
|
|
62
|
+
// Nested path mapping
|
|
63
|
+
age: 'details.age',
|
|
64
|
+
size: 'details.c',
|
|
65
|
+
owner: 'details.location.owner',
|
|
66
|
+
|
|
67
|
+
// Custom transformation function
|
|
68
|
+
traits: ['details', ({ x:color }) => {
|
|
69
|
+
const traits = [{ id: 1, type: 'habitat', value: 'farm' }];
|
|
70
|
+
if (color) traits.push({ id: 2, type: 'color', value: color });
|
|
71
|
+
return traits;
|
|
72
|
+
}]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Key Points:**
|
|
78
|
+
- `map` object defines field mappings
|
|
79
|
+
- Supports nested paths (`'details.age'`)
|
|
80
|
+
- Custom functions for complex transformations
|
|
81
|
+
- Handlers receive raw data subset
|
|
82
|
+
|
|
83
|
+
## 3. Custom Transforms
|
|
84
|
+
|
|
85
|
+
Transforms convert data types:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
// test/sample/transforms/animal.js
|
|
89
|
+
const codeEnumMap = { 'dog': 1, 'cat': 2, 'bird': 3 };
|
|
90
|
+
|
|
91
|
+
export default function(value) {
|
|
92
|
+
return codeEnumMap[value] || 0;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Built-in Transforms:**
|
|
97
|
+
- Type: `boolean`, `number`, `float`, `string`, `date`, `timestamp`
|
|
98
|
+
- Math: `round`, `ceil`, `floor`
|
|
99
|
+
- String: `trim`, `uppercase`
|
|
100
|
+
- Utility: `passthrough`
|
|
101
|
+
|
|
102
|
+
## 4. CRUD Operations
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
import { createRecord, updateRecord, store } from '@stonyx/orm';
|
|
106
|
+
|
|
107
|
+
// Create
|
|
108
|
+
createRecord('owner', { id: 'bob', age: 30 });
|
|
109
|
+
|
|
110
|
+
// Read
|
|
111
|
+
const owner = store.get('owner', 'bob');
|
|
112
|
+
const allOwners = store.get('owner');
|
|
113
|
+
|
|
114
|
+
// Update
|
|
115
|
+
updateRecord(owner, { age: 31 });
|
|
116
|
+
// Or direct: owner.age = 31;
|
|
117
|
+
|
|
118
|
+
// Delete
|
|
119
|
+
store.remove('owner', 'bob');
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 5. Database Schema
|
|
123
|
+
|
|
124
|
+
The DB schema is a Model defining top-level collections:
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
// test/sample/db-schema.js
|
|
128
|
+
import { Model, hasMany } from '@stonyx/orm';
|
|
129
|
+
|
|
130
|
+
export default class DBModel extends Model {
|
|
131
|
+
owners = hasMany('owner');
|
|
132
|
+
animals = hasMany('animal');
|
|
133
|
+
traits = hasMany('trait');
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## 6. Persistence
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
import Orm from '@stonyx/orm';
|
|
141
|
+
|
|
142
|
+
// Save to file
|
|
143
|
+
await Orm.db.save();
|
|
144
|
+
|
|
145
|
+
// Data auto-serializes to JSON file
|
|
146
|
+
// Reload using createRecord with serialize:false, transform:false
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## 7. Access Control
|
|
150
|
+
|
|
151
|
+
```javascript
|
|
152
|
+
// test/sample/access/global-access.js
|
|
153
|
+
export default class GlobalAccess {
|
|
154
|
+
models = ['owner', 'animal']; // or '*' for all
|
|
155
|
+
|
|
156
|
+
access(request) {
|
|
157
|
+
// Deny specific access
|
|
158
|
+
if (request.url.endsWith('/owner/angela')) return false;
|
|
159
|
+
|
|
160
|
+
// Filter collections
|
|
161
|
+
if (request.url.endsWith('/owner')) {
|
|
162
|
+
return record => record.id !== 'angela';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Grant CRUD permissions
|
|
166
|
+
return ['read', 'create', 'update', 'delete'];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 8. REST API (Auto-generated)
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
// Endpoints auto-generated for models:
|
|
175
|
+
// GET /owners - List all
|
|
176
|
+
// GET /owners/:id - Get one
|
|
177
|
+
// POST /animals - Create
|
|
178
|
+
// PATCH /animals/:id - Update (attributes and/or relationships)
|
|
179
|
+
// DELETE /animals/:id - Delete
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**PATCH supports both attributes and relationships:**
|
|
183
|
+
```javascript
|
|
184
|
+
// Update attributes only
|
|
185
|
+
PATCH /animals/1
|
|
186
|
+
{ data: { type: 'animal', attributes: { age: 5 } } }
|
|
187
|
+
|
|
188
|
+
// Update relationship only
|
|
189
|
+
PATCH /animals/1
|
|
190
|
+
{ data: { type: 'animal', relationships: { owner: { data: { type: 'owner', id: 'gina' } } } } }
|
|
191
|
+
|
|
192
|
+
// Update both
|
|
193
|
+
PATCH /animals/1
|
|
194
|
+
{ data: { type: 'animal', attributes: { age: 5 }, relationships: { owner: { data: { type: 'owner', id: 'gina' } } } } }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## 9. Include Parameter (Sideloading)
|
|
198
|
+
|
|
199
|
+
GET endpoints support sideloading related records with **nested relationship traversal**:
|
|
200
|
+
|
|
201
|
+
```javascript
|
|
202
|
+
// Single-level includes
|
|
203
|
+
GET /animals/1?include=owner,traits
|
|
204
|
+
|
|
205
|
+
// Nested includes (NEW!)
|
|
206
|
+
GET /animals/1?include=owner.pets,owner.company
|
|
207
|
+
|
|
208
|
+
// Deep nesting (3+ levels)
|
|
209
|
+
GET /scenes/e001-s001?include=slides.dialogue.character
|
|
210
|
+
|
|
211
|
+
// Response structure (unchanged)
|
|
212
|
+
{
|
|
213
|
+
data: { type: 'animal', id: 1, attributes: {...}, relationships: {...} },
|
|
214
|
+
included: [
|
|
215
|
+
{ type: 'owner', id: 'angela', ... },
|
|
216
|
+
{ type: 'animal', id: 7, ... }, // owner's other pets
|
|
217
|
+
{ type: 'animal', id: 11, ... }, // owner's other pets
|
|
218
|
+
{ type: 'company', id: 'acme', ... } // owner's company (if requested)
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**How Nested Includes Work:**
|
|
224
|
+
1. Query param parsed into path segments: `owner.pets` -> `[['owner'], ['owner', 'pets'], ['traits']]`
|
|
225
|
+
2. `traverseIncludePath()` recursively traverses relationships depth-first
|
|
226
|
+
3. Deduplication still by type+id (no duplicates in included array)
|
|
227
|
+
4. Gracefully handles null/missing relationships at any depth
|
|
228
|
+
5. Each included record gets full `toJSON()` representation
|
|
229
|
+
|
|
230
|
+
**Key Functions:**
|
|
231
|
+
- `parseInclude()` - Splits comma-separated includes and parses nested paths
|
|
232
|
+
- `traverseIncludePath()` - Recursively traverses relationship paths
|
|
233
|
+
- `collectIncludedRecords()` - Orchestrates traversal and deduplication
|
|
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/.claude/views.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
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
|
+
| `groupBy` | `undefined` | Field name to group source records by (one view record per unique value) |
|
|
41
|
+
| `resolve` | `undefined` | Optional escape-hatch map for custom derivations |
|
|
42
|
+
| `memory` | `false` | `false` = computed on demand; `true` = cached on startup |
|
|
43
|
+
| `readOnly` | `true` | **Enforced** — cannot be overridden to `false` |
|
|
44
|
+
| `pluralName` | `undefined` | Custom plural name (same as Model) |
|
|
45
|
+
|
|
46
|
+
## Aggregate Helpers
|
|
47
|
+
|
|
48
|
+
Aggregate helpers define fields that compute values from related records. Each helper knows both its JavaScript computation logic and its MySQL SQL translation.
|
|
49
|
+
|
|
50
|
+
| Helper | JS Behavior | MySQL Translation |
|
|
51
|
+
|--------|-------------|-------------------|
|
|
52
|
+
| `count(relationship)` | `relatedRecords.length` | `COUNT(table.id)` |
|
|
53
|
+
| `avg(relationship, field)` | Average of field values | `AVG(table.field)` |
|
|
54
|
+
| `sum(relationship, field)` | Sum of field values | `SUM(table.field)` |
|
|
55
|
+
| `min(relationship, field)` | Minimum field value | `MIN(table.field)` |
|
|
56
|
+
| `max(relationship, field)` | Maximum field value | `MAX(table.field)` |
|
|
57
|
+
|
|
58
|
+
### Empty/Null Handling
|
|
59
|
+
|
|
60
|
+
- `count` with empty/null relationship → `0`
|
|
61
|
+
- `avg` with empty/null relationship → `0`
|
|
62
|
+
- `sum` with empty/null relationship → `0`
|
|
63
|
+
- `min` with empty/null relationship → `null`
|
|
64
|
+
- `max` with empty/null relationship → `null`
|
|
65
|
+
- Non-numeric values are filtered/treated as 0
|
|
66
|
+
|
|
67
|
+
## Resolve Map (Escape Hatch)
|
|
68
|
+
|
|
69
|
+
For computed fields that can't be expressed as aggregates, use `static resolve`:
|
|
70
|
+
|
|
71
|
+
```javascript
|
|
72
|
+
export default class OwnerStatsView extends View {
|
|
73
|
+
static source = 'owner';
|
|
74
|
+
|
|
75
|
+
static resolve = {
|
|
76
|
+
gender: 'gender', // String path: maps from source record data
|
|
77
|
+
score: (owner) => { // Function: custom computation
|
|
78
|
+
return owner.__data.age * 10;
|
|
79
|
+
},
|
|
80
|
+
nestedVal: 'details.nested', // Nested string paths supported
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Must also define as attr() for the serializer to process
|
|
84
|
+
gender = attr('string');
|
|
85
|
+
score = attr('number');
|
|
86
|
+
nestedVal = attr('passthrough');
|
|
87
|
+
|
|
88
|
+
animalCount = count('pets');
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Important:** Each resolve map entry needs a corresponding `attr()` field definition on the view.
|
|
93
|
+
|
|
94
|
+
## GroupBy Views
|
|
95
|
+
|
|
96
|
+
GroupBy views produce one view record per unique value of a field on the source model, with aggregates computed within each group.
|
|
97
|
+
|
|
98
|
+
### Defining a GroupBy View
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
import { View, attr, count, avg, sum } from '@stonyx/orm';
|
|
102
|
+
|
|
103
|
+
export default class AnimalCountBySizeView extends View {
|
|
104
|
+
static source = 'animal';
|
|
105
|
+
static groupBy = 'size'; // Group animals by their size field
|
|
106
|
+
|
|
107
|
+
id = attr('string'); // The group key becomes the id
|
|
108
|
+
animalCount = count(); // Count records in each group
|
|
109
|
+
averageAge = avg('age'); // Average of 'age' field within each group
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Aggregate Helpers in GroupBy Views
|
|
114
|
+
|
|
115
|
+
In groupBy views, aggregate helpers operate on the group's records rather than on relationships:
|
|
116
|
+
|
|
117
|
+
| Helper | GroupBy Behavior | MySQL Translation |
|
|
118
|
+
|--------|-----------------|-------------------|
|
|
119
|
+
| `count()` | Number of records in the group | `COUNT(*)` |
|
|
120
|
+
| `sum('field')` | Sum of field across group records | `SUM(source.field)` |
|
|
121
|
+
| `avg('field')` | Average of field across group records | `AVG(source.field)` |
|
|
122
|
+
| `min('field')` | Minimum field value in the group | `MIN(source.field)` |
|
|
123
|
+
| `max('field')` | Maximum field value in the group | `MAX(source.field)` |
|
|
124
|
+
|
|
125
|
+
Relationship aggregates (e.g., `count('traits')`) also work — they flatten related records across all group members and aggregate the combined set.
|
|
126
|
+
|
|
127
|
+
### Resolve Map in GroupBy Views
|
|
128
|
+
|
|
129
|
+
The resolve map behaves differently in groupBy views:
|
|
130
|
+
- **Function resolvers** receive the **array of group records** (not a single record)
|
|
131
|
+
- **String path resolvers** take the value from the **first record** in the group
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
export default class LeagueStatsView extends View {
|
|
135
|
+
static source = 'game-stats';
|
|
136
|
+
static groupBy = 'competition';
|
|
137
|
+
|
|
138
|
+
static resolve = {
|
|
139
|
+
totalGoals: (groupRecords) => {
|
|
140
|
+
return groupRecords.reduce((sum, r) => {
|
|
141
|
+
const fs = r.__data.finalScore;
|
|
142
|
+
return fs ? sum + fs[0] + fs[1] : sum;
|
|
143
|
+
}, 0);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
matchCount = count();
|
|
148
|
+
totalGoals = attr('number');
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### MySQL DDL for GroupBy Views
|
|
153
|
+
|
|
154
|
+
```sql
|
|
155
|
+
CREATE OR REPLACE VIEW `animal-count-by-sizes` AS
|
|
156
|
+
SELECT
|
|
157
|
+
`animals`.`size` AS `id`,
|
|
158
|
+
COUNT(*) AS `animalCount`,
|
|
159
|
+
AVG(`animals`.`age`) AS `averageAge`
|
|
160
|
+
FROM `animals`
|
|
161
|
+
GROUP BY `animals`.`size`
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Querying Views
|
|
165
|
+
|
|
166
|
+
Views use the same store API as models:
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
// All view records (computed fresh each call in JSON mode)
|
|
170
|
+
const stats = await store.findAll('owner-stats');
|
|
171
|
+
|
|
172
|
+
// Single view record by source ID
|
|
173
|
+
const stat = await store.find('owner-stats', ownerId);
|
|
174
|
+
|
|
175
|
+
// With conditions
|
|
176
|
+
const filtered = await store.findAll('owner-stats', { animalCount: 5 });
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Read-Only Enforcement
|
|
180
|
+
|
|
181
|
+
Views are strictly read-only at all layers:
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
// All of these throw errors:
|
|
185
|
+
createRecord('owner-stats', data); // Error: Cannot create records for read-only view
|
|
186
|
+
updateRecord(viewRecord, data); // Error: Cannot update records for read-only view
|
|
187
|
+
store.remove('owner-stats', id); // Error: Cannot remove records from read-only view
|
|
188
|
+
|
|
189
|
+
// Internal use only — bypasses guard:
|
|
190
|
+
createRecord('owner-stats', data, { isDbRecord: true }); // Used by resolver/loader
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## REST API Behavior
|
|
194
|
+
|
|
195
|
+
When a view is included in an access configuration, only GET endpoints are mounted:
|
|
196
|
+
|
|
197
|
+
- `GET /view-plural-name` — Returns list of view records
|
|
198
|
+
- `GET /view-plural-name/:id` — Returns single view record
|
|
199
|
+
- `GET /view-plural-name/:id/{relationship}` — Related resources
|
|
200
|
+
- No POST, PATCH, DELETE endpoints
|
|
201
|
+
|
|
202
|
+
## JSON Mode (In-Memory Resolver)
|
|
203
|
+
|
|
204
|
+
In JSON/non-MySQL mode, the ViewResolver:
|
|
205
|
+
|
|
206
|
+
1. Iterates all records of the `source` model from the store
|
|
207
|
+
2. For each source record:
|
|
208
|
+
- Computes aggregate properties from relationships
|
|
209
|
+
- Applies resolve map entries (string paths or functions)
|
|
210
|
+
- Maps regular attr fields from source data
|
|
211
|
+
3. Creates view records via `createRecord` with `isDbRecord: true`
|
|
212
|
+
4. Returns computed array
|
|
213
|
+
|
|
214
|
+
## MySQL Mode
|
|
215
|
+
|
|
216
|
+
In MySQL mode:
|
|
217
|
+
|
|
218
|
+
1. **Schema introspection** generates VIEW metadata via `introspectViews()`
|
|
219
|
+
2. **DDL generation** creates `CREATE OR REPLACE VIEW` SQL from aggregates and relationships
|
|
220
|
+
3. **Queries** use `SELECT * FROM \`view_name\`` just like tables
|
|
221
|
+
4. **Migrations** include `CREATE OR REPLACE VIEW` after table statements
|
|
222
|
+
5. **persist()** is a no-op for views
|
|
223
|
+
6. **loadMemoryRecords()** loads views with `memory: true` from the MySQL VIEW
|
|
224
|
+
|
|
225
|
+
### Generated SQL Example
|
|
226
|
+
|
|
227
|
+
For `OwnerStatsView` with `count('pets')` and `avg('pets', 'age')`:
|
|
228
|
+
|
|
229
|
+
```sql
|
|
230
|
+
CREATE OR REPLACE VIEW `owner-stats` AS
|
|
231
|
+
SELECT
|
|
232
|
+
`owners`.`id` AS `id`,
|
|
233
|
+
COUNT(`animals`.`id`) AS `animalCount`,
|
|
234
|
+
AVG(`animals`.`age`) AS `avgAge`
|
|
235
|
+
FROM `owners`
|
|
236
|
+
LEFT JOIN `animals` ON `animals`.`owner_id` = `owners`.`id`
|
|
237
|
+
GROUP BY `owners`.`id`
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Migration Support
|
|
241
|
+
|
|
242
|
+
Views are handled in migrations alongside tables:
|
|
243
|
+
|
|
244
|
+
- **Added views** → `CREATE OR REPLACE VIEW ...` in UP, `DROP VIEW IF EXISTS ...` in DOWN
|
|
245
|
+
- **Removed views** → Commented `DROP VIEW` warning in UP (matching model removal pattern)
|
|
246
|
+
- **Changed views** → `CREATE OR REPLACE VIEW ...` in UP (replaces automatically)
|
|
247
|
+
- Views appear AFTER table statements in migrations (dependency order)
|
|
248
|
+
- Snapshots include view entries with `isView: true` and `viewQuery`
|
|
249
|
+
|
|
250
|
+
## Memory Flag
|
|
251
|
+
|
|
252
|
+
- `static memory = false` (default) — View records are computed fresh on each query
|
|
253
|
+
- `static memory = true` — View records are loaded from MySQL VIEW on startup and cached
|
|
254
|
+
|
|
255
|
+
## Testing Views
|
|
256
|
+
|
|
257
|
+
```javascript
|
|
258
|
+
import QUnit from 'qunit';
|
|
259
|
+
import { store } from '@stonyx/orm';
|
|
260
|
+
|
|
261
|
+
QUnit.test('view returns computed data', async function(assert) {
|
|
262
|
+
// Create source data
|
|
263
|
+
createRecord('owner', { id: 1, name: 'Alice' }, { serialize: false });
|
|
264
|
+
createRecord('animal', { id: 1, age: 3, owner: 1 }, { serialize: false });
|
|
265
|
+
|
|
266
|
+
// Query the view
|
|
267
|
+
const results = await store.findAll('owner-stats');
|
|
268
|
+
const stat = results.find(r => r.id === 1);
|
|
269
|
+
|
|
270
|
+
assert.strictEqual(stat.__data.animalCount, 1);
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Architecture
|
|
275
|
+
|
|
276
|
+
### Source Files
|
|
277
|
+
|
|
278
|
+
| File | Purpose |
|
|
279
|
+
|------|---------|
|
|
280
|
+
| `src/view.js` | View base class |
|
|
281
|
+
| `src/aggregates.js` | AggregateProperty class + helper functions |
|
|
282
|
+
| `src/view-resolver.js` | In-memory view resolver |
|
|
283
|
+
| `src/mysql/schema-introspector.js` | `introspectViews()`, `buildViewDDL()` |
|
|
284
|
+
| `src/mysql/migration-generator.js` | `diffViewSnapshots()`, view migration generation |
|
|
285
|
+
|
|
286
|
+
### Key Design Decisions
|
|
287
|
+
|
|
288
|
+
1. **View does NOT extend Model** — conceptually separate; shared behavior is minimal
|
|
289
|
+
2. **Driver-agnostic API** — No SQL in view definitions; MySQL driver generates SQL automatically
|
|
290
|
+
3. **Aggregate helpers follow the transform pattern** — Each knows both JS computation and MySQL mapping
|
|
291
|
+
4. **Resolve map mirrors Serializer.map** — String paths or function resolvers
|
|
292
|
+
5. **View schemas are separate** — `introspectViews()` is separate from `introspectModels()`
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -2,35 +2,15 @@ name: CI
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
pull_request:
|
|
5
|
-
branches:
|
|
6
|
-
- dev
|
|
7
|
-
- main
|
|
5
|
+
branches: [dev, main]
|
|
8
6
|
|
|
9
7
|
concurrency:
|
|
10
8
|
group: ci-${{ github.head_ref || github.ref }}
|
|
11
9
|
cancel-in-progress: true
|
|
12
10
|
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
13
14
|
jobs:
|
|
14
15
|
test:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
steps:
|
|
18
|
-
- name: Checkout code
|
|
19
|
-
uses: actions/checkout@v3
|
|
20
|
-
|
|
21
|
-
- name: Setup pnpm
|
|
22
|
-
uses: pnpm/action-setup@v4
|
|
23
|
-
with:
|
|
24
|
-
version: 9
|
|
25
|
-
|
|
26
|
-
- name: Set up Node.js
|
|
27
|
-
uses: actions/setup-node@v3
|
|
28
|
-
with:
|
|
29
|
-
node-version: 24.13.0
|
|
30
|
-
cache: 'pnpm'
|
|
31
|
-
|
|
32
|
-
- name: Install dependencies
|
|
33
|
-
run: pnpm install --frozen-lockfile
|
|
34
|
-
|
|
35
|
-
- name: Run tests
|
|
36
|
-
run: pnpm test
|
|
16
|
+
uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
|