@rip-lang/schema 0.2.1

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/emit-sql.js ADDED
@@ -0,0 +1,460 @@
1
+ // ==============================================================================
2
+ // emit-sql.js - Generate SQL DDL from Schema AST
3
+ //
4
+ // Walks the S-expression AST produced by the schema parser and emits clean
5
+ // SQL DDL targeting DuckDB (CREATE TABLE, CREATE INDEX, CREATE TYPE).
6
+ //
7
+ // Usage:
8
+ // import { generateSQL } from '@rip-lang/schema'
9
+ // const sql = generateSQL(ast)
10
+ //
11
+ // Author: Steve Shreeve <steve.shreeve@gmail.com>
12
+ // Date: February 2026
13
+ // ==============================================================================
14
+
15
+ // Schema type → SQL type mapping
16
+ const sqlTypeMap = {
17
+ string: 'VARCHAR',
18
+ text: 'TEXT',
19
+ integer: 'INTEGER',
20
+ number: 'DOUBLE',
21
+ boolean: 'BOOLEAN',
22
+ date: 'DATE',
23
+ datetime: 'TIMESTAMP',
24
+ email: 'VARCHAR',
25
+ url: 'VARCHAR',
26
+ uuid: 'UUID',
27
+ phone: 'VARCHAR',
28
+ json: 'JSON',
29
+ any: 'JSON',
30
+ }
31
+
32
+ // =============================================================================
33
+ // Naming helpers
34
+ // =============================================================================
35
+
36
+ // Convert CamelCase to snake_case
37
+ function toSnakeCase(str) {
38
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Pluralization — ported from Rails ActiveSupport::Inflector
43
+ // ---------------------------------------------------------------------------
44
+
45
+ const UNCOUNTABLES = new Set([
46
+ 'equipment', 'information', 'rice', 'money', 'species',
47
+ 'series', 'fish', 'sheep', 'jeans', 'police', 'data',
48
+ 'feedback', 'metadata', 'media', 'aircraft', 'deer',
49
+ 'moose', 'offspring', 'shrimp', 'swine', 'trout',
50
+ ])
51
+
52
+ const IRREGULARS = new Map([
53
+ ['person', 'people'],
54
+ ['man', 'men'],
55
+ ['woman', 'women'],
56
+ ['child', 'children'],
57
+ ['sex', 'sexes'],
58
+ ['move', 'moves'],
59
+ ['zombie', 'zombies'],
60
+ ['goose', 'geese'],
61
+ ['tooth', 'teeth'],
62
+ ['foot', 'feet'],
63
+ ['mouse', 'mice'],
64
+ ['louse', 'lice'],
65
+ ['ox', 'oxen'],
66
+ ])
67
+
68
+ // Plural rules applied in reverse order (last match wins)
69
+ const PLURAL_RULES = [
70
+ [/$/, 's'],
71
+ [/s$/i, 's'],
72
+ [/^(ax|test)is$/i, '$1es'],
73
+ [/(octop|vir)us$/i, '$1i'],
74
+ [/(octop|vir)i$/i, '$1i'],
75
+ [/(alias|status)$/i, '$1es'],
76
+ [/(bu)s$/i, '$1ses'],
77
+ [/(buffal|tomat)o$/i, '$1oes'],
78
+ [/([ti])um$/i, '$1a'],
79
+ [/([ti])a$/i, '$1a'],
80
+ [/sis$/i, 'ses'],
81
+ [/(?:([^f])fe|([lr])f)$/i, '$1$2ves'],
82
+ [/(hive)$/i, '$1s'],
83
+ [/([^aeiouy]|qu)y$/i, '$1ies'],
84
+ [/(x|ch|ss|sh)$/i, '$1es'],
85
+ [/(matr|vert|ind)(?:ix|ex)$/i, '$1ices'],
86
+ [/(quiz)$/i, '$1zes'],
87
+ ]
88
+
89
+ function pluralize(word) {
90
+ const lower = word.toLowerCase()
91
+
92
+ if (UNCOUNTABLES.has(lower)) return lower
93
+ if (IRREGULARS.has(lower)) return IRREGULARS.get(lower)
94
+
95
+ // Apply rules in reverse order (last rule wins)
96
+ for (let i = PLURAL_RULES.length - 1; i >= 0; i--) {
97
+ const [regex, replacement] = PLURAL_RULES[i]
98
+ if (regex.test(lower)) {
99
+ return lower.replace(regex, replacement)
100
+ }
101
+ }
102
+
103
+ return lower + 's'
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Table and column naming
108
+ // ---------------------------------------------------------------------------
109
+
110
+ // Model name → table name: "User" → "users", "Person" → "people"
111
+ // Accepts optional overrides map: { Person: "people", Datum: "data" }
112
+ let _tableOverrides = {}
113
+
114
+ function toTableName(modelName) {
115
+ if (_tableOverrides[modelName]) return _tableOverrides[modelName]
116
+ return pluralize(toSnakeCase(modelName))
117
+ }
118
+
119
+ // Foreign key: "User" → "user_id"
120
+ function toForeignKeyCol(name) {
121
+ return toSnakeCase(name) + '_id'
122
+ }
123
+
124
+ // Index name: tableName + fields → "idx_users_role_status"
125
+ function toIndexName(tableName, fields) {
126
+ return 'idx_' + tableName + '_' + fields.map(f => toSnakeCase(f)).join('_')
127
+ }
128
+
129
+ // =============================================================================
130
+ // Dependency ordering
131
+ // =============================================================================
132
+
133
+ // Topological sort: models that are referenced via belongs_to come first
134
+ function topologicalSort(models) {
135
+ const byName = new Map()
136
+ for (const def of models) byName.set(def[1], def)
137
+
138
+ const deps = new Map()
139
+ for (const def of models) {
140
+ const name = def[1]
141
+ const body = def[3]
142
+ const refs = []
143
+ if (Array.isArray(body)) {
144
+ for (const member of body) {
145
+ if (Array.isArray(member) && member[0] === 'belongs_to' && byName.has(member[1])) {
146
+ refs.push(member[1])
147
+ }
148
+ }
149
+ }
150
+ deps.set(name, refs)
151
+ }
152
+
153
+ const sorted = []
154
+ const visited = new Set()
155
+ const visiting = new Set()
156
+
157
+ function visit(name) {
158
+ if (visited.has(name)) return
159
+ if (visiting.has(name)) return // circular ref — break cycle
160
+ visiting.add(name)
161
+ for (const dep of deps.get(name) || []) visit(dep)
162
+ visiting.delete(name)
163
+ visited.add(name)
164
+ sorted.push(byName.get(name))
165
+ }
166
+
167
+ for (const def of models) visit(def[1])
168
+ return sorted
169
+ }
170
+
171
+ // =============================================================================
172
+ // SQL value formatting
173
+ // =============================================================================
174
+
175
+ function formatSQLDefault(val) {
176
+ if (val === true) return 'true'
177
+ if (val === false) return 'false'
178
+ if (val === null) return 'NULL'
179
+ if (typeof val === 'number') return String(val)
180
+ if (typeof val === 'string') return `'${val.replace(/'/g, "''")}'`
181
+ if (Array.isArray(val)) {
182
+ if (val[0] === 'object') return "'{}'"
183
+ if (val[0] === 'array') return "'[]'"
184
+ }
185
+ return `'${String(val).replace(/'/g, "''")}'`
186
+ }
187
+
188
+ // =============================================================================
189
+ // Column type resolution
190
+ // =============================================================================
191
+
192
+ function resolveSQLType(type, constraints, enums) {
193
+ // Array types → JSON (DuckDB supports JSON arrays natively)
194
+ if (Array.isArray(type) && type[0] === 'array') {
195
+ return 'JSON'
196
+ }
197
+
198
+ const base = sqlTypeMap[type]
199
+ if (base) {
200
+ // VARCHAR with max length from constraints
201
+ if ((base === 'VARCHAR') && constraints && constraints.length >= 2) {
202
+ return `VARCHAR(${constraints[1]})`
203
+ }
204
+ return base
205
+ }
206
+
207
+ // Enum reference
208
+ if (enums.has(type)) {
209
+ return toSnakeCase(type)
210
+ }
211
+
212
+ // Nested type reference → store as JSON
213
+ return 'JSON'
214
+ }
215
+
216
+ // =============================================================================
217
+ // Enum emission
218
+ // =============================================================================
219
+
220
+ function emitEnumSQL(def) {
221
+ const [, name, values] = def
222
+ const typeName = toSnakeCase(name)
223
+ let members
224
+
225
+ if (Array.isArray(values[0])) {
226
+ // Valued enum: use the member names as SQL enum values
227
+ members = values.map(([member]) => `'${member}'`)
228
+ } else {
229
+ // Simple enum
230
+ members = values.map(v => `'${v}'`)
231
+ }
232
+
233
+ return `CREATE TYPE ${typeName} AS ENUM (${members.join(', ')});`
234
+ }
235
+
236
+ // =============================================================================
237
+ // Table emission
238
+ // =============================================================================
239
+
240
+ function emitTableSQL(def, enums) {
241
+ const [, name, parent, body] = def
242
+ const tableName = toTableName(name)
243
+ const columns = []
244
+ const indexes = []
245
+
246
+ // Auto-generated primary key
247
+ columns.push(' id UUID PRIMARY KEY DEFAULT gen_random_uuid()')
248
+
249
+ if (Array.isArray(body)) {
250
+ for (const member of body) {
251
+ if (!Array.isArray(member)) continue
252
+ const kind = member[0]
253
+
254
+ if (kind === 'field') {
255
+ const col = emitColumnSQL(member, enums)
256
+ if (col) columns.push(col)
257
+
258
+ } else if (kind === 'timestamps') {
259
+ columns.push(' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
260
+ columns.push(' updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP')
261
+
262
+ } else if (kind === 'softDelete') {
263
+ columns.push(' deleted_at TIMESTAMP')
264
+
265
+ } else if (kind === 'belongs_to') {
266
+ const [, target, opts] = member
267
+ const fkCol = toForeignKeyCol(target)
268
+ const refTable = toTableName(target)
269
+ const notNull = isRelationOptional(opts) ? '' : ' NOT NULL'
270
+ columns.push(` ${fkCol} UUID${notNull} REFERENCES ${refTable}(id)`)
271
+
272
+ } else if (kind === 'index') {
273
+ const [, fields, unique] = member
274
+ const sqlFields = fields.map(f => toSnakeCase(f))
275
+ const idxName = toIndexName(tableName, fields)
276
+ const uniqueStr = unique ? 'UNIQUE ' : ''
277
+ indexes.push(`CREATE ${uniqueStr}INDEX ${idxName} ON ${tableName} (${sqlFields.join(', ')});`)
278
+ }
279
+ }
280
+ }
281
+
282
+ const lines = [`CREATE TABLE ${tableName} (`]
283
+ lines.push(columns.join(',\n'))
284
+ lines.push(');')
285
+
286
+ const result = [lines.join('\n')]
287
+ if (indexes.length > 0) {
288
+ result.push(indexes.join('\n'))
289
+ }
290
+
291
+ return result.join('\n\n')
292
+ }
293
+
294
+ function emitColumnSQL(field, enums) {
295
+ const [, name, modifiers, type, fieldConstraints] = field
296
+ const colName = toSnakeCase(name)
297
+ const sqlType = resolveSQLType(type, fieldConstraints, enums)
298
+ const parts = [` ${colName} ${sqlType}`]
299
+
300
+ // NOT NULL for required fields
301
+ if (modifiers?.includes('!')) {
302
+ parts.push('NOT NULL')
303
+ }
304
+
305
+ // UNIQUE for # modifier
306
+ if (modifiers?.includes('#')) {
307
+ parts.push('UNIQUE')
308
+ }
309
+
310
+ // DEFAULT value
311
+ if (fieldConstraints && fieldConstraints.length === 1) {
312
+ parts.push(`DEFAULT ${formatSQLDefault(fieldConstraints[0])}`)
313
+ } else if (fieldConstraints && fieldConstraints.length >= 3) {
314
+ parts.push(`DEFAULT ${formatSQLDefault(fieldConstraints[2])}`)
315
+ }
316
+
317
+ return parts.join(' ')
318
+ }
319
+
320
+ function isRelationOptional(opts) {
321
+ if (!Array.isArray(opts) || opts[0] !== 'object') return false
322
+ for (let i = 1; i < opts.length; i++) {
323
+ if (Array.isArray(opts[i]) && opts[i][0] === 'optional' && opts[i][1] === true) {
324
+ return true
325
+ }
326
+ }
327
+ return false
328
+ }
329
+
330
+ // =============================================================================
331
+ // Links table (universal temporal associations)
332
+ // =============================================================================
333
+
334
+ function hasLinks(models) {
335
+ for (const def of models) {
336
+ const body = def[3]
337
+ if (!Array.isArray(body)) continue
338
+ for (const member of body) {
339
+ if (Array.isArray(member) && member[0] === 'link') return true
340
+ }
341
+ }
342
+ return false
343
+ }
344
+
345
+ function emitLinksTableSQL() {
346
+ const lines = [
347
+ 'CREATE TABLE links (',
348
+ ' id UUID PRIMARY KEY DEFAULT gen_random_uuid(),',
349
+ ' source_type VARCHAR NOT NULL,',
350
+ ' source_id UUID NOT NULL,',
351
+ ' target_type VARCHAR NOT NULL,',
352
+ ' target_id UUID NOT NULL,',
353
+ ' role VARCHAR NOT NULL,',
354
+ ' when_from TIMESTAMP,',
355
+ ' when_till TIMESTAMP,',
356
+ ' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP',
357
+ ');',
358
+ '',
359
+ 'CREATE INDEX idx_links_source ON links (source_type, source_id);',
360
+ 'CREATE INDEX idx_links_target ON links (target_type, target_id);',
361
+ 'CREATE INDEX idx_links_role ON links (role);',
362
+ ]
363
+ return lines.join('\n')
364
+ }
365
+
366
+ // =============================================================================
367
+ // Main entry point
368
+ // =============================================================================
369
+
370
+ /**
371
+ * Generate SQL DDL from a schema AST.
372
+ *
373
+ * @param {Array} ast - The S-expression AST from parse()
374
+ * @param {Object} [options] - Generation options
375
+ * @param {boolean} [options.enums=true] - Emit CREATE TYPE for enums
376
+ * @param {boolean} [options.tables=true] - Emit CREATE TABLE for models
377
+ * @param {boolean} [options.dropFirst=false] - Prepend DROP TABLE IF EXISTS
378
+ * @param {Object} [options.tableNames] - Override table names: { Person: "people" }
379
+ * @param {string} [options.header] - Custom header comment
380
+ * @returns {string} SQL DDL source
381
+ */
382
+ export function generateSQL(ast, options = {}) {
383
+ if (!Array.isArray(ast) || ast[0] !== 'schema') {
384
+ throw new Error('Invalid schema AST: expected ["schema", ...]')
385
+ }
386
+
387
+ const {
388
+ enums: emitEnums = true,
389
+ tables: emitTables = true,
390
+ dropFirst = false,
391
+ tableNames = {},
392
+ header = '-- Generated by @rip-lang/schema — do not edit\n',
393
+ } = options
394
+
395
+ // Set table name overrides for this generation run
396
+ _tableOverrides = tableNames
397
+
398
+ // Collect enum names for type resolution
399
+ const enumNames = new Set()
400
+ for (let i = 1; i < ast.length; i++) {
401
+ if (Array.isArray(ast[i]) && ast[i][0] === 'enum') {
402
+ enumNames.add(ast[i][1])
403
+ }
404
+ }
405
+
406
+ const blocks = []
407
+ if (header) blocks.push(header)
408
+
409
+ // Enums first (tables reference them)
410
+ if (emitEnums) {
411
+ for (let i = 1; i < ast.length; i++) {
412
+ const def = ast[i]
413
+ if (Array.isArray(def) && def[0] === 'enum') {
414
+ blocks.push(emitEnumSQL(def))
415
+ }
416
+ }
417
+ }
418
+
419
+ // Tables
420
+ if (emitTables) {
421
+ // Optionally drop existing tables
422
+ if (dropFirst) {
423
+ const drops = []
424
+ for (let i = 1; i < ast.length; i++) {
425
+ const def = ast[i]
426
+ if (Array.isArray(def) && def[0] === 'model') {
427
+ drops.push(`DROP TABLE IF EXISTS ${toTableName(def[1])} CASCADE;`)
428
+ }
429
+ }
430
+ if (drops.length > 0) {
431
+ blocks.push(drops.join('\n'))
432
+ }
433
+ }
434
+
435
+ // Collect models and sort by dependency order (referenced tables first)
436
+ const models = []
437
+ for (let i = 1; i < ast.length; i++) {
438
+ const def = ast[i]
439
+ if (Array.isArray(def) && def[0] === 'model') models.push(def)
440
+ }
441
+
442
+ const sorted = topologicalSort(models)
443
+ for (const def of sorted) {
444
+ blocks.push(emitTableSQL(def, enumNames))
445
+ }
446
+
447
+ // Auto-generate links table if any model uses @link
448
+ if (hasLinks(models)) {
449
+ if (dropFirst) {
450
+ blocks.push('DROP TABLE IF EXISTS links CASCADE;')
451
+ }
452
+ blocks.push(emitLinksTableSQL())
453
+ }
454
+ }
455
+
456
+ return blocks.join('\n\n') + '\n'
457
+ }
458
+
459
+ export { toSnakeCase, toTableName, pluralize }
460
+ export default generateSQL