Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,58 @@ The predicate is part of the index's identity: change the `where` clause and the

---

## Schema comments

Schema-level prose lives in the database, not just the code. PostgreSQL and MySQL both expose comments on tables and columns; sumak surfaces both via the schema DSL and threads the value through `diffSchemas` so a comment edit shows up as a normal additive migration step.

Declare on the column or table:

```ts
const users = defineTable(
"users",
{
id: serial().primaryKey(),
email: text().notNull().comment("Primary contact; case-folded on insert"),
deletedAt: timestamp().nullable().comment("NULL = live; set by softDelete plugin"),
},
{
comment: "User accounts (renamed from old_users in v1.2)",
},
)
```

Emitted SQL (PG — two statements per object):

```sql
CREATE TABLE "users" (
"id" SERIAL PRIMARY KEY,
"email" text NOT NULL,
"deletedAt" timestamp
);
COMMENT ON TABLE "users" IS 'User accounts (renamed from old_users in v1.2)';
COMMENT ON COLUMN "users"."email" IS 'Primary contact; case-folded on insert';
COMMENT ON COLUMN "users"."deletedAt" IS 'NULL = live; set by softDelete plugin';
```

MySQL inlines the column comment and uses `ALTER TABLE` for the table-level form:

```sql
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTO_INCREMENT,
`email` text NOT NULL COMMENT 'Primary contact; case-folded on insert',
`deletedAt` timestamp COMMENT 'NULL = live; set by softDelete plugin'
);
ALTER TABLE `users` COMMENT = 'User accounts (renamed from old_users in v1.2)';
```

Editing a comment after the table already exists is metadata-only — the diff never trips the destructive-gate. Passing a `null` comment to `diffSchemas`' machinery (via dropping the `.comment(...)` call on the column) emits `COMMENT ON … IS NULL` on PG and `ALTER TABLE … COMMENT = ''` on MySQL.

Single quotes in the comment text are escaped automatically (doubled `''`), so `text().comment("Alice's note")` prints `COMMENT 'Alice''s note'` on every supported dialect.

**Dialect support.** PG and MySQL only. SQLite has no portable equivalent — its grammar accepts the keyword in some dialects but treats it as a no-op comment in the DDL text, which is silent-loss territory. MSSQL exposes object metadata via the separate `sp_addextendedproperty` stored procedure, which is a completely different surface; sumak refuses to bridge it under the `COMMENT ON` builder. Compile a `CommentNode` against SQLite or MSSQL and `compileDDL` throws `UnsupportedDialectFeatureError` (`OBJECT_COMMENTS` feature flag). MySQL also refuses the standalone _column_-comment form because the underlying `ALTER TABLE … MODIFY COLUMN` requires the column's full type at modification time — use the inline `.comment("…")` on the column when defining the table instead.

---

## Multi-tenant scoping

```ts
Expand Down
42 changes: 42 additions & 0 deletions src/ast/ddl-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export interface ColumnDefinitionNode {
expression: ExpressionNode
stored?: boolean
}
/**
* Optional human-readable comment attached to the column. PG and MySQL
* both support comments on columns. On MySQL the comment is emitted
* **inline** in `CREATE TABLE` (`<col_def> COMMENT 'text'`); on PG the
* `CREATE TABLE` syntax has no inline form and the DDL printer leaves
* this field out of `CREATE TABLE` — the diff engine emits a separate
* {@link CommentNode} instead. SQLite has no equivalent at all (its
* SQL grammar accepts the keyword but only as a no-op comment in the
* DDL text); MSSQL uses `sp_addextendedproperty`, a separate surface
* we don't bridge. See {@link FEATURES.OBJECT_COMMENTS}.
*/
comment?: string
}

// ── Table Constraints ──
Expand Down Expand Up @@ -229,6 +241,35 @@ export interface DropSchemaNode {
cascade?: boolean
}

// ── COMMENT ON TABLE / COLUMN ──

/**
* Standalone object-comment statement — PG's `COMMENT ON TABLE` /
* `COMMENT ON COLUMN`, also lowered to MySQL's `ALTER TABLE … COMMENT
* = 'text'` for table comments at print time. Used by the migration
* diff engine when a comment is added, changed, or cleared on an
* already-existing schema object; new tables fold the comment back
* into the per-column field on MySQL and emit a follow-up CommentNode
* on PG.
*
* - `target: "table"` → comment refers to `tableName`; `columnName`
* must be undefined.
* - `target: "column"` → comment refers to `tableName.columnName`;
* `columnName` is required.
* - `comment: null` → drop the comment (PG emits `IS NULL`; MySQL
* emits `COMMENT = ''` for the table form).
*
* SQLite has no equivalent and the DDL printer refuses. MSSQL uses
* `sp_addextendedproperty` — also refused for the first cut.
*/
export interface CommentNode {
type: "comment_on"
target: "table" | "column"
tableName: string
columnName?: string
comment: string | null
}

// ── Union of all DDL nodes ──

export type DDLNode =
Expand All @@ -242,3 +283,4 @@ export type DDLNode =
| TruncateTableNode
| CreateSchemaNode
| DropSchemaNode
| CommentNode
11 changes: 11 additions & 0 deletions src/dialect/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,17 @@ export const FEATURES = {
GIST_INDEX: { label: "GIST index", dialects: ["pg"] },
PARTIAL_INDEX: { label: "partial index (WHERE)", dialects: ["pg", "sqlite"] },
CASCADE_DROP: { label: "DROP ... CASCADE", dialects: ["pg"] },
/**
* `COMMENT ON TABLE` / `COMMENT ON COLUMN` (PG syntax) and the
* equivalent MySQL forms (`ALTER TABLE … COMMENT = '…'` for table
* comments; inline `<col_def> COMMENT '…'` for column comments
* inside `CREATE TABLE`). PG and MySQL only — MSSQL uses the
* separate `sp_addextendedproperty` stored procedure (out of scope)
* and SQLite has no equivalent at all (the keyword is accepted as a
* no-op in some grammars but not portably). The DDL printer refuses
* on the unsupported dialects rather than emit silent no-ops.
*/
OBJECT_COMMENTS: { label: "COMMENT ON TABLE / COLUMN", dialects: ["pg", "mysql"] },

// ── TCL (transactions) ────────────────────────────────────────────
TX_ISOLATION_INLINE: {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export type {
} from "./ast/nodes.ts"

// ─── DDL AST types (for typing `db.compileDDL` / custom DDL flows) ─────────
export type { DDLNode, CreateSchemaNode, DropSchemaNode } from "./ast/ddl-nodes.ts"
export type { CommentNode, CreateSchemaNode, DDLNode, DropSchemaNode } from "./ast/ddl-nodes.ts"

// QueryFlags — builder-intent bitmap surfaced on SELECT/UPDATE/DELETE nodes.
export { QueryFlags } from "./ast/nodes.ts"
Expand Down
105 changes: 103 additions & 2 deletions src/migrate/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
AlterTableAction,
AlterTableNode,
ColumnDefinitionNode,
CommentNode,
CreateIndexNode,
CreateTableNode,
DDLNode,
Expand Down Expand Up @@ -212,6 +213,19 @@ export function diffSchemas(
}
}

// ── COMMENT ON for newly created tables ───────────────────────
// PG's `CREATE TABLE` syntax has no inline comment form, so a table-
// or column-level comment on a freshly created table is emitted as a
// follow-up `COMMENT ON …` statement. MySQL's DDL printer reads the
// inline column-level comment off `ColumnDefinitionNode.comment`
// directly (set by `columnDefinitionFromBuilder`) and ignores these
// CommentNodes for table-comments-on-create. (Table-comment on
// create still emits a separate node — MySQL's printer rewrites it
// to `ALTER TABLE … COMMENT = …`; on PG it's the canonical form.)
for (const t of createdInOrder) {
additive.push(...commentNodesForTable(t, undefined, afterNorm[t]!))
}

// ── ALTER (per shared table) ──────────────────────────────────
const columnRenames = opts.renames?.columns ?? {}
// Normalize typeMigrations entries to bare `ExpressionNode` — the
Expand Down Expand Up @@ -409,9 +423,88 @@ function diffTable(
} satisfies AlterTableNode)
}
for (const n of indexDelta.added) result.additive.push(n)

// ── COMMENT ON diff for the shared table ──────────────────────
// Comment changes never qualify as destructive — they're metadata
// only — so they always land on the additive side.
for (const n of commentNodesForTable(name, before, after)) result.additive.push(n)
return result
}

/**
* Compute the {@link CommentNode}s needed to bring the comment state
* of a single table from `before` to `after`. Handles both the
* table-level comment and per-column comments (only for columns that
* appear in both before and after — added/removed columns carry their
* comments through the create/alter column path on MySQL and through
* a follow-up CommentNode on PG via the "created table" branch).
*
* Passing `before === undefined` switches the function into "freshly
* created table" mode: every non-empty comment in `after` is emitted
* as a CommentNode.
*
* Returns `[]` when the comment state already matches — including
* "both sides unset," "both sides equal," and "MySQL inline-only
* column comments without a table change" (the inline form lives on
* `ColumnDefinitionNode.comment` instead, handled at print time).
*/
function commentNodesForTable(
name: string,
before: NormalizedTable | undefined,
after: NormalizedTable,
): CommentNode[] {
const out: CommentNode[] = []
// Table-level comment.
const beforeTableComment = before?.comment
const afterTableComment = after.comment
if (beforeTableComment !== afterTableComment) {
out.push({
type: "comment_on",
target: "table",
tableName: name,
comment: afterTableComment ?? null,
})
}

// Per-column comments. On the create path (before === undefined) we
// emit a CommentNode for every column that has a non-empty comment.
// On the alter path we only emit when the value changed; columns that
// exist on only one side are out of scope here (their comment travels
// with the add/drop column action's column definition).
if (before === undefined) {
for (const [col, builder] of Object.entries(after.columns)) {
const c = builder._def.comment
if (c !== undefined) {
out.push({
type: "comment_on",
target: "column",
tableName: name,
columnName: col,
comment: c,
})
}
}
return out
}

for (const [col, builder] of Object.entries(after.columns)) {
const beforeBuilder = before.columns[col]
if (!beforeBuilder) continue // added column — comment travels with the add_column action
const beforeComment = beforeBuilder._def.comment
const afterComment = builder._def.comment
if (beforeComment !== afterComment) {
out.push({
type: "comment_on",
target: "column",
tableName: name,
columnName: col,
comment: afterComment ?? null,
})
}
}
return out
}

// ── Index diff / materialization ──────────────────────────────────────

function diffIndexes(
Expand Down Expand Up @@ -581,6 +674,12 @@ function columnDefinitionFromBuilder(
? { expression: def.generated.expression }
: { expression: def.generated.expression, stored: def.generated.stored }
}
// Per-column comment. The MySQL DDL printer reads this inline inside
// `CREATE TABLE`; the PG printer leaves it out of `CREATE TABLE` and
// the diff engine emits a follow-up CommentNode in its place (see
// commentNodesForTable). Setting the field on both paths is fine —
// the inline-vs-standalone choice happens at print time.
if (def.comment !== undefined) node.comment = def.comment
return node
}

Expand Down Expand Up @@ -717,11 +816,11 @@ function normalizeSchema(schema: SchemaDef): Record<string, NormalizedTable> {
const out: Record<string, NormalizedTable> = {}
for (const [name, entry] of Object.entries(schema)) {
if (isTableDefinition(entry)) {
out[name] = buildNormalized(entry.columns, entry.constraints, entry.indexes)
out[name] = buildNormalized(entry.columns, entry.constraints, entry.indexes, entry.comment)
continue
}
if (isNormalizedTable(entry)) {
out[name] = buildNormalized(entry.columns, entry.constraints, entry.indexes)
out[name] = buildNormalized(entry.columns, entry.constraints, entry.indexes, entry.comment)
continue
}
out[name] = { columns: entry }
Expand All @@ -733,10 +832,12 @@ function buildNormalized(
columns: Record<string, ColumnBuilder<any, any, any>>,
constraints: TableConstraints | undefined,
indexes: readonly IndexDef[] | undefined,
comment: string | undefined,
): NormalizedTable {
const out: NormalizedTable = { columns }
if (constraints) (out as { constraints?: TableConstraints }).constraints = constraints
if (indexes) (out as { indexes?: readonly IndexDef[] }).indexes = indexes
if (comment !== undefined) (out as { comment?: string }).comment = comment
return out
}

Expand Down
54 changes: 54 additions & 0 deletions src/printer/ddl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AlterTableNode,
ColumnDefinitionNode,
CommentNode,
CreateIndexNode,
CreateSchemaNode,
CreateTableNode,
Expand Down Expand Up @@ -67,6 +68,8 @@ export class DDLPrinter {
return this.printCreateSchema(node)
case "drop_schema":
return this.printDropSchema(node)
case "comment_on":
return this.printCommentOn(node)
}
}

Expand Down Expand Up @@ -208,6 +211,16 @@ export class DDLPrinter {
parts.push("GENERATED ALWAYS AS", `(${this.printExpr(col.generatedAs.expression)})`)
if (col.generatedAs.stored) parts.push("STORED")
}
// Inline column comment — MySQL only. PG has no inline form in
// `CREATE TABLE`; the diff engine emits a follow-up `COMMENT ON
// COLUMN` statement instead, so we leave the field alone on PG.
// SQLite / MSSQL silently drop the inline comment (they're refused
// at the standalone-CommentNode path; including the inline form in
// CREATE TABLE would be a parse error or a no-op depending on the
// engine, so we omit on those dialects too).
if (col.comment !== undefined && this.dialect === "mysql") {
parts.push("COMMENT", `'${escapeStringLiteral(col.comment)}'`)
}
return parts.join(" ")
}

Expand Down Expand Up @@ -583,6 +596,47 @@ export class DDLPrinter {
return parts.join(" ")
}

private printCommentOn(node: CommentNode): string {
// PG and MySQL only — SQLite has no equivalent, MSSQL uses the
// separate `sp_addextendedproperty` stored-procedure surface that
// sumak doesn't bridge. Refuse loudly rather than ship a no-op or
// half-correct SQL.
if (this.dialect === "sqlite" || this.dialect === "mssql") {
// MSSQL gets a pointer at the right escape hatch in the error
// message; SQLite has no equivalent at all and the generic label
// covers it.
assertFeature(this.dialect, "OBJECT_COMMENTS")
}
const literal = node.comment === null ? "NULL" : `'${escapeStringLiteral(node.comment)}'`

if (this.dialect === "pg") {
if (node.target === "table") {
return `COMMENT ON TABLE ${quoteIdentifier(node.tableName, this.dialect)} IS ${literal}`
}
// Column comment: requires the column name.
if (!node.columnName) {
throw new Error("CommentNode target='column' requires columnName — got undefined.")
}
return `COMMENT ON COLUMN ${quoteIdentifier(node.tableName, this.dialect)}.${quoteIdentifier(node.columnName, this.dialect)} IS ${literal}`
}

// MySQL path. For table comments the idiomatic form is `ALTER
// TABLE … COMMENT = '…'`. For column comments MySQL has no
// standalone statement — `ALTER TABLE … MODIFY COLUMN <col> <type>
// COMMENT '…'` requires knowing the column's current type, which
// we don't carry through to this layer. Refuse at print time and
// point the caller at the inline form on CREATE TABLE / the
// typed-builder ALTER COLUMN path (future work).
if (node.target === "table") {
const valueLiteral = node.comment === null ? "''" : `'${escapeStringLiteral(node.comment)}'`
return `ALTER TABLE ${quoteIdentifier(node.tableName, this.dialect)} COMMENT = ${valueLiteral}`
}
throw new UnsupportedDialectFeatureError(
"mysql",
"standalone COMMENT ON COLUMN (MySQL requires ALTER TABLE … MODIFY COLUMN <name> <type> COMMENT '…' with the column's full type; use the inline `.comment(\"…\")` form on the column when defining the table instead)",
)
}

private printExpr(node: import("../ast/nodes.ts").ExpressionNode): string {
// DDL expression contexts: CHECK, DEFAULT, GENERATED ALWAYS AS,
// partial-index WHERE. None of these go through param binding —
Expand Down
Loading
Loading