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
//! Approach B: reusable model bases via `#[derive(ModelBase)]` + a
//! `#[umbral(flatten)]` embedded field. A base struct declares shared
//! columns (with the full `#[umbral(...)]` attribute set — `primary_key`,
//! `auto_now_add`, `auto_now`, …) once; any model embeds it as a nested
//! field and inherits those columns as if written inline.
//!
//! This is the Django abstract-base-model equivalent. The base's columns
//! must appear in the embedding model's `FIELDS` (in declaration order,
//! with every attribute preserved) so the whole ORM — migrations, the
//! SELECT list, inserts, auto-stamping — sees a flat schema.
use umbral::orm::{Model, SqlType};
// A reusable base carrying the PK plus audit timestamps — exactly the
// shape a Django `TimeStampedModel(models.Model)` abstract base produces.
#[derive(
Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, umbral::orm::ModelBase,
)]
pub struct TimeStamped {
#[umbral(primary_key)]
pub id: i64,
#[umbral(auto_now_add)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[umbral(auto_now)]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, umbral::orm::Model)]
#[umbral(table = "mb_note")]
pub struct Note {
#[umbral(flatten)]
#[sqlx(flatten)]
#[serde(flatten)]
pub base: TimeStamped,
pub title: String,
}
// A base bundling the primary key plus the soft-delete tombstone column.
// A model that embeds it AND marks itself `#[umbral(soft_delete)]` gets
// the framework's hide-instead-of-delete behavior for free.
#[derive(
Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, umbral::orm::ModelBase,
)]
pub struct SoftDeleteBase {
#[umbral(primary_key)]
pub id: i64,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow, umbral::orm::Model)]
#[umbral(table = "mb_doc", soft_delete)]
pub struct Doc {
#[umbral(flatten)]
#[sqlx(flatten)]
#[serde(flatten)]
pub base: SoftDeleteBase,
pub title: String,
}
// gaps5 #105: opt into typed column consts for the base-inherited fields,
// bound to `Note`. This makes `Note::ID` / `Note::CREATED_AT` /
// `Note::UPDATED_AT` usable in `filter` / `order_by` like own-field consts.
umbral::mixin_cols!(Note: TimeStamped);
/// The embedded base's columns land in `Note::FIELDS`, in declaration
/// order (base first, then the model's own fields), each carrying the
/// attribute it was declared with on the base.
#[test]
fn embedded_base_columns_appear_in_fields_in_declaration_order() {
let names: Vec<&str> = <Note as Model>::FIELDS.iter().map(|f| f.name).collect();
assert_eq!(
names,
vec!["id", "created_at", "updated_at", "title"],
"base columns should be spliced in ahead of the model's own fields"
);
}
/// The base's `#[umbral(primary_key)] id` is the model's primary key,
/// with the right SQL type — proving PK-in-base wiring works.
#[test]
fn primary_key_declared_on_the_base_is_the_models_primary_key() {
let id = <Note as Model>::FIELDS
.iter()
.find(|f| f.name == "id")
.expect("id column inherited from the base");
assert!(id.primary_key, "base `id` should be the model PK");
assert_eq!(id.ty, SqlType::BigInt);
assert!(!id.nullable);
}
/// The `auto_now_add` / `auto_now` attributes declared on the base are
/// preserved on the embedding model's columns — so the write path still
/// stamps them.
#[test]
fn auto_now_attributes_survive_the_flatten() {
let created = <Note as Model>::FIELDS
.iter()
.find(|f| f.name == "created_at")
.expect("created_at inherited from base");
assert!(created.auto_now_add, "created_at should keep auto_now_add");
let updated = <Note as Model>::FIELDS
.iter()
.find(|f| f.name == "updated_at")
.expect("updated_at inherited from base");
assert!(updated.auto_now, "updated_at should keep auto_now");
}
/// `mixin_cols!` generates typed consts for the base-inherited columns
/// (`Note::ID`, `Note::CREATED_AT`, `Note::UPDATED_AT`), usable in
/// `filter` / `order_by` exactly like a model's own field consts — closing
/// the gaps5 #105 gap.
#[tokio::test]
async fn mixin_cols_generates_typed_base_column_consts() {
boot().await;
// If the consts didn't exist or had the wrong Col type, this wouldn't
// compile; running it proves the built predicate/order are valid SQL.
let _ = Note::objects()
.filter(Note::ID.ge(0))
.order_by(Note::CREATED_AT.desc())
.order_by(Note::UPDATED_AT.asc())
.count()
.await
.expect("query builds and runs with base-column consts");
}
// --------------------------------------------------------------------- //
// Live round-trip: create through the real ORM path, read back the //
// nested base, and confirm the base's PK autoincrements + auto_now_add //
// stamps — proving the flattened columns behave exactly like inline ones //
// across migrations, INSERT, and hydration. //
// --------------------------------------------------------------------- //
use tokio::sync::OnceCell;
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 = umbral_core::db::connect_sqlite("sqlite::memory:")
.await
.expect("in-memory sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.model::<Note>()
.model::<Doc>()
.build()
.expect("App::build");
umbral_core::migrate::create_tables_for_tests()
.await
.expect("create schema from the flattened FIELDS");
})
.await;
}
/// A `Note` embedding `TimeStamped` inserts through `objects().create()`:
/// the base's `id` autoincrements (PK-in-base through the real INSERT),
/// `auto_now_add`/`auto_now` stamp real timestamps over the epoch
/// placeholder, and the nested `base` struct hydrates on read-back.
#[tokio::test]
async fn create_round_trip_stamps_base_and_autoincrements_base_pk() {
boot().await;
// Epoch placeholders: if the write path didn't stamp them, they'd
// persist as 1970 and the assertions below would fail.
let epoch = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0).unwrap();
let new = Note {
base: TimeStamped {
id: 0, // autoincrement sentinel — the base carries the PK
created_at: epoch,
updated_at: epoch,
},
title: "flattened".into(),
};
let row = Note::objects().create(new).await.expect("create");
assert!(
row.base.id > 0,
"base PK should autoincrement through the flattened INSERT; got {}",
row.base.id
);
assert_eq!(row.title, "flattened");
assert!(
row.base.created_at.timestamp() > 1_600_000_000,
"auto_now_add should have stamped a real `created_at`, not the epoch placeholder; got {}",
row.base.created_at
);
assert!(
row.base.updated_at.timestamp() > 1_600_000_000,
"auto_now should have stamped a real `updated_at`; got {}",
row.base.updated_at
);
// Read back through the ORM (filtering on the model's OWN column, for
// which a typed const exists) to confirm the row persisted and the
// nested base re-hydrates from the flat columns.
let fetched = Note::objects()
.filter(note::TITLE.eq("flattened"))
.first()
.await
.expect("query")
.expect("row present");
assert_eq!(fetched.base.id, row.base.id, "base PK re-hydrates on read");
assert_eq!(
fetched.base.created_at.timestamp(),
row.base.created_at.timestamp()
);
}
/// A model marked `#[umbral(soft_delete)]` whose `deleted_at` tombstone
/// column is inherited from an embedded base behaves exactly like an
/// inline soft-delete model: `delete()` hides the row (default queries
/// skip it) rather than removing it, and `.with_deleted()` still sees it.
#[tokio::test]
async fn soft_delete_column_inherited_from_a_base_hides_rows() {
boot().await;
let doc = Doc::objects()
.create(Doc {
base: SoftDeleteBase {
id: 0,
deleted_at: None,
},
title: "gone-soon".into(),
})
.await
.expect("create");
assert!(doc.base.id > 0);
// Soft-delete: UPDATE ... SET deleted_at = now(), not a hard DELETE.
let removed = Doc::objects()
.filter(doc::TITLE.eq("gone-soon"))
.delete()
.await
.expect("soft delete");
assert_eq!(removed, 1);
// Default queries skip soft-deleted rows (auto WHERE deleted_at IS NULL,
// on the base-inherited column).
let visible = Doc::objects()
.filter(doc::TITLE.eq("gone-soon"))
.count()
.await
.expect("count");
assert_eq!(visible, 0, "soft-deleted row hidden from default queries");
// `.with_deleted()` opts back in and finds the tombstoned row.
let including = Doc::objects()
.filter(doc::TITLE.eq("gone-soon"))
.with_deleted()
.count()
.await
.expect("count with deleted");
assert_eq!(including, 1, "row still present, just tombstoned");
}