@stonyx/orm 0.2.1-alpha.11 → 0.2.1-alpha.12
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/views.md +71 -0
- package/package.json +1 -1
- package/src/aggregates.js +20 -8
- package/src/mysql/schema-introspector.js +42 -21
- package/src/view-resolver.js +80 -0
- package/src/view.js +1 -0
package/.claude/views.md
CHANGED
|
@@ -37,6 +37,7 @@ export default class OwnerStatsView extends View {
|
|
|
37
37
|
| Property | Default | Description |
|
|
38
38
|
|----------|---------|-------------|
|
|
39
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) |
|
|
40
41
|
| `resolve` | `undefined` | Optional escape-hatch map for custom derivations |
|
|
41
42
|
| `memory` | `false` | `false` = computed on demand; `true` = cached on startup |
|
|
42
43
|
| `readOnly` | `true` | **Enforced** — cannot be overridden to `false` |
|
|
@@ -90,6 +91,76 @@ export default class OwnerStatsView extends View {
|
|
|
90
91
|
|
|
91
92
|
**Important:** Each resolve map entry needs a corresponding `attr()` field definition on the view.
|
|
92
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
|
+
|
|
93
164
|
## Querying Views
|
|
94
165
|
|
|
95
166
|
Views use the same store API as models:
|
package/package.json
CHANGED
package/src/aggregates.js
CHANGED
|
@@ -64,18 +64,30 @@ export function count(relationship) {
|
|
|
64
64
|
return new AggregateProperty('count', relationship);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export function avg(
|
|
68
|
-
|
|
67
|
+
export function avg(relationshipOrField, field) {
|
|
68
|
+
if (field !== undefined) {
|
|
69
|
+
return new AggregateProperty('avg', relationshipOrField, field);
|
|
70
|
+
}
|
|
71
|
+
return new AggregateProperty('avg', undefined, relationshipOrField);
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
export function sum(
|
|
72
|
-
|
|
74
|
+
export function sum(relationshipOrField, field) {
|
|
75
|
+
if (field !== undefined) {
|
|
76
|
+
return new AggregateProperty('sum', relationshipOrField, field);
|
|
77
|
+
}
|
|
78
|
+
return new AggregateProperty('sum', undefined, relationshipOrField);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
export function min(
|
|
76
|
-
|
|
81
|
+
export function min(relationshipOrField, field) {
|
|
82
|
+
if (field !== undefined) {
|
|
83
|
+
return new AggregateProperty('min', relationshipOrField, field);
|
|
84
|
+
}
|
|
85
|
+
return new AggregateProperty('min', undefined, relationshipOrField);
|
|
77
86
|
}
|
|
78
87
|
|
|
79
|
-
export function max(
|
|
80
|
-
|
|
88
|
+
export function max(relationshipOrField, field) {
|
|
89
|
+
if (field !== undefined) {
|
|
90
|
+
return new AggregateProperty('max', relationshipOrField, field);
|
|
91
|
+
}
|
|
92
|
+
return new AggregateProperty('max', undefined, relationshipOrField);
|
|
81
93
|
}
|
|
@@ -193,6 +193,7 @@ export function introspectViews() {
|
|
|
193
193
|
schemas[name] = {
|
|
194
194
|
viewName: getPluralName(name),
|
|
195
195
|
source,
|
|
196
|
+
groupBy: viewClass.groupBy || undefined,
|
|
196
197
|
columns,
|
|
197
198
|
foreignKeys,
|
|
198
199
|
aggregates,
|
|
@@ -219,32 +220,46 @@ export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
|
|
|
219
220
|
const selectColumns = [];
|
|
220
221
|
const joins = [];
|
|
221
222
|
const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
|
|
223
|
+
const groupByField = viewSchema.groupBy;
|
|
222
224
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
+
// ID column: groupBy field or source table PK
|
|
226
|
+
if (groupByField) {
|
|
227
|
+
selectColumns.push(`\`${sourceTable}\`.\`${groupByField}\` AS \`id\``);
|
|
228
|
+
} else {
|
|
229
|
+
selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
|
|
230
|
+
}
|
|
225
231
|
|
|
226
232
|
// Aggregate columns
|
|
227
233
|
for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
+
if (aggProp.relationship === undefined) {
|
|
235
|
+
// Field-level aggregate (groupBy views)
|
|
236
|
+
if (aggProp.aggregateType === 'count') {
|
|
237
|
+
selectColumns.push(`COUNT(*) AS \`${key}\``);
|
|
238
|
+
} else {
|
|
239
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${sourceTable}\`.\`${aggProp.field}\`) AS \`${key}\``);
|
|
240
|
+
}
|
|
234
241
|
} else {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
242
|
+
// Relationship aggregate
|
|
243
|
+
const relName = aggProp.relationship;
|
|
244
|
+
const relModelName = camelCaseToKebabCase(relName);
|
|
245
|
+
const relTable = getPluralName(relModelName);
|
|
246
|
+
|
|
247
|
+
if (aggProp.aggregateType === 'count') {
|
|
248
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`id\`) AS \`${key}\``);
|
|
249
|
+
} else {
|
|
250
|
+
const field = aggProp.field;
|
|
251
|
+
selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
|
|
252
|
+
}
|
|
238
253
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
254
|
+
// Add LEFT JOIN for the relationship if not already added
|
|
255
|
+
const joinKey = `${relTable}`;
|
|
256
|
+
if (!joins.find(j => j.table === joinKey)) {
|
|
257
|
+
const fkColumn = `${sourceModelName}_id`;
|
|
258
|
+
joins.push({
|
|
259
|
+
table: relTable,
|
|
260
|
+
condition: `\`${relTable}\`.\`${fkColumn}\` = \`${sourceTable}\`.\`id\``
|
|
261
|
+
});
|
|
262
|
+
}
|
|
248
263
|
}
|
|
249
264
|
}
|
|
250
265
|
|
|
@@ -259,7 +274,12 @@ export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
|
|
|
259
274
|
).join('\n ');
|
|
260
275
|
|
|
261
276
|
// Build GROUP BY
|
|
262
|
-
|
|
277
|
+
let groupBy = '';
|
|
278
|
+
if (groupByField) {
|
|
279
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`${groupByField}\``;
|
|
280
|
+
} else if (hasAggregates) {
|
|
281
|
+
groupBy = `\nGROUP BY \`${sourceTable}\`.\`id\``;
|
|
282
|
+
}
|
|
263
283
|
|
|
264
284
|
const viewName = viewSchema.viewName;
|
|
265
285
|
const sql = `CREATE OR REPLACE VIEW \`${viewName}\` AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM \`${sourceTable}\`${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
|
|
@@ -274,6 +294,7 @@ export function viewSchemasToSnapshot(viewSchemas) {
|
|
|
274
294
|
snapshot[name] = {
|
|
275
295
|
viewName: schema.viewName,
|
|
276
296
|
source: schema.source,
|
|
297
|
+
...(schema.groupBy ? { groupBy: schema.groupBy } : {}),
|
|
277
298
|
columns: { ...schema.columns },
|
|
278
299
|
foreignKeys: { ...schema.foreignKeys },
|
|
279
300
|
isView: true,
|
package/src/view-resolver.js
CHANGED
|
@@ -39,6 +39,16 @@ export default class ViewResolver {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const groupByField = viewClass.groupBy;
|
|
43
|
+
|
|
44
|
+
if (groupByField) {
|
|
45
|
+
return this._resolveGroupBy(sourceRecords, groupByField, aggregateFields, regularFields, resolveMap, viewClass);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this._resolvePerRecord(sourceRecords, aggregateFields, regularFields, resolveMap, viewClass);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_resolvePerRecord(sourceRecords, aggregateFields, regularFields, resolveMap, viewClass) {
|
|
42
52
|
const results = [];
|
|
43
53
|
|
|
44
54
|
for (const sourceRecord of sourceRecords) {
|
|
@@ -94,6 +104,76 @@ export default class ViewResolver {
|
|
|
94
104
|
return results;
|
|
95
105
|
}
|
|
96
106
|
|
|
107
|
+
_resolveGroupBy(sourceRecords, groupByField, aggregateFields, regularFields, resolveMap, viewClass) {
|
|
108
|
+
// Group source records by the groupBy field value
|
|
109
|
+
const groups = new Map();
|
|
110
|
+
for (const record of sourceRecords) {
|
|
111
|
+
const key = record.__data?.[groupByField] ?? record[groupByField];
|
|
112
|
+
if (!groups.has(key)) {
|
|
113
|
+
groups.set(key, []);
|
|
114
|
+
}
|
|
115
|
+
groups.get(key).push(record);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const results = [];
|
|
119
|
+
|
|
120
|
+
for (const [groupKey, groupRecords] of groups) {
|
|
121
|
+
const rawData = { id: groupKey };
|
|
122
|
+
|
|
123
|
+
// Compute aggregate fields
|
|
124
|
+
for (const [key, aggProp] of Object.entries(aggregateFields)) {
|
|
125
|
+
if (aggProp.relationship === undefined) {
|
|
126
|
+
// Field-level aggregate — compute over group records directly
|
|
127
|
+
rawData[key] = aggProp.compute(groupRecords);
|
|
128
|
+
} else {
|
|
129
|
+
// Relationship aggregate — flatten related records across all group members
|
|
130
|
+
const allRelated = [];
|
|
131
|
+
for (const record of groupRecords) {
|
|
132
|
+
const relatedRecords = record.__relationships?.[aggProp.relationship]
|
|
133
|
+
|| record[aggProp.relationship];
|
|
134
|
+
if (Array.isArray(relatedRecords)) {
|
|
135
|
+
allRelated.push(...relatedRecords);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
rawData[key] = aggProp.compute(allRelated);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Apply resolve map entries — functions receive the group array
|
|
143
|
+
for (const [key, resolver] of Object.entries(resolveMap)) {
|
|
144
|
+
if (typeof resolver === 'function') {
|
|
145
|
+
rawData[key] = resolver(groupRecords);
|
|
146
|
+
} else if (typeof resolver === 'string') {
|
|
147
|
+
// String path — take value from first record in group
|
|
148
|
+
const first = groupRecords[0];
|
|
149
|
+
rawData[key] = get(first.__data || first, resolver)
|
|
150
|
+
?? get(first, resolver);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Map regular attr fields from first record if not already set
|
|
155
|
+
for (const key of Object.keys(regularFields)) {
|
|
156
|
+
if (rawData[key] !== undefined) continue;
|
|
157
|
+
const first = groupRecords[0];
|
|
158
|
+
const sourceValue = first.__data?.[key] ?? first[key];
|
|
159
|
+
if (sourceValue !== undefined) {
|
|
160
|
+
rawData[key] = sourceValue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Clear existing record from store to allow re-resolution
|
|
165
|
+
const viewStore = store.get(this.viewName);
|
|
166
|
+
if (viewStore?.has(rawData.id)) {
|
|
167
|
+
viewStore.delete(rawData.id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const record = createRecord(this.viewName, rawData, { isDbRecord: true });
|
|
171
|
+
results.push(record);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
97
177
|
async resolveOne(id) {
|
|
98
178
|
const all = await this.resolveAll();
|
|
99
179
|
return all.find(record => {
|