Skip to main content

rainier_database/
statement.rs

1//! Statement preparation — building SQL **synchronously**, so the futures that
2//! run it are `Send`.
3//!
4//! ## Why this module exists
5//!
6//! Rainier ORM's `repo::` functions build a `sea_query` statement and then
7//! `await` the executor with that statement still in scope. `sea_query`'s
8//! statement types hold `Rc<dyn Iden>`, so the statement is `!Send`, so the
9//! generated future is `!Send` — and a future that is not `Send` cannot be
10//! awaited inside a handler that a multi-threaded server will `tokio::spawn`.
11//!
12//! The fix is one line upstream (`drop(stmt);`, or scoping the build, before
13//! the `await`). Until Rainier ORM does that, Rainier prepares statements here
14//! instead: every `sea_query` value is created **and dropped inside a
15//! synchronous function**, which hands back a [`Prepared`] holding only
16//! `String` and `Value` — both `Send`. The `await` then touches nothing that
17//! is not `Send`.
18//!
19//! ## What is *not* re-implemented
20//!
21//! The metadata, the dialect rendering, the column types, the row decoding and
22//! the shard-routing *rule* all still come from Rainier ORM. This module is a
23//! thin re-statement of `repo::`'s SQL shapes against the same
24//! [`Entity`] metadata — not a second ORM. In particular,
25//! routing behaviour is deliberately identical to Rainier ORM's, so a sharded
26//! deployment routes the same way whether a query goes through here or through
27//! `repo::` directly.
28
29use rainier_orm::sea_query::{
30    Alias, Asterisk, ColumnRef, Cond, Expr, Func, IntoColumnRef, JoinType, OnConflict, Order,
31    Query as SqQuery, SimpleExpr, Value,
32};
33use rainier_orm::{Dialect, Entity, ShardRoute};
34
35use crate::criteria::Criteria;
36
37/// A rendered statement: SQL, its ordered bind values, and where to run it.
38///
39/// Every field is `Send`, which is the entire point — see the module docs.
40#[derive(Debug, Clone)]
41pub struct Prepared {
42    /// The rendered SQL.
43    pub sql: String,
44    /// The bind values, in the order the placeholders appear.
45    pub params: Vec<Value>,
46    /// The shard this operation targets. Ignored by single-database backends.
47    pub route: ShardRoute,
48}
49
50/// Reduce a bound value to a `u64` routing key.
51///
52/// Mirrors Rainier ORM's own (crate-private) rule exactly: a numeric id is taken
53/// as-is, because [`ShardCodec`](rainier_orm::ShardCodec) packs the shard into
54/// its high bits; a string key is hashed with the ORM's `stable_hash`, which is
55/// deliberately *not* `std`'s randomised hasher so the same key always routes
56/// to the same shard across processes and builds.
57pub fn routing_key(value: &Value) -> Option<u64> {
58    Some(match value {
59        Value::TinyInt(Some(n)) => *n as u64,
60        Value::SmallInt(Some(n)) => *n as u64,
61        Value::Int(Some(n)) => *n as u64,
62        Value::BigInt(Some(n)) => *n as u64,
63        Value::TinyUnsigned(Some(n)) => *n as u64,
64        Value::SmallUnsigned(Some(n)) => *n as u64,
65        Value::Unsigned(Some(n)) => *n as u64,
66        Value::BigUnsigned(Some(n)) => *n,
67        Value::String(Some(s)) => rainier_orm::stable_hash(s.as_bytes()),
68        Value::Char(Some(c)) => rainier_orm::stable_hash(c.encode_utf8(&mut [0u8; 4]).as_bytes()),
69        _ => return None,
70    })
71}
72
73/// The route for constraining `column` to `value` on `E`.
74pub fn route_for<E: Entity>(column: &str, value: &Value) -> ShardRoute {
75    if E::shard_columns().contains(&column) {
76        if let Some(key) = routing_key(value) {
77            return ShardRoute::Key(key);
78        }
79    }
80    ShardRoute::Global
81}
82
83/// The route implied by a whole row's `(column, value)` pairs: the first
84/// shard-encoded column that carries a usable key.
85fn route_from_pairs<E: Entity>(pairs: &[(&'static str, Value)]) -> ShardRoute {
86    if E::shard_columns().is_empty() {
87        return ShardRoute::Global;
88    }
89    for (column, value) in pairs {
90        let route = route_for::<E>(column, value);
91        if !matches!(route, ShardRoute::Global) {
92            return route;
93        }
94    }
95    ShardRoute::Global
96}
97
98fn alias(name: &str) -> Alias {
99    Alias::new(name)
100}
101
102/// `"name"` → `E.table.name`; `"table.name"` → `table.name`.
103fn column_ref<E: Entity>(spec: &str) -> ColumnRef {
104    match spec.split_once('.') {
105        Some((table, column)) => (alias(table), alias(column)).into_column_ref(),
106        None => (alias(E::table()), alias(spec)).into_column_ref(),
107    }
108}
109
110/// Start a `SELECT` of every column of `E`, qualified to its table.
111fn select_columns<E: Entity>() -> rainier_orm::sea_query::SelectStatement {
112    let mut stmt = SqQuery::select();
113    stmt.from(alias(E::table()));
114    for column in E::columns() {
115        stmt.column((alias(E::table()), alias(column.name)));
116    }
117    stmt
118}
119
120/// `SELECT … FROM table` — every row.
121pub fn select_all<E: Entity>(dialect: Dialect) -> Prepared {
122    let stmt = select_columns::<E>();
123    let (sql, params) = dialect.build_query(&stmt);
124    Prepared { sql, params: params.0, route: ShardRoute::Global }
125}
126
127/// `SELECT … WHERE pk = ? LIMIT 1`.
128pub fn select_by_pk<E: Entity>(dialect: Dialect, key: Value) -> Prepared {
129    let route = route_for::<E>(E::primary_key(), &key);
130    let mut stmt = select_columns::<E>();
131    stmt.and_where(Expr::col(alias(E::primary_key())).eq(key)).limit(1);
132
133    let (sql, params) = dialect.build_query(&stmt);
134    Prepared { sql, params: params.0, route }
135}
136
137/// `SELECT … WHERE column = ?`, optionally limited.
138pub fn select_by_column<E: Entity>(
139    dialect: Dialect,
140    column: &str,
141    value: Value,
142    limit: Option<u64>,
143) -> Prepared {
144    let route = route_for::<E>(column, &value);
145    let mut stmt = select_columns::<E>();
146    stmt.and_where(Expr::col(alias(column)).eq(value));
147    if let Some(limit) = limit {
148        stmt.limit(limit);
149    }
150
151    let (sql, params) = dialect.build_query(&stmt);
152    Prepared { sql, params: params.0, route }
153}
154
155/// `SELECT …` under a [`Criteria`]'s filters, joins, ordering and paging.
156pub fn select_matching<E: Entity>(dialect: Dialect, criteria: &Criteria) -> Prepared {
157    let mut stmt = select_columns::<E>();
158    let route = apply_criteria::<E>(&mut stmt, criteria, true);
159
160    let (sql, params) = dialect.build_query(&stmt);
161    Prepared { sql, params: params.0, route }
162}
163
164/// `SELECT COUNT(*) AS cnt` under a [`Criteria`]'s filters and joins only.
165///
166/// Ordering and paging are dropped: counting a page's rows with the page's own
167/// `LIMIT` applied would always report the page size.
168pub fn count_matching<E: Entity>(dialect: Dialect, criteria: &Criteria) -> Prepared {
169    let mut stmt = SqQuery::select();
170    stmt.from(alias(E::table()));
171    let route = apply_criteria::<E>(&mut stmt, criteria, false);
172    stmt.expr_as(Func::count(Expr::col(Asterisk)), alias("cnt"));
173
174    let (sql, params) = dialect.build_query(&stmt);
175    Prepared { sql, params: params.0, route }
176}
177
178/// `SELECT column, COUNT(*) AS cnt … GROUP BY column`.
179///
180/// One row per distinct value, which is what turns "how many children does each
181/// of these parents have" into a single query.
182pub fn count_grouped<E: Entity>(dialect: Dialect, column: &str, criteria: &Criteria) -> Prepared {
183    let mut stmt = SqQuery::select();
184    stmt.from(alias(E::table()));
185    let route = apply_criteria::<E>(&mut stmt, criteria, false);
186
187    stmt.column(column_ref::<E>(column));
188    stmt.expr_as(Func::count(Expr::col(Asterisk)), alias("cnt"));
189    stmt.add_group_by(vec![SimpleExpr::Column(column_ref::<E>(column))]);
190
191    let (sql, params) = dialect.build_query(&stmt);
192    Prepared { sql, params: params.0, route }
193}
194
195/// `SELECT parent, related FROM pivot WHERE parent IN (…)`.
196///
197/// A pivot table has no [`Entity`], because two key columns do not need one.
198/// The route is global: a pivot spanning shards cannot be resolved from one
199/// side's keys, and quietly picking a shard would silently lose the links held
200/// on the others.
201pub fn select_pivot(dialect: Dialect, query: &crate::relation::PivotQuery) -> Prepared {
202    let mut stmt = SqQuery::select();
203    stmt.from(alias(&query.table));
204    stmt.column((alias(&query.table), alias(&query.parent_column)));
205    stmt.column((alias(&query.table), alias(&query.related_column)));
206    stmt.and_where(Expr::col(alias(&query.parent_column)).is_in(query.parent_keys.iter().cloned()));
207
208    let (sql, params) = dialect.build_query(&stmt);
209    Prepared { sql, params: params.0, route: ShardRoute::Global }
210}
211
212/// Apply a criteria to a select. Returns the route the filters imply.
213fn apply_criteria<E: Entity>(
214    stmt: &mut rainier_orm::sea_query::SelectStatement,
215    criteria: &Criteria,
216    with_paging: bool,
217) -> ShardRoute {
218    for (table, local, foreign) in criteria.joins() {
219        let on =
220            Expr::col((alias(E::table()), alias(local))).equals((alias(table), alias(foreign)));
221        stmt.join(JoinType::InnerJoin, alias(table), on);
222    }
223
224    let mut condition = Cond::all();
225    let mut route = ShardRoute::Global;
226
227    for constraint in criteria.constraints() {
228        // An equality on a shard-encoded column pins the query to one shard,
229        // exactly as Rainier ORM's query builder does.
230        if let (Some((column, value)), true) =
231            (constraint.as_equality(), matches!(route, ShardRoute::Global))
232        {
233            route = route_for::<E>(column, value);
234        }
235        condition = condition.add(constraint.to_expr(column_ref::<E>(constraint.column())));
236    }
237    stmt.cond_where(condition);
238
239    for (column, descending) in criteria.orders() {
240        let order = if *descending { Order::Desc } else { Order::Asc };
241        stmt.order_by(column_ref::<E>(column), order);
242    }
243
244    if with_paging {
245        if let Some(limit) = criteria.limit_value() {
246            stmt.limit(limit);
247        }
248        if let Some(offset) = criteria.offset_value() {
249            stmt.offset(offset);
250        }
251    }
252
253    route
254}
255
256/// `INSERT INTO table (…) VALUES (…)`.
257///
258/// `assigned_id` is a shard-encoded primary key minted by the connector, for
259/// the sharded data tier where the key cannot be auto-increment.
260pub fn insert<E: Entity>(dialect: Dialect, entity: &E, assigned_id: Option<u64>) -> Prepared {
261    let mut pairs = entity.insert_values();
262    if let Some(id) = assigned_id {
263        let pk = E::primary_key();
264        for (column, value) in pairs.iter_mut() {
265            if *column == pk {
266                *value = id.into();
267            }
268        }
269    }
270
271    let route = route_from_pairs::<E>(&pairs);
272    let mut stmt = SqQuery::insert();
273    stmt.into_table(alias(E::table()));
274    stmt.columns(pairs.iter().map(|(column, _)| alias(column)));
275    stmt.values_panic(pairs.into_iter().map(|(_, value)| SimpleExpr::from(Expr::val(value))));
276
277    let (sql, params) = dialect.build_query(&stmt);
278    Prepared { sql, params: params.0, route }
279}
280
281/// Whether `E` needs a connector-minted primary key for this row, and the
282/// shard key to mint it from.
283///
284/// Mirrors Rainier ORM: the primary key must itself be a shard key, must not be
285/// auto-increment, and must currently be unset.
286pub fn shard_key_for_insert<E: Entity>(entity: &E) -> Option<u64> {
287    let pk = E::primary_key();
288    if !E::shard_columns().contains(&pk) {
289        return None;
290    }
291    if E::columns().iter().find(|c| c.name == pk)?.auto_increment {
292        return None;
293    }
294
295    let pairs = entity.insert_values();
296    let pk_value = pairs.iter().find(|(column, _)| *column == pk).map(|(_, value)| value)?;
297    if routing_key(pk_value) != Some(0) {
298        return None; // already set
299    }
300
301    pairs
302        .iter()
303        .find(|(column, value)| {
304            *column != pk && E::shard_columns().contains(column) && routing_key(value).is_some()
305        })
306        .and_then(|(_, value)| routing_key(value))
307}
308
309/// `INSERT … ON CONFLICT (…) DO UPDATE`.
310pub fn upsert<E: Entity>(
311    dialect: Dialect,
312    entity: &E,
313    conflict_columns: &[&str],
314    update_columns: &[&str],
315) -> Prepared {
316    let pairs = entity.insert_values();
317    let route = route_from_pairs::<E>(&pairs);
318
319    let mut stmt = SqQuery::insert();
320    stmt.into_table(alias(E::table()));
321    stmt.columns(pairs.iter().map(|(column, _)| alias(column)));
322    stmt.values_panic(pairs.into_iter().map(|(_, value)| SimpleExpr::from(Expr::val(value))));
323
324    let mut on_conflict = OnConflict::columns(conflict_columns.iter().map(|c| alias(c)));
325    if !update_columns.is_empty() {
326        on_conflict.update_columns(update_columns.iter().map(|c| alias(c)));
327    } else if let Some(first) = conflict_columns.first() {
328        // Insert-or-ignore. MySQL has no portable `DO NOTHING`, so a no-op
329        // self-update is used instead — valid everywhere, changes nothing.
330        on_conflict.value(alias(first), Expr::col(alias(first)));
331    } else {
332        on_conflict.do_nothing();
333    }
334    stmt.on_conflict(on_conflict);
335
336    let (sql, params) = dialect.build_query(&stmt);
337    Prepared { sql, params: params.0, route }
338}
339
340/// `UPDATE table SET … WHERE pk = ?` — every non-key column.
341pub fn update<E: Entity>(dialect: Dialect, entity: &E) -> Prepared {
342    let key = entity.pk_value();
343    let route = route_for::<E>(E::primary_key(), &key);
344
345    let mut stmt = SqQuery::update();
346    stmt.table(alias(E::table()));
347    for (column, value) in entity.update_values() {
348        stmt.value(alias(column), value);
349    }
350    stmt.and_where(Expr::col(alias(E::primary_key())).eq(key));
351
352    let (sql, params) = dialect.build_query(&stmt);
353    Prepared { sql, params: params.0, route }
354}
355
356/// `UPDATE table SET … WHERE <criteria>` — only the named columns.
357pub fn update_matching<E: Entity>(
358    dialect: Dialect,
359    criteria: &Criteria,
360    set: Vec<(String, Value)>,
361) -> Prepared {
362    let mut stmt = SqQuery::update();
363    stmt.table(alias(E::table()));
364    for (column, value) in set {
365        stmt.value(alias(&column), value);
366    }
367
368    let mut condition = Cond::all();
369    let mut route = ShardRoute::Global;
370    for constraint in criteria.constraints() {
371        if let (Some((column, value)), true) =
372            (constraint.as_equality(), matches!(route, ShardRoute::Global))
373        {
374            route = route_for::<E>(column, value);
375        }
376        condition = condition.add(constraint.to_expr(column_ref::<E>(constraint.column())));
377    }
378    stmt.cond_where(condition);
379
380    let (sql, params) = dialect.build_query(&stmt);
381    Prepared { sql, params: params.0, route }
382}
383
384/// `DELETE FROM table WHERE pk = ?`.
385pub fn delete_by_pk<E: Entity>(dialect: Dialect, key: Value) -> Prepared {
386    let route = route_for::<E>(E::primary_key(), &key);
387
388    let mut stmt = SqQuery::delete();
389    stmt.from_table(alias(E::table()));
390    stmt.and_where(Expr::col(alias(E::primary_key())).eq(key));
391
392    let (sql, params) = dialect.build_query(&stmt);
393    Prepared { sql, params: params.0, route }
394}
395
396/// `DELETE FROM table WHERE <criteria>`.
397pub fn delete_matching<E: Entity>(dialect: Dialect, criteria: &Criteria) -> Prepared {
398    let mut stmt = SqQuery::delete();
399    stmt.from_table(alias(E::table()));
400
401    let mut condition = Cond::all();
402    let mut route = ShardRoute::Global;
403    for constraint in criteria.constraints() {
404        if let (Some((column, value)), true) =
405            (constraint.as_equality(), matches!(route, ShardRoute::Global))
406        {
407            route = route_for::<E>(column, value);
408        }
409        condition = condition.add(constraint.to_expr(column_ref::<E>(constraint.column())));
410    }
411    stmt.cond_where(condition);
412
413    let (sql, params) = dialect.build_query(&stmt);
414    Prepared { sql, params: params.0, route }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[derive(rainier_orm::Entity, Clone, Debug)]
422    #[orm(table = "posts")]
423    struct Post {
424        #[orm(pk, auto_increment)]
425        id: u64,
426        title: String,
427        published: bool,
428    }
429
430    #[derive(rainier_orm::Entity, Clone, Debug)]
431    #[orm(table = "tokens")]
432    struct Token {
433        #[orm(pk, shard_key)]
434        id: u64,
435        #[orm(shard_key)]
436        user_id: u64,
437        hash: String,
438    }
439
440    fn post() -> Post {
441        Post { id: 0, title: "Hello".into(), published: true }
442    }
443
444    #[test]
445    fn a_prepared_statement_is_send() {
446        // The whole reason this module exists. If `Prepared` ever stopped
447        // being `Send`, every handler that touches the database would stop
448        // compiling with a much more confusing error than this.
449        fn assert_send<T: Send>() {}
450        assert_send::<Prepared>();
451    }
452
453    #[test]
454    fn select_all_lists_every_column() {
455        let prepared = select_all::<Post>(Dialect::Sqlite);
456        assert!(prepared.sql.contains("\"posts\""), "{}", prepared.sql);
457        assert!(prepared.sql.contains("\"title\""), "{}", prepared.sql);
458        assert!(prepared.params.is_empty());
459        assert_eq!(prepared.route, ShardRoute::Global);
460    }
461
462    #[test]
463    fn select_by_pk_binds_and_limits() {
464        let prepared = select_by_pk::<Post>(Dialect::Sqlite, 7_i64.into());
465        assert!(prepared.sql.contains("WHERE"), "{}", prepared.sql);
466        assert!(prepared.sql.contains("LIMIT"), "{}", prepared.sql);
467        // The key and the limit: sea-query parameterises `LIMIT` too, rather
468        // than inlining it as a literal.
469        assert_eq!(prepared.params.len(), 2);
470        assert_eq!(prepared.params[0], 7_i64.into());
471    }
472
473    #[test]
474    fn criteria_become_predicates_ordering_and_paging() {
475        let criteria = Criteria::new()
476            .where_eq("published", true)
477            .where_gt("id", 10_i64)
478            .order_by_desc("id")
479            .limit(5)
480            .offset(10);
481
482        let prepared = select_matching::<Post>(Dialect::Sqlite, &criteria);
483        assert!(prepared.sql.contains("ORDER BY"), "{}", prepared.sql);
484        assert!(prepared.sql.contains("LIMIT"), "{}", prepared.sql);
485        assert!(prepared.sql.contains("OFFSET"), "{}", prepared.sql);
486        // Two predicates plus the limit and the offset, all parameterised.
487        assert_eq!(prepared.params.len(), 4);
488    }
489
490    #[test]
491    fn counting_drops_the_paging_but_keeps_the_filters() {
492        let criteria = Criteria::new().where_eq("published", true).limit(5).offset(10);
493        let prepared = count_matching::<Post>(Dialect::Sqlite, &criteria);
494
495        assert!(prepared.sql.contains("COUNT"), "{}", prepared.sql);
496        assert!(prepared.sql.contains("cnt"), "{}", prepared.sql);
497        assert!(!prepared.sql.contains("LIMIT"), "{}", prepared.sql);
498        assert!(!prepared.sql.contains("ORDER BY"), "{}", prepared.sql);
499        assert_eq!(prepared.params.len(), 1);
500    }
501
502    #[test]
503    fn insert_omits_an_auto_increment_key() {
504        let prepared = insert::<Post>(Dialect::Sqlite, &post(), None);
505        assert!(prepared.sql.starts_with("INSERT INTO"), "{}", prepared.sql);
506        assert!(!prepared.sql.contains("\"id\""), "auto-increment keys are the DB's job");
507        assert_eq!(prepared.params.len(), 2);
508    }
509
510    #[test]
511    fn update_writes_every_column_but_the_key() {
512        let prepared = update::<Post>(Dialect::Sqlite, &Post { id: 3, ..post() });
513        assert!(prepared.sql.starts_with("UPDATE"), "{}", prepared.sql);
514        assert!(prepared.sql.contains("WHERE"), "{}", prepared.sql);
515        // title + published, then the key in the WHERE.
516        assert_eq!(prepared.params.len(), 3);
517    }
518
519    #[test]
520    fn upsert_renders_a_conflict_clause() {
521        let prepared = upsert::<Post>(Dialect::Sqlite, &post(), &["title"], &["published"]);
522        assert!(prepared.sql.contains("ON CONFLICT"), "{}", prepared.sql);
523    }
524
525    #[test]
526    fn an_upsert_with_no_updates_is_insert_or_ignore() {
527        let prepared = upsert::<Post>(Dialect::Sqlite, &post(), &["title"], &[]);
528        // A no-op self-update rather than DO NOTHING, which MySQL cannot render.
529        assert!(prepared.sql.contains("ON CONFLICT"), "{}", prepared.sql);
530        assert!(!prepared.sql.contains("DO NOTHING"), "{}", prepared.sql);
531    }
532
533    #[test]
534    fn delete_targets_the_key() {
535        let prepared = delete_by_pk::<Post>(Dialect::Sqlite, 3_i64.into());
536        assert!(prepared.sql.starts_with("DELETE FROM"), "{}", prepared.sql);
537        assert_eq!(prepared.params.len(), 1);
538    }
539
540    #[test]
541    fn every_dialect_renders() {
542        for dialect in [Dialect::Sqlite, Dialect::MySql, Dialect::Postgres] {
543            let prepared = select_by_pk::<Post>(dialect, 1_i64.into());
544            assert!(prepared.sql.contains("posts"), "{dialect:?}: {}", prepared.sql);
545        }
546        // Postgres uses numbered placeholders, MySQL/SQLite use `?`.
547        assert!(select_by_pk::<Post>(Dialect::Postgres, 1_i64.into()).sql.contains("$1"));
548    }
549
550    // --- routing -----------------------------------------------------------
551
552    #[test]
553    fn a_global_entity_always_routes_globally() {
554        assert_eq!(route_for::<Post>("id", &7_i64.into()), ShardRoute::Global);
555        assert_eq!(select_by_pk::<Post>(Dialect::Sqlite, 7_i64.into()).route, ShardRoute::Global);
556    }
557
558    #[test]
559    fn a_shard_key_column_routes_by_its_value() {
560        assert_eq!(route_for::<Token>("id", &7_u64.into()), ShardRoute::Key(7));
561        assert_eq!(route_for::<Token>("user_id", &9_u64.into()), ShardRoute::Key(9));
562        assert_eq!(
563            route_for::<Token>("hash", &"abc".into()),
564            ShardRoute::Global,
565            "a non-shard column never routes"
566        );
567    }
568
569    #[test]
570    fn a_criteria_equality_on_a_shard_column_pins_the_query() {
571        let criteria = Criteria::new().where_eq("user_id", 42_u64);
572        assert_eq!(select_matching::<Token>(Dialect::Sqlite, &criteria).route, ShardRoute::Key(42));
573    }
574
575    #[test]
576    fn string_routing_keys_hash_stably() {
577        // Must match Rainier ORM's `stable_hash`, not a randomised hasher, or the
578        // same key would land on a different shard in a different process.
579        let expected = rainier_orm::stable_hash(b"user@example.com");
580        assert_eq!(routing_key(&"user@example.com".into()), Some(expected));
581    }
582
583    #[test]
584    fn a_null_or_unroutable_value_has_no_key() {
585        assert_eq!(routing_key(&Value::BigInt(None)), None);
586        assert_eq!(routing_key(&Value::Bool(Some(true))), None);
587    }
588
589    #[test]
590    fn an_insert_routes_by_the_rows_shard_key() {
591        let token = Token { id: 100, user_id: 42, hash: "x".into() };
592        assert_eq!(insert::<Token>(Dialect::Sqlite, &token, None).route, ShardRoute::Key(100));
593    }
594
595    #[test]
596    fn an_unset_shard_encoded_key_asks_the_connector_to_mint_one() {
597        let token = Token { id: 0, user_id: 42, hash: "x".into() };
598        assert_eq!(shard_key_for_insert::<Token>(&token), Some(42));
599
600        // Already set: the connector is not consulted.
601        let assigned = Token { id: 100, user_id: 42, hash: "x".into() };
602        assert_eq!(shard_key_for_insert::<Token>(&assigned), None);
603
604        // A global entity never allocates.
605        assert_eq!(shard_key_for_insert::<Post>(&post()), None);
606    }
607
608    #[test]
609    fn a_minted_id_is_written_into_the_insert() {
610        let token = Token { id: 0, user_id: 42, hash: "x".into() };
611        let prepared = insert::<Token>(Dialect::Sqlite, &token, Some(4242));
612        assert_eq!(prepared.route, ShardRoute::Key(4242));
613    }
614}