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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ import Orm from '@stonyx/orm';
2
+ import { getMysqlType } from './type-map.js';
3
+ import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
+ import { getPluralName } from '../plural-registry.js';
5
+ import { dbKey } from '../db.js';
6
+ import { AggregateProperty } from '../aggregates.js';
7
+
8
+ function getRelationshipInfo(property) {
9
+ if (typeof property !== 'function') return null;
10
+ const fnStr = property.toString();
11
+
12
+ if (fnStr.includes(`getRelationships('belongsTo',`)) return 'belongsTo';
13
+ if (fnStr.includes(`getRelationships('hasMany',`)) return 'hasMany';
14
+
15
+ return null;
16
+ }
17
+
18
+ export function introspectModels() {
19
+ const { models } = Orm.instance;
20
+ const schemas = {};
21
+
22
+ for (const [modelKey, modelClass] of Object.entries(models)) {
23
+ const name = camelCaseToKebabCase(modelKey.slice(0, -5));
24
+
25
+ if (name === dbKey) continue;
26
+
27
+ const model = new modelClass(modelKey);
28
+ const columns = {};
29
+ const foreignKeys = {};
30
+ const relationships = { belongsTo: {}, hasMany: {} };
31
+ let idType = 'number';
32
+
33
+ const transforms = Orm.instance.transforms;
34
+
35
+ for (const [key, property] of Object.entries(model)) {
36
+ if (key.startsWith('__')) continue;
37
+
38
+ const relType = getRelationshipInfo(property);
39
+
40
+ if (relType === 'belongsTo') {
41
+ relationships.belongsTo[key] = true;
42
+ } else if (relType === 'hasMany') {
43
+ relationships.hasMany[key] = true;
44
+ } else if (property?.constructor?.name === 'ModelProperty') {
45
+ if (key === 'id') {
46
+ idType = property.type;
47
+ } else {
48
+ columns[key] = getMysqlType(property.type, transforms[property.type]);
49
+ }
50
+ }
51
+ }
52
+
53
+ // Build foreign keys from belongsTo relationships
54
+ for (const relName of Object.keys(relationships.belongsTo)) {
55
+ const modelName = camelCaseToKebabCase(relName);
56
+ const fkColumn = `${relName}_id`;
57
+ foreignKeys[fkColumn] = {
58
+ references: getPluralName(modelName),
59
+ column: 'id',
60
+ };
61
+ }
62
+
63
+ schemas[name] = {
64
+ table: getPluralName(name),
65
+ idType,
66
+ columns,
67
+ foreignKeys,
68
+ relationships,
69
+ memory: modelClass.memory !== false, // default true for backward compat
70
+ };
71
+ }
72
+
73
+ return schemas;
74
+ }
75
+
76
+ export function buildTableDDL(name, schema, allSchemas = {}) {
77
+ const { table, idType, columns, foreignKeys } = schema;
78
+ const lines = [];
79
+
80
+ // Primary key
81
+ if (idType === 'string') {
82
+ lines.push(' `id` VARCHAR(255) PRIMARY KEY');
83
+ } else {
84
+ lines.push(' `id` INT AUTO_INCREMENT PRIMARY KEY');
85
+ }
86
+
87
+ // Attribute columns
88
+ for (const [col, mysqlType] of Object.entries(columns)) {
89
+ lines.push(` \`${col}\` ${mysqlType}`);
90
+ }
91
+
92
+ // Foreign key columns
93
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
94
+ const refIdType = getReferencedIdType(fkDef.references, allSchemas);
95
+ lines.push(` \`${fkCol}\` ${refIdType}`);
96
+ }
97
+
98
+ // Timestamps
99
+ lines.push(' `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
100
+ lines.push(' `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
101
+
102
+ // Foreign key constraints
103
+ for (const [fkCol, fkDef] of Object.entries(foreignKeys)) {
104
+ lines.push(` FOREIGN KEY (\`${fkCol}\`) REFERENCES \`${fkDef.references}\`(\`${fkDef.column}\`) ON DELETE SET NULL`);
105
+ }
106
+
107
+ return `CREATE TABLE IF NOT EXISTS \`${table}\` (\n${lines.join(',\n')}\n)`;
108
+ }
109
+
110
+ function getReferencedIdType(tableName, allSchemas) {
111
+ // Look up the referenced table's PK type from schemas
112
+ for (const schema of Object.values(allSchemas)) {
113
+ if (schema.table === tableName) {
114
+ return schema.idType === 'string' ? 'VARCHAR(255)' : 'INT';
115
+ }
116
+ }
117
+
118
+ // Default to INT if referenced table not found in schemas
119
+ return 'INT';
120
+ }
121
+
122
+ export function getTopologicalOrder(schemas) {
123
+ const visited = new Set();
124
+ const order = [];
125
+
126
+ function visit(name) {
127
+ if (visited.has(name)) return;
128
+ visited.add(name);
129
+
130
+ const schema = schemas[name];
131
+ if (!schema) return;
132
+
133
+ // Visit dependencies (belongsTo targets) first
134
+ for (const relName of Object.keys(schema.relationships.belongsTo)) {
135
+ visit(camelCaseToKebabCase(relName));
136
+ }
137
+
138
+ order.push(name);
139
+ }
140
+
141
+ for (const name of Object.keys(schemas)) {
142
+ visit(name);
143
+ }
144
+
145
+ return order;
146
+ }
147
+
148
+ export function introspectViews() {
149
+ const orm = Orm.instance;
150
+ if (!orm.views) return {};
151
+
152
+ const schemas = {};
153
+
154
+ for (const [viewKey, viewClass] of Object.entries(orm.views)) {
155
+ const name = camelCaseToKebabCase(viewKey.slice(0, -4)); // Remove 'View' suffix
156
+
157
+ const source = viewClass.source;
158
+ if (!source) continue;
159
+
160
+ const model = new viewClass(name);
161
+ const columns = {};
162
+ const foreignKeys = {};
163
+ const aggregates = {};
164
+ const relationships = { belongsTo: {}, hasMany: {} };
165
+
166
+ for (const [key, property] of Object.entries(model)) {
167
+ if (key.startsWith('__')) continue;
168
+ if (key === 'id') continue;
169
+
170
+ if (property instanceof AggregateProperty) {
171
+ aggregates[key] = property;
172
+ continue;
173
+ }
174
+
175
+ const relType = getRelationshipInfo(property);
176
+
177
+ if (relType === 'belongsTo') {
178
+ relationships.belongsTo[key] = true;
179
+ const modelName = camelCaseToKebabCase(key);
180
+ const fkColumn = `${key}_id`;
181
+ foreignKeys[fkColumn] = {
182
+ references: getPluralName(modelName),
183
+ column: 'id',
184
+ };
185
+ } else if (relType === 'hasMany') {
186
+ relationships.hasMany[key] = true;
187
+ } else if (property?.constructor?.name === 'ModelProperty') {
188
+ const transforms = Orm.instance.transforms;
189
+ columns[key] = getMysqlType(property.type, transforms[property.type]);
190
+ }
191
+ }
192
+
193
+ schemas[name] = {
194
+ viewName: getPluralName(name),
195
+ source,
196
+ columns,
197
+ foreignKeys,
198
+ aggregates,
199
+ relationships,
200
+ isView: true,
201
+ memory: viewClass.memory !== false ? false : false, // Views default to memory:false
202
+ };
203
+ }
204
+
205
+ return schemas;
206
+ }
207
+
208
+ export function buildViewDDL(name, viewSchema, modelSchemas = {}) {
209
+ if (!viewSchema.source) {
210
+ throw new Error(`View '${name}' must define a source model`);
211
+ }
212
+
213
+ const sourceModelName = viewSchema.source;
214
+ const sourceSchema = modelSchemas[sourceModelName];
215
+ const sourceTable = sourceSchema
216
+ ? sourceSchema.table
217
+ : getPluralName(sourceModelName);
218
+
219
+ const selectColumns = [];
220
+ const joins = [];
221
+ const hasAggregates = Object.keys(viewSchema.aggregates || {}).length > 0;
222
+
223
+ // Source table primary key
224
+ selectColumns.push(`\`${sourceTable}\`.\`id\` AS \`id\``);
225
+
226
+ // Aggregate columns
227
+ for (const [key, aggProp] of Object.entries(viewSchema.aggregates || {})) {
228
+ const relName = aggProp.relationship;
229
+ const relModelName = camelCaseToKebabCase(relName);
230
+ const relTable = getPluralName(relModelName);
231
+
232
+ if (aggProp.aggregateType === 'count') {
233
+ selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`id\`) AS \`${key}\``);
234
+ } else {
235
+ const field = aggProp.field;
236
+ selectColumns.push(`${aggProp.mysqlFunction}(\`${relTable}\`.\`${field}\`) AS \`${key}\``);
237
+ }
238
+
239
+ // Add LEFT JOIN for the relationship if not already added
240
+ const joinKey = `${relTable}`;
241
+ if (!joins.find(j => j.table === joinKey)) {
242
+ // Determine the FK column: the related table has a belongsTo back to the source
243
+ const fkColumn = `${sourceModelName}_id`;
244
+ joins.push({
245
+ table: relTable,
246
+ condition: `\`${relTable}\`.\`${fkColumn}\` = \`${sourceTable}\`.\`id\``
247
+ });
248
+ }
249
+ }
250
+
251
+ // Regular columns (from resolve map string paths or direct attr fields)
252
+ for (const [key, mysqlType] of Object.entries(viewSchema.columns || {})) {
253
+ selectColumns.push(`\`${sourceTable}\`.\`${key}\` AS \`${key}\``);
254
+ }
255
+
256
+ // Build JOIN clauses
257
+ const joinClauses = joins.map(j =>
258
+ `LEFT JOIN \`${j.table}\` ON ${j.condition}`
259
+ ).join('\n ');
260
+
261
+ // Build GROUP BY
262
+ const groupBy = hasAggregates ? `\nGROUP BY \`${sourceTable}\`.\`id\`` : '';
263
+
264
+ const viewName = viewSchema.viewName;
265
+ const sql = `CREATE OR REPLACE VIEW \`${viewName}\` AS\nSELECT\n ${selectColumns.join(',\n ')}\nFROM \`${sourceTable}\`${joinClauses ? '\n ' + joinClauses : ''}${groupBy}`;
266
+
267
+ return sql;
268
+ }
269
+
270
+ export function viewSchemasToSnapshot(viewSchemas) {
271
+ const snapshot = {};
272
+
273
+ for (const [name, schema] of Object.entries(viewSchemas)) {
274
+ snapshot[name] = {
275
+ viewName: schema.viewName,
276
+ source: schema.source,
277
+ columns: { ...schema.columns },
278
+ foreignKeys: { ...schema.foreignKeys },
279
+ isView: true,
280
+ viewQuery: buildViewDDL(name, schema),
281
+ };
282
+ }
283
+
284
+ return snapshot;
285
+ }
286
+
287
+ export function schemasToSnapshot(schemas) {
288
+ const snapshot = {};
289
+
290
+ for (const [name, schema] of Object.entries(schemas)) {
291
+ snapshot[name] = {
292
+ table: schema.table,
293
+ idType: schema.idType,
294
+ columns: { ...schema.columns },
295
+ foreignKeys: { ...schema.foreignKeys },
296
+ };
297
+ }
298
+
299
+ return snapshot;
300
+ }
@@ -0,0 +1,37 @@
1
+ const typeMap = {
2
+ string: 'VARCHAR(255)',
3
+ number: 'INT',
4
+ float: 'FLOAT',
5
+ boolean: 'TINYINT(1)',
6
+ date: 'DATETIME',
7
+ timestamp: 'BIGINT',
8
+ passthrough: 'TEXT',
9
+ trim: 'VARCHAR(255)',
10
+ uppercase: 'VARCHAR(255)',
11
+ ceil: 'INT',
12
+ floor: 'INT',
13
+ round: 'INT',
14
+ };
15
+
16
+ /**
17
+ * Resolves a Stonyx ORM attribute type to a MySQL column type.
18
+ *
19
+ * For built-in types, returns the mapped MySQL type directly.
20
+ *
21
+ * For custom transforms (e.g. an `animal` transform that maps strings to ints):
22
+ * - If the transform function exports a `mysqlType` property, that value is used.
23
+ * Example: `const transform = (v) => codeMap[v]; transform.mysqlType = 'INT'; export default transform;`
24
+ * - Otherwise, defaults to JSON. Values are JSON-stringified on write and
25
+ * JSON-parsed on read. This handles primitives and plain objects correctly.
26
+ * Class instances will be reduced to plain objects — if a custom transform
27
+ * produces class instances, it must declare a `mysqlType` and handle
28
+ * serialization itself.
29
+ */
30
+ export function getMysqlType(attrType, transformFn) {
31
+ if (typeMap[attrType]) return typeMap[attrType];
32
+ if (transformFn?.mysqlType) return transformFn.mysqlType;
33
+
34
+ return 'JSON';
35
+ }
36
+
37
+ export default typeMap;