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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! gaps3 #40 — cross-plugin foreign-key ordering.
//!
//! `App::build()` sorts plugins with Kahn's algorithm; ties break by
//! REGISTRATION order since gaps4 #44 (they used to break alphabetically).
//! When *no* plugin declares `Plugin::dependencies()`, every plugin has
//! in-degree 0, so the "topological" order collapses to the tie-break — and
//! under the old alphabetical one, `"accounts"` sorted before `"auth"`, so
//! `CREATE TABLE accounts_git_hub_account (... user bigint REFERENCES
//! auth_user(id))` ran before `auth_user` existed and the first umbralrs.dev
//! deploy died with `relation "auth_user" does not exist`.
//!
//! Any database that *already* contains the referenced table migrates fine, so
//! the whole dev loop, every test, and every incremental deploy passed. Only a
//! fresh database executes the creates in dependency order. It was a
//! prod-only, first-run-only failure.
//!
//! The schema already states the edge: a `ForeignKey<T>` field renders
//! `REFERENCES "<T::TABLE>"`, and every `Column` carries `fk_target`. So the
//! ordering can be *derived* rather than hand-declared. These tests pin that.
//!
//! `App::build()` publishes process-global state (`db::init`, the model
//! registry, `init_plugin_order`) through `OnceLock`s, so exactly ONE
//! successful build may run per test binary. This file spends it on the
//! ordering assertion; the cycle-attribution case fails in phase 1.5 before
//! any global is published and so may share the process.
use umbral::migrate::{Column, ModelMeta};
use umbral::orm::{FkAction, SqlType};
use umbral::plugin::Plugin;
use umbral::{App, BuildError};
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
/// A plugin that owns exactly one table, optionally with one physical FK into
/// another plugin's table. `deps` stays empty on purpose: the whole point is
/// that the author never declared the edge.
struct FkPlugin {
name: &'static str,
table: &'static str,
fk_target: Option<&'static str>,
/// `false` reproduces `#[umbral(db_constraint = false)]` — a logical FK
/// that renders no `REFERENCES` clause and therefore orders nothing.
db_constraint: bool,
deps: &'static [&'static str],
}
impl FkPlugin {
fn new(name: &'static str, table: &'static str) -> Self {
Self {
name,
table,
fk_target: None,
db_constraint: true,
deps: &[],
}
}
fn fk_to(mut self, target: &'static str) -> Self {
self.fk_target = Some(target);
self
}
fn logical_fk_to(mut self, target: &'static str) -> Self {
self.fk_target = Some(target);
self.db_constraint = false;
self
}
}
fn column(name: &str, ty: SqlType, primary_key: bool) -> Column {
Column {
name: name.to_string(),
ty,
primary_key,
nullable: false,
fk_target: None,
noform: false,
privileged: false,
private: false,
secret: false,
db_constraint: true,
noedit: false,
auto_user_add: false,
auto_user: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: FkAction::NoAction,
on_update: FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
trim: false,
lowercase: false,
case_insensitive: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: None,
slug_from: None,
}
}
impl Plugin for FkPlugin {
fn name(&self) -> &'static str {
self.name
}
fn dependencies(&self) -> &'static [&'static str] {
self.deps
}
fn models(&self) -> Vec<ModelMeta> {
let mut fields = vec![column("id", SqlType::BigInt, true)];
if let Some(target) = self.fk_target {
let mut fk = column("owner", SqlType::ForeignKey, false);
fk.fk_target = Some(target.to_string());
fk.db_constraint = self.db_constraint;
fields.push(fk);
}
vec![ModelMeta {
view: None,
materialized: false,
name: format!("{}Model", self.name),
table: self.table.to_string(),
fields,
display: format!("{}Model", self.name),
icon: "database".to_string(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
audited: false,
app_label: self.name.to_string(),
}]
}
}
async fn settings_and_pool() -> (umbral::Settings, sqlx::SqlitePool) {
let settings = umbral::Settings::from_env().expect("figment defaults load in a test env");
let pool = umbral::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite connects");
(settings, pool)
}
fn position(order: &[String], plugin: &str) -> usize {
order
.iter()
.position(|p| p == plugin)
.unwrap_or_else(|| panic!("`{plugin}` missing from plugin_order(): {order:?}"))
}
// ---------------------------------------------------------------------------
// The regression: a derived FK edge outranks the tie-break.
// ---------------------------------------------------------------------------
/// `fkorder_accounts` FKs `fkorder_auth`'s table, declares NO dependency, and
/// is REGISTERED first — exactly the order that produced `relation
/// "auth_user" does not exist` on the fresh Postgres. The FK edge derived
/// from the model registry must outrank the registration-order tie-break
/// (gaps4 #44) and reorder them.
///
/// The logical-FK plugin (`db_constraint = false`) renders no `REFERENCES`
/// clause, so it imposes no DDL ordering and must NOT gain an edge — it is
/// registered FIRST and stays first, even though it points at
/// `fkorder_user`; a wrongly-derived edge would push it after `fkorder_auth`.
#[tokio::test]
async fn cross_plugin_fk_orders_target_before_dependent() {
let (settings, pool) = settings_and_pool().await;
let accounts = FkPlugin::new("fkorder_accounts", "fkorder_account").fk_to("fkorder_user");
let auth = FkPlugin::new("fkorder_auth", "fkorder_user");
// Registered first and points at `fkorder_user`, but only logically —
// no physical constraint, so no ordering obligation to outrank its
// registration position.
let logical = FkPlugin::new("fkorder_aaa", "fkorder_logical").logical_fk_to("fkorder_user");
App::builder()
.settings(settings)
.database("default", pool)
.plugin(logical)
.plugin(accounts)
.plugin(auth)
.build()
.expect("build succeeds: the FK edges are acyclic");
let order = umbral::migrate::plugin_order();
assert!(
position(&order, "fkorder_auth") < position(&order, "fkorder_accounts"),
"the FK target's plugin must be created before the plugin that \
REFERENCES it; got {order:?}",
);
// The implicit "app" plugin stays last: app models FK into plugin tables.
assert_eq!(
order.last().map(String::as_str),
Some("app"),
"the implicit `app` plugin stays last; got {order:?}",
);
// A logical-only FK creates no ordering obligation, so `fkorder_aaa`
// keeps its registration-order position AHEAD of its (non-)target — a
// wrongly-derived edge would have pushed it after `fkorder_auth`.
assert!(
position(&order, "fkorder_aaa") < position(&order, "fkorder_auth"),
"`db_constraint = false` renders no REFERENCES clause and must not \
constrain ordering; got {order:?}",
);
}
// ---------------------------------------------------------------------------
// Cycle attribution. Fails in phase 1.5, before any global is published.
// ---------------------------------------------------------------------------
/// Two plugins whose models FK each other cannot both be created first. Across
/// crates Cargo already forbids this (a `ForeignKey<T>` needs `T` in scope, so
/// mutual FKs would be a circular crate dependency), but two plugins defined in
/// one crate can still do it. The build must name the FK edge rather than
/// reporting a bare `PluginCycle` the author never declared.
#[tokio::test]
async fn foreign_key_cycle_is_attributed_to_the_fk_not_the_declaration() {
let (settings, pool) = settings_and_pool().await;
let a = FkPlugin::new("fkcycle_a", "fkcycle_a_table").fk_to("fkcycle_b_table");
let b = FkPlugin::new("fkcycle_b", "fkcycle_b_table").fk_to("fkcycle_a_table");
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(a)
.plugin(b)
.build();
match result {
Err(BuildError::ForeignKeyCycle { edges }) => {
assert!(
edges.iter().any(|e| e.plugin == "fkcycle_a"
&& e.depends_on == "fkcycle_b"
&& e.table == "fkcycle_a_table"
&& e.fk_target == "fkcycle_b_table"),
"the error must name the offending column's table and target; got {edges:?}",
);
assert!(
edges.iter().any(|e| e.plugin == "fkcycle_b"),
"both sides of the cycle should be reported; got {edges:?}",
);
}
Err(other) => panic!("expected BuildError::ForeignKeyCycle, got {other:?}"),
Ok(_) => panic!("a cross-plugin foreign-key cycle has no valid CREATE TABLE order"),
}
}
/// A cycle the author *declared* keeps reporting as `PluginCycle`. The FK-edge
/// derivation must not swallow or rename the pre-existing diagnostic.
#[tokio::test]
async fn declared_cycle_still_reports_plugin_cycle() {
let (settings, pool) = settings_and_pool().await;
let mut a = FkPlugin::new("declcycle_a", "declcycle_a_table");
a.deps = &["declcycle_b"];
let mut b = FkPlugin::new("declcycle_b", "declcycle_b_table");
b.deps = &["declcycle_a"];
let result = App::builder()
.settings(settings)
.database("default", pool)
.plugin(a)
.plugin(b)
.build();
match result {
Err(BuildError::PluginCycle { names }) => {
assert!(
names.contains(&"declcycle_a") && names.contains(&"declcycle_b"),
"PluginCycle should still cover both declared plugins; got {names:?}",
);
}
Err(other) => panic!("expected BuildError::PluginCycle, got {other:?}"),
Ok(_) => panic!("a declared dependency cycle must stay rejected"),
}
}