1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
//! gaps3 #43 — `#[umbral(help = "...")]` reaches the database as a column comment.
//!
//! Help text already flowed to the OpenAPI `description`, the admin form hint,
//! and (since gaps3 #38) the generated TypeScript TSDoc. The one audience that
//! never saw it was the person holding a `psql` prompt — the place people
//! actually go when they're asking "what is this column for?".
//!
//! Postgres has `COMMENT ON COLUMN`. SQLite has no comment facility at all, so
//! the same migration renders to zero statements there; that is an absence of a
//! *display* feature, not a schema divergence — the columns, types, constraints
//! and rows are identical on both backends.
//!
//! (MySQL, which the entry also asks about, is not a backend umbral ships. The
//! renderer panics on any name other than `sqlite` / `postgres`.)
//!
//! These drive the real `render_operation_for` and the real `diff`, so nothing
//! here is a mock of the migration engine.
use umbral::migrate::{Column, ModelMeta, OpSafety, Operation, Snapshot, classify_operation, diff};
use umbral::orm::SqlType;
fn col(name: &str, ty: SqlType, help: &str) -> Column {
Column {
name: name.to_string(),
ty,
help: help.to_string(),
..Column::default()
}
}
fn model(fields: Vec<Column>) -> ModelMeta {
ModelMeta {
view: None,
materialized: false,
name: "Note".to_string(),
table: "note".to_string(),
app_label: "app".to_string(),
fields,
..ModelMeta::default()
}
}
fn snapshot(fields: Vec<Column>) -> Snapshot {
Snapshot {
models: vec![model(fields)],
}
}
fn create_table(fields: Vec<Column>) -> Operation {
Operation::CreateTable {
table: "note".to_string(),
columns: fields,
unique_together: Vec::new(),
indexes: Vec::new(),
}
}
/// Every statement the operation renders to, on one backend.
fn render(op: &Operation, backend: &str) -> Vec<String> {
umbral::migrate::render_operation_for(op, backend)
}
fn comments(stmts: &[String]) -> Vec<&String> {
stmts
.iter()
.filter(|s| s.starts_with("COMMENT ON"))
.collect()
}
// ---------------------------------------------------------------------------
// CREATE TABLE
// ---------------------------------------------------------------------------
/// A column with help text gets a `COMMENT ON COLUMN` after the `CREATE TABLE`.
/// It has to be a separate statement — Postgres has no inline column-comment
/// syntax.
#[test]
fn create_table_emits_a_comment_per_documented_column() {
let op = create_table(vec![
col("id", SqlType::BigInt, ""),
col("body", SqlType::Text, "The note's contents, as Markdown."),
col(
"views",
SqlType::Integer,
"Read count. Incremented on every GET.",
),
]);
let stmts = render(&op, "postgres");
let found = comments(&stmts);
assert_eq!(
found.len(),
2,
"one comment per documented column, none for `id`; got: {stmts:#?}",
);
assert!(
stmts.contains(
&r#"COMMENT ON COLUMN "note"."body" IS 'The note''s contents, as Markdown.'"#
.to_string()
),
"got: {stmts:#?}",
);
assert!(
stmts.contains(
&r#"COMMENT ON COLUMN "note"."views" IS 'Read count. Incremented on every GET.'"#
.to_string()
),
"got: {stmts:#?}",
);
// The comments come after the CREATE TABLE — commenting a column that does
// not exist yet is an error.
let create_at = stmts
.iter()
.position(|s| s.contains("CREATE TABLE"))
.unwrap();
let first_comment = stmts
.iter()
.position(|s| s.starts_with("COMMENT ON"))
.unwrap();
assert!(create_at < first_comment, "got: {stmts:#?}");
}
/// The apostrophe in "note's" above is the whole reason this test exists: help
/// text is prose, prose has apostrophes, and an unescaped one closes the SQL
/// string literal.
#[test]
fn a_quote_in_help_text_is_escaped() {
let op = create_table(vec![col("body", SqlType::Text, "Don't 'quote' me")]);
let stmts = render(&op, "postgres");
assert!(
stmts.contains(&r#"COMMENT ON COLUMN "note"."body" IS 'Don''t ''quote'' me'"#.to_string()),
"single quotes must be doubled; got: {stmts:#?}",
);
}
/// SQLite has no `COMMENT` statement. Rendering must simply omit them, not
/// produce SQL the driver will reject.
#[test]
fn sqlite_renders_no_comments() {
let op = create_table(vec![col("body", SqlType::Text, "Some help.")]);
let stmts = render(&op, "sqlite");
assert!(
comments(&stmts).is_empty(),
"SQLite has no COMMENT statement; got: {stmts:#?}",
);
assert!(
stmts.iter().any(|s| s.contains("CREATE TABLE")),
"the table itself must still be created; got: {stmts:#?}",
);
}
/// An undocumented column produces no comment at all — not `IS ''`, which would
/// read as "documented, with an empty description".
#[test]
fn an_undocumented_column_gets_no_comment() {
let op = create_table(vec![col("id", SqlType::BigInt, "")]);
assert!(comments(&render(&op, "postgres")).is_empty());
}
// ---------------------------------------------------------------------------
// ADD COLUMN
// ---------------------------------------------------------------------------
/// A column added later carries its help text too.
#[test]
fn add_column_emits_its_comment() {
let op = Operation::AddColumn {
table: "note".to_string(),
column: col("slug", SqlType::Text, "URL-safe identifier."),
};
let stmts = render(&op, "postgres");
assert!(
stmts.contains(&r#"COMMENT ON COLUMN "note"."slug" IS 'URL-safe identifier.'"#.to_string()),
"got: {stmts:#?}",
);
assert!(comments(&render(&op, "sqlite")).is_empty());
}
// ---------------------------------------------------------------------------
// The diff: editing help text is now a schema change.
// ---------------------------------------------------------------------------
/// Before this, `help` was excluded from the diff as "no DB effect". Now it has
/// one, so a copy edit must produce a migration — otherwise the comment in the
/// database silently rots against the comment in the model.
#[test]
fn editing_help_text_emits_a_set_column_comment() {
let previous = snapshot(vec![col("body", SqlType::Text, "Old description.")]);
let current = snapshot(vec![col("body", SqlType::Text, "New description.")]);
let ops = diff(&previous, ¤t).expect("diff");
assert_eq!(
ops,
vec![Operation::SetColumnComment {
table: "note".to_string(),
column: "body".to_string(),
comment: "New description.".to_string(),
}],
"a help-text edit is exactly one comment op — no table rewrite",
);
let stmts = render(&ops[0], "postgres");
assert_eq!(
stmts,
vec![r#"COMMENT ON COLUMN "note"."body" IS 'New description.'"#.to_string()],
);
}
/// Removing help text clears the comment. `IS NULL` is Postgres's "no comment",
/// distinct from `IS ''`.
#[test]
fn removing_help_text_clears_the_comment_with_null() {
let previous = snapshot(vec![col("body", SqlType::Text, "Was documented.")]);
let current = snapshot(vec![col("body", SqlType::Text, "")]);
let ops = diff(&previous, ¤t).expect("diff");
assert_eq!(
ops,
vec![Operation::SetColumnComment {
table: "note".to_string(),
column: "body".to_string(),
comment: String::new(),
}],
);
assert_eq!(
render(&ops[0], "postgres"),
vec![r#"COMMENT ON COLUMN "note"."body" IS NULL"#.to_string()],
);
assert!(render(&ops[0], "sqlite").is_empty());
}
/// Unchanged help text produces no migration. Without this, every
/// `makemigrations` after the upgrade would re-emit a comment for every
/// documented column, forever.
#[test]
fn unchanged_help_text_produces_no_operation() {
let fields = vec![col("body", SqlType::Text, "Stable description.")];
let ops = diff(&snapshot(fields.clone()), &snapshot(fields)).expect("diff");
assert!(ops.is_empty(), "no change means no migration; got {ops:#?}");
}
/// A help edit must not drag the column through a rewrite. On SQLite an
/// `AlterColumn` is a full table-recreation dance; on Postgres it rewrites the
/// column. A comment is neither.
#[test]
fn a_help_edit_never_emits_an_alter_column() {
let previous = snapshot(vec![col("body", SqlType::Text, "Old.")]);
let current = snapshot(vec![col("body", SqlType::Text, "New.")]);
let ops = diff(&previous, ¤t).expect("diff");
assert!(
!ops.iter()
.any(|o| matches!(o, Operation::AlterColumn { .. })),
"got: {ops:#?}",
);
}
/// `checkmigrations` gates deploys on operation safety. A comment touches no
/// row and no lock worth naming, so it must classify as SAFE — otherwise a
/// docstring edit would block a zero-downtime deploy.
#[test]
fn setting_a_comment_is_a_safe_operation() {
let op = Operation::SetColumnComment {
table: "note".to_string(),
column: "body".to_string(),
comment: "Anything.".to_string(),
};
assert!(
matches!(classify_operation(&op), OpSafety::Safe),
"a column comment is metadata; it must not gate a deploy",
);
}