Skip to content

Commit 68ed497

Browse files
docs: sync from coderbuzz/codex@b8d6f33
1 parent c6f86a1 commit 68ed497

2 files changed

Lines changed: 128 additions & 9 deletions

File tree

‎AI_KNOWLEDGE.md‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!-- docs: sync from coderbuzz/codex@d28b4e9 -->
1+
<!-- docs: sync from coderbuzz/codex@b8d6f33 -->
22

33
# @coderbuzz/sql — AI Expert Knowledge Reference
44

@@ -302,8 +302,29 @@ for (const stmt of stmts) {
302302
}
303303
```
304304

305-
`applyDiff()` supports: ADD COLUMN (all dialects), DROP COLUMN (PG/MySQL/MSSQL),
306-
ALTER COLUMN (PG/MySQL/MSSQL). SQLite skips DROP/ALTER with a `console.warn`.
305+
`applyDiff()` supports: RENAME COLUMN (all dialects), ADD COLUMN (all dialects),
306+
DROP COLUMN (PG/MySQL/MSSQL), ALTER COLUMN (PG/MySQL/MSSQL). SQLite skips
307+
DROP/ALTER with a `console.warn`.
308+
309+
**Renaming a column.** `diff()` cannot infer a rename — `memo` disappearing and
310+
`keterangan` appearing is indistinguishable from a genuine drop-and-add, and
311+
guessing means sometimes emitting `ALTER ... RENAME` for a column that should
312+
have been dropped, keeping data that was meant to go under a name that now means
313+
something else. Declare it on the schema:
314+
315+
```ts
316+
new SqlTable('journal', { keterangan: pg.varchar(255).renamedFrom('memo') })
317+
```
318+
319+
`diff()` then fills `TableDiff.renameColumns` instead of producing an add plus a
320+
drop, and `applyDiff()` emits
321+
`ALTER TABLE journal RENAME COLUMN memo TO keterangan` **before** any
322+
`ADD COLUMN`, so the data moves with the name. A rename that also changes the
323+
column's type emits both statements. The annotation is inert once the old name
324+
is gone from the database, so it is safe to leave in place until every
325+
environment has migrated. `renameColumns` is optional on `TableDiff`, so a
326+
hand-built diff still compiles. The annotation survives the other column
327+
modifiers (`.notNull()`, `.index()`, …).
307328

308329
---
309330

@@ -351,6 +372,8 @@ const rows = await db.select("u.id", "u.name", "p.title")
351372

352373
### Joins
353374

375+
**Untyped** — raw strings, validated by the identifier check:
376+
354377
```ts
355378
db.select("u.id", "p.title")
356379
.from("users u")
@@ -360,6 +383,50 @@ db.select("u.id", "p.title")
360383
// .full_join() — NOT supported by SQLite, MySQL, ClickHouse
361384
```
362385

386+
**Typed** — from a `SqlTable`, stated as column pairs:
387+
388+
```ts
389+
journalLines.from(db)
390+
.join(accounts, { left: 'account_id', right: 'id' }) // INNER
391+
.leftJoin(entries, { left: 'entry_id', right: 'id' }) // LEFT
392+
.rightJoin(table, on) // RIGHT
393+
.fullJoin(table, on) // FULL
394+
```
395+
396+
```ts
397+
type JoinOn<L, R> =
398+
| { left: keyof L & string; right: keyof R & string }
399+
| ReadonlyArray<{ left: keyof L & string; right: keyof R & string }>
400+
```
401+
402+
`left` is a column of the query so far — the base table or anything already
403+
joined — `right` a column of the table being joined. Both are checked against
404+
their schemas: a typo is a compile error, and there is no string left for
405+
anything else to end up inside. An array of pairs joins them with `AND`.
406+
407+
**Result types track outer-join nullability**, which is the part worth having:
408+
409+
| | Result |
410+
|---|---|
411+
| `join` | `TResult & InferRow<S2>` |
412+
| `leftJoin` | `TResult & Nullable<InferRow<S2>>` |
413+
| `rightJoin` | `Nullable<TResult> & InferRow<S2>` |
414+
| `fullJoin` | `Nullable<TResult> & Nullable<InferRow<S2>>` |
415+
416+
`order_by()` and `group_by()` on a typed query accept columns of the base table
417+
**and** of everything joined; `order_by` also takes `[column, 'ASC' | 'DESC']`. A
418+
raw string still works — the validator still runs on it — so nothing existing
419+
breaks.
420+
421+
Why this exists: `SqlTable.from()` gave a typed query, but `TypedSelectQuery`
422+
inherited `left_join`/`order_by`/`group_by` from `SelectQuery` unchanged, so the
423+
first join dropped you back to raw strings and took the joined table's column
424+
types with it. The identifier validator had already closed the injection surface;
425+
this makes the safe path the convenient one.
426+
427+
Dialect limits still apply: SQLite, MySQL and ClickHouse reject `fullJoin` at
428+
compile time, the same as `.full_join()`.
429+
363430
### CTE
364431
365432
```ts

‎README.md‎

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!-- docs: sync from coderbuzz/codex@d28b4e9 -->
1+
<!-- docs: sync from coderbuzz/codex@b8d6f33 -->
22

33
# @coderbuzz/sql
44

@@ -272,15 +272,33 @@ for (const stmt of stmts) {
272272
| MSSQL | ✓ | opt-in | ✓ |
273273

274274
`diff()` compares type, nullability, uniqueness, primary key **and default
275-
value**. It cannot tell a rename from a drop-plus-add, so `applyDiff()` omits
276-
`DROP COLUMN` unless you ask for it:
275+
value**. `applyDiff()` omits `DROP COLUMN` unless you ask for it:
277276

278277
```ts
279278
applyDiff(diffs, db, { allowDestructive: true });
280279
```
281280

282-
Read the statements before enabling it. `ALTER TABLE ... DROP COLUMN` cannot be
283-
undone once committed, and a renamed column arrives here as a drop.
281+
Read the statements before enabling it — `ALTER TABLE ... DROP COLUMN` cannot be
282+
undone once committed.
283+
284+
**Renaming a column.** A rename is not something two schemas can reveal: `memo`
285+
disappearing and `keterangan` appearing looks identical whether it is a rename or
286+
a genuine drop-and-add. Guessing means sometimes emitting `ALTER ... RENAME` for
287+
a column that should have been dropped, keeping data that was meant to go under a
288+
name that now means something else. So say it:
289+
290+
```ts
291+
const journal = new SqlTable("journal", {
292+
keterangan: pg.varchar(255).renamedFrom("memo"),
293+
});
294+
```
295+
296+
`diff()` then produces `renameColumns` instead of an add plus a drop, and
297+
`applyDiff()` emits `ALTER TABLE journal RENAME COLUMN memo TO keterangan` —
298+
before any `ADD COLUMN`, so the data moves with the name. If the rename also
299+
changes the column's type, both statements are emitted. Once the migration has
300+
run everywhere, drop the annotation: with the old name gone from the database,
301+
it does nothing.
284302

285303
### Running migrations safely
286304

@@ -324,7 +342,7 @@ const rows = await db.select_distinct("country").from("users").execute();
324342

325343
// JOIN
326344
const rows = await db.select("u.id", "u.email", "p.title")
327-
.from("users u").left_join("posts p", "p.user_id = u.id")
345+
.from("users u").left_join("posts p", "p.user_id = u.id") // untyped; see join() below
328346
.where(pg.eq("u.active", true)).execute();
329347

330348
// GROUP BY / HAVING
@@ -351,6 +369,40 @@ console.log(compiled.sql); // "SELECT "id", "email" FROM users WHERE active = ?"
351369
console.log(compiled.params); // [true]
352370
```
353371

372+
### Typed joins
373+
374+
`SqlTable.from(db)` gives a typed query — but until now the first `left_join()`
375+
dropped you back to raw strings and took the joined table's column types with it.
376+
The typed form states the join as column pairs:
377+
378+
```ts
379+
const rows = await journalLines.from(db)
380+
.join(accounts, { left: "account_id", right: "id" })
381+
.leftJoin(entries, { left: "entry_id", right: "id" })
382+
.order_by(["amount", "DESC"])
383+
.execute();
384+
```
385+
386+
| | |
387+
|---|---|
388+
| `join(table, on)` | INNER JOIN. Row type gains the joined table's columns |
389+
| `leftJoin(table, on)` | LEFT JOIN. **The joined columns become nullable** — which is what a LEFT JOIN produces for an unmatched row |
390+
| `rightJoin(table, on)` | RIGHT JOIN. The base table's columns become nullable instead |
391+
| `fullJoin(table, on)` | FULL JOIN. Both sides become nullable |
392+
393+
`on` is `{ left, right }`, or an array of them for a composite key. `left` is a
394+
column of the query so far — the base table or anything already joined — and
395+
`right` a column of the table being joined. Both are checked against their
396+
schemas, so a typo is a compile error, and there is no string for anything else
397+
to end up inside.
398+
399+
`order_by()` and `group_by()` on a typed query accept the columns the query can
400+
actually see, including the joined ones. `order_by` also takes
401+
`[column, "ASC" | "DESC"]`. A raw string still works and is still validated by
402+
the identifier check, so nothing existing breaks.
403+
404+
The untyped `left_join(table: string, on: string)` family is unchanged.
405+
354406
### Explain Query
355407

356408
```ts

0 commit comments

Comments
 (0)