@bakery-framework/orm 1.0.0

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,160 @@
1
+ /**
2
+ * Canonicalising a view's `SELECT` so the two sides of the diff can be compared.
3
+ *
4
+ * A view body is compared as **text** — there is no parser here, and there will
5
+ * not be one. That works only if both sides are spelled the same way, and they
6
+ * are not: you write
7
+ *
8
+ * SELECT id, name FROM users WHERE active = 1
9
+ *
10
+ * and MySQL hands back
11
+ *
12
+ * select `p`.`id` AS `id`,`p`.`name` AS `name` from `buzzy`.`users` `p` ...
13
+ *
14
+ * fully qualified, fully quoted, with every alias spelled out. Compare those
15
+ * literally and the view is recreated on every single sync, forever — the same
16
+ * perpetual-churn failure the column diff has hit twice.
17
+ *
18
+ * So everything here must hold two properties, and both are tested:
19
+ *
20
+ * - **Idempotent**: `f(f(x)) === f(x)`.
21
+ * - **Convergent**: `f(what you wrote) === f(what the server returns)`.
22
+ *
23
+ * Which is why this only removes *noise* — quoting, schema qualification,
24
+ * redundant aliases, whitespace. It never reorders, rewrites or reflows
25
+ * anything semantic, because a transformation the server would not also produce
26
+ * is one that makes the two sides differ rather than agree.
27
+ */
28
+
29
+ /**
30
+ * Words that must keep their quoting: unquoting one turns an identifier into
31
+ * syntax. Deliberately small — it only has to cover words a column or table is
32
+ * plausibly *named* after, since anything else was never quoted to begin with.
33
+ */
34
+ const RESERVED = new Set([
35
+ 'SELECT',
36
+ 'FROM',
37
+ 'WHERE',
38
+ 'JOIN',
39
+ 'LEFT',
40
+ 'RIGHT',
41
+ 'INNER',
42
+ 'OUTER',
43
+ 'FULL',
44
+ 'CROSS',
45
+ 'ON',
46
+ 'AS',
47
+ 'AND',
48
+ 'OR',
49
+ 'NOT',
50
+ 'NULL',
51
+ 'IS',
52
+ 'IN',
53
+ 'EXISTS',
54
+ 'GROUP',
55
+ 'BY',
56
+ 'ORDER',
57
+ 'HAVING',
58
+ 'LIMIT',
59
+ 'OFFSET',
60
+ 'ASC',
61
+ 'DESC',
62
+ 'UNION',
63
+ 'ALL',
64
+ 'DISTINCT',
65
+ 'CASE',
66
+ 'WHEN',
67
+ 'THEN',
68
+ 'ELSE',
69
+ 'END',
70
+ 'CAST',
71
+ 'BETWEEN',
72
+ 'LIKE',
73
+ 'WITH',
74
+ 'RECURSIVE',
75
+ 'USING',
76
+ 'VALUES',
77
+ 'INTERVAL',
78
+ ])
79
+
80
+ const PLAIN_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
81
+
82
+ /**
83
+ * Strip the quoting around a plain identifier, leaving anything else alone.
84
+ *
85
+ * Both dialect spellings, because the same schema is compared against whichever
86
+ * server it is on: MySQL returns backticks, Postgres double quotes.
87
+ */
88
+ function unquoteIdentifiers(sql: string): string {
89
+ return sql.replace(/`([^`]+)`|"([^"]+)"/g, (whole, tick, dquote) => {
90
+ const word = tick ?? dquote
91
+ return PLAIN_IDENTIFIER.test(word) && !RESERVED.has(word.toUpperCase())
92
+ ? word
93
+ : whole
94
+ })
95
+ }
96
+
97
+ /**
98
+ * Drop `db.` from `db.table`, for the database the view lives in.
99
+ *
100
+ * MySQL qualifies every table in a stored view with the schema it was created
101
+ * in, so the body carries a hard-coded database name — and the same schema
102
+ * deployed against a differently-named database would then compare unequal and
103
+ * be recreated forever. It is also simply wrong to write down: the view already
104
+ * lives in that database.
105
+ *
106
+ * Only the view's *own* database is stripped. A genuine cross-database
107
+ * reference keeps its qualifier, because there the name is load-bearing.
108
+ */
109
+ function stripOwnSchema(sql: string, database?: string): string {
110
+ if (!database) return sql
111
+ const escaped = database.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
112
+ return sql.replace(new RegExp(`\\b${escaped}\\.(?=[a-zA-Z_\`"])`, 'g'), '')
113
+ }
114
+
115
+ /**
116
+ * `x AS x` is what MySQL writes for every selected column. It says nothing, and
117
+ * you would not have typed it.
118
+ *
119
+ * Matched on the *last* segment so `p.id AS id` collapses too — the qualifier
120
+ * is part of where the value comes from, not of what the output column is
121
+ * called.
122
+ */
123
+ function dropRedundantAliases(sql: string): string {
124
+ return sql.replace(
125
+ /([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
126
+ (whole, expr, alias) => (expr === alias ? expr : whole),
127
+ )
128
+ }
129
+
130
+ /**
131
+ * The canonical form of a view body, for comparison and for writing down.
132
+ *
133
+ * `database` is the schema the view belongs to; pass it and the qualifier goes.
134
+ */
135
+ export function normalizeViewBody(sql: string, database?: string): string {
136
+ if (!sql) return ''
137
+ let out = String(sql).replace(/\s+/g, ' ').trim()
138
+ out = unquoteIdentifiers(out)
139
+ out = stripOwnSchema(out, database)
140
+ out = dropRedundantAliases(out)
141
+ // Qualifier removal can leave `from products`; alias removal can leave a
142
+ // doubled space too. Collapse once more so the result is stable under a
143
+ // second pass — which is what makes this idempotent.
144
+ return out.replace(/\s+/g, ' ').trim()
145
+ }
146
+
147
+ /**
148
+ * The same body, broken across lines for a generated file.
149
+ *
150
+ * Safe only because the comparison collapses whitespace before comparing: this
151
+ * adds newlines and nothing else, so a normalised pretty body and a normalised
152
+ * canonical one are the same string.
153
+ */
154
+ export function formatViewBody(sql: string, database?: string): string {
155
+ const normalized = normalizeViewBody(sql, database)
156
+ return normalized.replace(
157
+ /\s+(from|where|group by|order by|having|limit|left join|right join|inner join|cross join|join|union all|union)\s+/gi,
158
+ (_m, kw) => `\n ${String(kw).toLowerCase()} `,
159
+ )
160
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Template schema — copy this to `schema.ts` to get started:
3
+ *
4
+ * ```sh
5
+ * cp schema.example.ts schema.ts
6
+ * bun run db:sync
7
+ * ```
8
+ *
9
+ * `schema.ts` itself is gitignored: it belongs to the application, and
10
+ * `db:sync` rewrites it (it can generate the file from an existing database
11
+ * with `--choose=db`). This example is tracked so the shape — and, more
12
+ * importantly, the registration block at the bottom — is discoverable in a
13
+ * fresh clone.
14
+ *
15
+ * The framework never imports this file for types. Everything runs and
16
+ * typechecks without it; the ORM is simply untyped (permissive `any`
17
+ * columns) until a schema registers itself.
18
+ */
19
+ import { Field } from '@bakery-framework/orm'
20
+ import type {
21
+ ExtractOptionals,
22
+ ExtractTableTypes,
23
+ ExtractViews,
24
+ } from '@bakery-framework/orm/schema-util'
25
+
26
+ export namespace DBInfo {
27
+ export const constraints = {
28
+ users: {
29
+ id: Field.Primary(),
30
+ // `Varchar` rather than `Text` wherever a length is known: it is the only
31
+ // text form that can carry a default on MySQL, and the width is part of
32
+ // the column diff, so widening one migrates.
33
+ username: Field.Varchar(64, null),
34
+ email: Field.Varchar(255, null),
35
+ password: Field.Varchar(255, null),
36
+ createdAt: Field.Date.now(),
37
+ },
38
+ posts: {
39
+ id: Field.Primary(),
40
+ authorId: Field.Int(null),
41
+ title: Field.Varchar(255, null),
42
+ slug: Field.Varchar(255, null),
43
+ // Sized, not `Text`, because it has a default: MySQL refuses a literal
44
+ // DEFAULT on a TEXT column, so `Field.String('')` here is what previously
45
+ // stopped this template syncing against MySQL at all. Use `Field.Text()`
46
+ // for unbounded text with no default.
47
+ body: Field.Varchar(8192, ''),
48
+ published: Field.Int(0),
49
+ createdAt: Field.Date.now(),
50
+ },
51
+ comments: {
52
+ id: Field.Primary(),
53
+ postId: Field.Int(null),
54
+ authorId: Field.Int(null),
55
+ body: Field.Varchar(8192, ''),
56
+ createdAt: Field.Date.now(),
57
+ },
58
+ } as const
59
+
60
+ export const indexes = {
61
+ usersUsernameUniq: Field.Unique('users', ['username']),
62
+ postsSlugUniq: Field.Unique('posts', ['slug']),
63
+ postsAuthorIdx: Field.Index('posts', ['authorId']),
64
+ commentsPostIdx: Field.Index('comments', ['postId']),
65
+ } as const
66
+
67
+ type C = typeof constraints
68
+ export type Table<T extends keyof C> = ExtractTableTypes<C, T>
69
+ export type Optionals<T extends keyof C> = ExtractOptionals<C, T>
70
+ export type Views = ExtractViews<C>
71
+ }
72
+
73
+ export type DBSchema = {
74
+ [T in keyof typeof DBInfo.constraints]: DBInfo.Table<T>
75
+ }
76
+
77
+ export type DBOptionals = {
78
+ [T in keyof typeof DBInfo.constraints]: DBInfo.Optionals<T>
79
+ }
80
+
81
+ /**
82
+ * Registers this schema with the framework's type system, which is what makes
83
+ * `DB.from('posts')` know its columns. Keep this block — without it the ORM
84
+ * still works, but every table and column is `any`.
85
+ */
86
+ declare module '@bakery-framework/orm/schema-registry' {
87
+ interface SchemaRegistry {
88
+ schema: {
89
+ DBSchema: DBSchema
90
+ DBOptionals: DBOptionals
91
+ Views: DBInfo.Views
92
+ }
93
+ }
94
+ }