@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 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
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.2.1-alpha.11",
7
+ "version": "0.2.1-alpha.12",
8
8
  "description": "",
9
9
  "main": "src/main.js",
10
10
  "type": "module",
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(relationship, field) {
68
- return new AggregateProperty('avg', relationship, field);
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(relationship, field) {
72
- return new AggregateProperty('sum', relationship, field);
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(relationship, field) {
76
- return new AggregateProperty('min', relationship, field);
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(relationship, field) {
80
- return new AggregateProperty('max', relationship, field);
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
- // Source table primary key
224
- selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
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
- 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
+ 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
- const field = aggProp.field;
236
- selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
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
- // 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
- });
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
- const groupBy = hasAggregates ? `\nGROUP BY \`${sourceTable}\`.\`id\`` : '';
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,
@@ -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 => {
package/src/view.js CHANGED
@@ -5,6 +5,7 @@ export default class View {
5
5
  static readOnly = true;
6
6
  static pluralName = undefined;
7
7
  static source = undefined;
8
+ static groupBy = undefined;
8
9
  static resolve = undefined;
9
10
 
10
11
  id = attr('number');