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
//! gaps #112 / PK lift Pass A — `?include=` works when the FK
//! target has a non-i64 PK.
//!
//! Pre-fix, `DynQuerySet::hydrate_select_related_into` collected
//! FK values via `.as_i64()` and queried the target with `WHERE id
//! IN (...)`. A target like `permissions_permission` whose PK is a
//! String column named `codename` silently dropped every FK on
//! the floor (`.as_i64()` on a JSON String returns None) — REST
//! `?include=permission` on the FK side returned the bare codename
//! string instead of expanding to the full permission row.
//!
//! This test pins the fix: a `Tag` model with a String PK (`slug`)
//! and a `Bookmark` model with `ForeignKey<Tag>` round-trips
//! through the dynamic hydrator end-to-end.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use umbral::orm::{DynQuerySet, ForeignKey};
use umbral_core::db;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "spk_tag")]
pub struct Tag {
/// String PK, not the default `id: i64`. Same shape
/// `permissions_permission.codename` uses since gap #60.
#[umbral(primary_key, string, max_length = 50)]
pub slug: String,
#[umbral(string)]
pub label: String,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "spk_bookmark")]
pub struct Bookmark {
pub id: i64,
#[umbral(string)]
pub url: String,
/// FK to a String-PK target. The dynamic hydrator must read
/// this column as a JSON String, not i64, and bind it as a
/// String in the IN-list against `spk_tag.slug`.
pub tag: ForeignKey<Tag>,
}
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let pool = db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<Tag>()
.model::<Bookmark>()
.build()
.expect("App::build");
sqlx::query(
"CREATE TABLE spk_tag (
slug TEXT PRIMARY KEY,
label TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE spk_tag");
sqlx::query(
"CREATE TABLE spk_bookmark (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
tag TEXT NOT NULL REFERENCES spk_tag(slug)
)",
)
.execute(&pool)
.await
.expect("CREATE TABLE spk_bookmark");
for (slug, label) in &[("rust", "Rust"), ("web", "Web"), ("db", "Database")] {
sqlx::query("INSERT INTO spk_tag (slug, label) VALUES (?, ?)")
.bind(*slug)
.bind(*label)
.execute(&pool)
.await
.expect("seed tag");
}
for (url, tag) in &[
("https://rust-lang.org", "rust"),
("https://crates.io", "rust"),
("https://docs.rs", "web"),
] {
sqlx::query("INSERT INTO spk_bookmark (url, tag) VALUES (?, ?)")
.bind(*url)
.bind(*tag)
.execute(&pool)
.await
.expect("seed bookmark");
}
})
.await;
}
fn meta_for(table: &str) -> umbral::migrate::ModelMeta {
umbral::migrate::registered_models()
.into_iter()
.find(|m| m.table == table)
.expect("registered")
}
#[tokio::test]
async fn select_related_dyn_expands_string_pk_target() {
boot().await;
let rows = DynQuerySet::for_meta(&meta_for("spk_bookmark"))
.select_related_dyn(&["tag".to_string()])
.order_by_col("id", false)
.fetch_as_json()
.await
.expect("fetch");
assert!(rows.len() >= 3, "expected at least 3 seeded bookmarks");
// Every row's `tag` field should now be a FULL OBJECT
// (the Tag row keyed by `slug`), NOT the raw string id.
// This is the regression the i64-only hydrator caused: the
// raw string was passed through unchanged because
// `.as_i64()` returned None and the FK was never queued for
// the IN-list.
let first = &rows[0];
let tag = first
.get("tag")
.expect("tag field present")
.as_object()
.expect(
"tag must be an object after select_related_dyn (was the bare slug pre-fix); \
got: {tag:?}",
);
assert!(
tag.contains_key("slug"),
"expanded tag carries its slug PK; got keys: {:?}",
tag.keys().collect::<Vec<_>>()
);
assert!(
tag.contains_key("label"),
"expanded tag carries its non-PK columns too; got keys: {:?}",
tag.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn fetch_as_strings_renders_string_pk_fk_cell() {
// Review #3: the admin display path (`fetch_as_strings`) decoded every
// ForeignKey cell as i64. `Bookmark.tag` targets a String-PK `Tag`, so
// its column holds a slug — decoding it as i64 fails. It must render as
// the slug string.
boot().await;
let rows = DynQuerySet::for_meta(&meta_for("spk_bookmark"))
.order_by_col("id", false)
.fetch_as_strings()
.await
.expect("fetch_as_strings must not fail on a String-PK FK column");
assert!(rows.len() >= 3);
assert_eq!(rows[0].get("tag").map(String::as_str), Some("rust"));
assert_eq!(
rows[0].get("url").map(String::as_str),
Some("https://rust-lang.org")
);
}
#[tokio::test]
async fn select_related_dyn_dedupes_string_pk_fk_ids_across_rows() {
// Two of the three bookmarks point at slug="rust". The
// pk-key dedup must collapse those into ONE bind in the
// SELECT — not two — so the IN-list stays minimal.
boot().await;
let rows = DynQuerySet::for_meta(&meta_for("spk_bookmark"))
.select_related_dyn(&["tag".to_string()])
.fetch_as_json()
.await
.expect("fetch");
let by_url: std::collections::HashMap<String, &serde_json::Map<String, serde_json::Value>> =
rows.iter()
.map(|r| {
(
r.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
r,
)
})
.collect();
let r1 = by_url["https://rust-lang.org"];
let r2 = by_url["https://crates.io"];
let r3 = by_url["https://docs.rs"];
// Same expanded shape on both rust rows, distinct one on the
// web row — proves the dedup'd batch fetch found both source
// bookmarks AND mapped them back correctly.
assert_eq!(
r1.get("tag")
.and_then(|t| t.as_object())
.unwrap()
.get("label")
.and_then(|v| v.as_str()),
Some("Rust"),
);
assert_eq!(
r2.get("tag")
.and_then(|t| t.as_object())
.unwrap()
.get("label")
.and_then(|v| v.as_str()),
Some("Rust"),
);
assert_eq!(
r3.get("tag")
.and_then(|t| t.as_object())
.unwrap()
.get("label")
.and_then(|v| v.as_str()),
Some("Web"),
);
}