autumn_web/repository.rs
1//! Repository support types for framework-generated CRUD operations.
2//!
3//! Generated `#[repository]` read-only methods (`find_by_id`, `find_all`,
4//! `count`, `paginate`, `cursor_page`, derived `find_by_*`, full-text-search
5//! reads) route to the configured read replica automatically: set
6//! `database.replica_url` and the extractor snapshots a [`ReadRoute`] per
7//! request via [`crate::AppState::read_pool`]. Mutating methods (`save`,
8//! `update`, `delete_by_id`, bulk writes) always run on the primary pool.
9//! Pin a read-after-write-sensitive repository to the primary with
10//! `#[repository(Model, primary_reads)]`, or pin a single call chain with
11//! the generated `on_primary()` method. When no replica is configured, all
12//! methods use the primary — nothing changes for single-pool apps.
13//!
14//! Sharded repositories built from a
15//! [`ShardedDb`](crate::sharding::ShardedDb) via the generated `from_shard`
16//! constructor get the same treatment **per shard**: reads route to the
17//! shard's replica when one is configured and healthy (honoring the shard's
18//! `replica_fallback` policy via
19//! [`Shard::read_route`](crate::sharding::Shard::read_route)), writes stay on
20//! the shard primary, and `primary_reads` / `on_primary()` pin reads back to
21//! the shard primary.
22//!
23//! [`RepositoryError`] surfaces typed errors that arise during repository
24//! operations — most notably optimistic-lock conflicts when two replicas
25//! write the same row concurrently.
26
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29
30/// Backend-portable `FOR UPDATE` seam for generated `#[repository]` CRUD.
31///
32/// Postgres pessimistic-lock reads chain `.for_update()` onto a select query to
33/// take a row lock, but diesel's `LockingClause` only implements
34/// `QueryFragment<Pg>` — the same query is not constructible against the
35/// `SQLite` backend at all, so even a *simple* repository's always-emitted lock
36/// reads would fail to compile under `--features sqlite`. Worse, diesel's
37/// `FOR UPDATE` lock marker is `pub(crate)`, so no generic extension trait in a
38/// downstream crate can name the bound `for_update()` needs — a portable seam
39/// cannot be a plain trait.
40///
41/// This macro is that seam: the `#[repository]` macro emits
42/// `::autumn_web::maybe_for_update!(<query>)` where it used to chain
43/// `<query>.for_update()`, and the returned query is still loadable
44/// (`.first(...)`/`.load(...)`), so the generated call chains compose
45/// identically on either backend. Because the `#[cfg]` that selects the
46/// expansion lives on the macro **definition**, it is evaluated in
47/// **autumn-web's** compilation (where the `sqlite` feature actually lives) —
48/// not the consumer crate's — so a downstream app needs no `sqlite` feature of
49/// its own for the right arm to be chosen.
50///
51/// - **Postgres** (default): expands to `QueryDsl::for_update(<query>)` — the
52/// `FOR UPDATE` locking clause, unchanged. Marker resolution happens at the
53/// concrete call site, so no unnameable bound is ever written.
54/// - **`SQLite`**: expands to `<query>` verbatim — `SQLite` has no
55/// `SELECT … FOR UPDATE` (it serializes writers with a database-level lock),
56/// so the row-lock read degrades to a plain read. Write-write correctness
57/// still rests on the optimistic `lock_version` check plus the pool's
58/// `busy_timeout`; see [`crate::db::RuntimeConnection`] and issue #1996 for
59/// the residual single-writer concurrency caveat.
60#[cfg(all(feature = "db", not(feature = "sqlite")))]
61#[macro_export]
62macro_rules! maybe_for_update {
63 ($query:expr $(,)?) => {
64 $crate::reexports::diesel::query_dsl::QueryDsl::for_update($query)
65 };
66}
67
68/// `SQLite` arm of [`maybe_for_update!`] — the identity.
69///
70/// See the Postgres
71/// definition for the full contract; `SQLite` has no `SELECT … FOR UPDATE`, so a
72/// pessimistic-lock read degrades to a plain read.
73#[cfg(all(feature = "db", feature = "sqlite"))]
74#[macro_export]
75macro_rules! maybe_for_update {
76 ($query:expr $(,)?) => {
77 $query
78 };
79}
80
81/// Compile-time backend block selector for generated `#[repository]`/`#[model]` CRUD.
82///
83/// Used where the two backends need *structurally different* code (not just a
84/// swapped receiver), e.g. Postgres multi-row batch insert vs. the `SQLite`
85/// per-row loop, or the batched `ON CONFLICT` upsert vs. `SQLite`'s per-row
86/// upsert.
87///
88/// The `#[cfg]` selecting the arm lives on the macro **definition**, so it is
89/// evaluated in **autumn-web's** compilation (where the `sqlite` feature lives)
90/// — the un-selected arm's tokens are never even type-checked, which is what
91/// lets each arm use backend-specific diesel fragments (Postgres `DEFAULT`-keyword
92/// batch inserts, `SQLite` per-row `RETURNING`) that would not compile against
93/// the other backend.
94///
95/// Expands to the chosen block as an expression:
96/// `::autumn_web::backend_select! { pg => { … }, sqlite => { … } }`.
97#[cfg(all(feature = "db", not(feature = "sqlite")))]
98#[macro_export]
99macro_rules! backend_select {
100 (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
101 $pg
102 };
103}
104
105/// `SQLite` arm of [`backend_select!`]. See the Postgres definition for the
106/// contract.
107#[cfg(all(feature = "db", feature = "sqlite"))]
108#[macro_export]
109macro_rules! backend_select {
110 (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
111 $sqlite
112 };
113}
114
115/// Where a generated repository routes its read-only methods (`find_by_id`,
116/// `find_all`, `count`, `paginate`, `cursor_page`, derived `find_by_*`,
117/// full-text-search reads, …).
118///
119/// The route is snapshotted from [`crate::AppState`] when the repository is
120/// extracted, so every read within a request sees one consistent decision.
121/// Mutating methods (`save`, `update`, `delete_by_id`, bulk writes) always
122/// use the primary pool regardless of this route, as do pessimistic-lock
123/// reads (`with_lock`) and reads running on an explicit transaction
124/// connection.
125#[cfg(feature = "db")]
126#[derive(Clone)]
127pub enum ReadRoute {
128 /// Reads use the primary/write pool: no replica is configured, the
129 /// repository was declared with `#[repository(..., primary_reads)]`, or
130 /// the caller pinned this instance with `on_primary()`.
131 Primary,
132 /// Reads use this read-role pool snapshot — the replica when healthy,
133 /// or the primary when the replica is unready and the
134 /// [`ReplicaFallback::Primary`](crate::config::ReplicaFallback) policy
135 /// applies.
136 ReadPool(diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>),
137 /// A replica is configured but currently unready, and the
138 /// [`ReplicaFallback::FailReadiness`](crate::config::ReplicaFallback)
139 /// policy forbids falling back to the primary.
140 ///
141 /// Generated reads fail
142 /// fast with `503 Service Unavailable` instead of silently serving
143 /// from the wrong role.
144 Unavailable,
145}
146
147#[cfg(feature = "db")]
148impl ReadRoute {
149 /// Snapshot the read-routing decision for one request from the app
150 /// state, mirroring [`crate::AppState::read_pool`] semantics.
151 #[must_use]
152 pub fn from_state(state: &crate::AppState) -> Self {
153 if state.replica_pool().is_some() {
154 state
155 .read_pool()
156 .map_or(Self::Unavailable, |pool| Self::ReadPool(pool.clone()))
157 } else {
158 Self::Primary
159 }
160 }
161}
162
163#[cfg(feature = "db")]
164impl std::fmt::Debug for ReadRoute {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 Self::Primary => f.write_str("ReadRoute::Primary"),
168 Self::ReadPool(pool) => {
169 write!(f, "ReadRoute::ReadPool(max={})", pool.status().max_size)
170 }
171 Self::Unavailable => f.write_str("ReadRoute::Unavailable"),
172 }
173 }
174}
175
176/// Upper bound on bound parameters in one prepared statement, per backend —
177/// used by generated bulk-write CRUD to size insert/update chunks so a batch
178/// never exceeds the driver's placeholder limit.
179///
180/// Postgres caps a statement at 65535 (`u16`) parameters; `SQLite`'s default
181/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766 (since 3.32.0). The generated code
182/// divides this by the per-row column count to pick a chunk size, so the
183/// value tracks the active backend via the same `sqlite` feature that flips
184/// [`crate::db::RuntimeConnection`]. (The `SQLite` write path also inserts
185/// row-by-row, so this bound is a conservative belt-and-suspenders there.)
186#[cfg(not(feature = "sqlite"))]
187pub const MAX_BIND_PARAMS: usize = 65535;
188/// See [`MAX_BIND_PARAMS`] (Postgres). `SQLite`'s default
189/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766.
190#[cfg(feature = "sqlite")]
191pub const MAX_BIND_PARAMS: usize = 32766;
192
193/// Typed errors returned by generated repository methods.
194///
195/// Distinct from [`crate::AutumnError`] so callers can match on the
196/// variant without parsing an HTTP status code.
197#[derive(Debug, Clone, Error)]
198pub enum RepositoryError {
199 /// Two writers raced on the same row.
200 ///
201 /// Returned by generated `update`/`save` methods when the
202 /// `#[lock_version]` field no longer matches the value the client
203 /// sent — meaning another replica committed a write in the meantime.
204 ///
205 /// Map this to `409 Conflict` via [`crate::AutumnError::conflict`].
206 #[error(
207 "optimistic lock conflict on record {id}: \
208 client expected version {expected_version}, \
209 row was already modified (actual: {actual_version:?})"
210 )]
211 Conflict {
212 /// Primary key of the contested record.
213 id: i64,
214 /// The version the client read and expected to still be current.
215 expected_version: i64,
216 /// The version actually stored when the conflict was detected,
217 /// or `None` if the row was deleted between the read and the write.
218 actual_version: Option<i64>,
219 },
220}
221
222/// How a parent's dependent children are handled when the parent is deleted.
223///
224/// Declared per association on the `#[repository]` macro via
225/// `dependent(ChildRepository, fk = "parent_fk", on_delete = <action>)`, and
226/// applied inside the parent's `delete_by_id` transaction so a parent delete
227/// never leaves orphaned child rows (issue #1369).
228///
229/// The cascade runs app-side (not via a DB `ON DELETE` constraint) so that
230/// soft-delete, lifecycle/commit hooks, and counter caches stay correct — a
231/// guarantee raw `ON DELETE CASCADE` cannot provide.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum DependentAction {
234 /// Delete each child through the child repository's delete path, honoring
235 /// the child's soft-delete mode and firing its lifecycle/commit hooks.
236 Destroy,
237 /// Bulk hard-delete every child in a single `DELETE`, skipping per-row hooks.
238 DeleteAll,
239 /// Set the child's foreign-key column to `NULL` (the child rows survive,
240 /// detached from the deleted parent).
241 Nullify,
242 /// Refuse to delete the parent while any child exists, surfacing a typed
243 /// `409 Conflict` error instead of orphaning or cascading.
244 Restrict,
245}
246
247/// The boxed, `Send` future a [`RuntimeDependentCascadeFn`] returns.
248///
249/// It resolves to the deferred `(topic, dom_id)` OOB delete broadcasts a single
250/// child association's cascade accumulated (empty for a non-broadcasting child).
251pub type RuntimeDependentCascadeFuture<'a> = ::std::pin::Pin<
252 ::std::boxed::Box<
253 dyn ::std::future::Future<Output = crate::AutumnResult<Vec<(String, String)>>> + Send + 'a,
254 >,
255>;
256
257/// A type-erased entry point into one child repository's
258/// `__autumn_apply_dependent_on_conn` leaf executor (#1738).
259///
260/// `#[model]` emits one of these per model-declared
261/// `#[has_many(Child, dependent = …)]` association, resolving the child
262/// repository through the `Pg{Child}Repository` naming convention. Given the
263/// parent repository's pool, its live connection/transaction, the parent id,
264/// the parent's soft-delete kind, and the two shared cascade-guard sets, it
265/// constructs the child repository and applies this one association's cascade,
266/// returning any deferred OOB delete broadcasts to publish post-commit.
267///
268/// The three guard sets serve distinct roles (Codex round-5-B + #1800 case 1):
269/// `__path` is the ACTIVE recursion stack (pushed before descending into a node's
270/// children, popped once that subtree completes) used only to break
271/// self-/mutual-referential cycles; `__deleted` is a monotonic set of every row
272/// HANDLED by the cascade — soft OR physical — used by the bulk `delete_many` root
273/// dedup / hook double-fire guard to skip a root already processed as another
274/// root's descendant (so its `before_delete` never fires twice); `__physical` is
275/// the subset of rows PHYSICALLY removed, used only by the diamond traversal
276/// revisit-skip so a hard-delete path can still physically remove a row a
277/// soft-delete path merely marked `deleted_at` on. Keeping `__deleted` and
278/// `__physical` separate is what lets the bulk root skip stay complete for
279/// soft-delete graphs while the diamond hard path is still free to run; keeping
280/// `__path` separate lets a batch process a descendant root before its ancestor
281/// without the ancestor's cascade skipping a still-referenced intermediate
282/// (immediate-FK failure).
283///
284/// All references share one lifetime so the returned future can borrow the
285/// connection and all three guard sets for exactly as long as it is awaited.
286pub type RuntimeDependentCascadeFn = for<'a> fn(
287 &'a ::diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
288 &'a mut crate::db::RuntimeConnection,
289 i64,
290 bool,
291 &'a mut ::std::collections::HashSet<(&'static str, i64)>,
292 &'a mut ::std::collections::HashSet<(&'static str, i64)>,
293 &'a mut ::std::collections::HashSet<(&'static str, i64)>,
294) -> RuntimeDependentCascadeFuture<'a>;
295
296/// One model-declared dependent-cascade association, produced at compile time
297/// by `#[model]` and consulted at run time by the parent's `delete_by_id`
298/// (#1738).
299///
300/// The `#[model]` and `#[repository]` derives are separate proc-macro
301/// invocations, so the repository macro — which owns the `delete_by_id` cascade
302/// codegen — never sees the model struct's `#[has_many]` attributes. This spec
303/// bridges the two at run time: the parent iterates
304/// [`AutumnDependents::dependents`] and drives each spec's [`cascade`] inside
305/// its transaction, reproducing exactly the cascade the repository-attribute
306/// `dependent(PgChildRepository, …)` form produces.
307///
308/// Framework plumbing; not constructed by hand.
309///
310/// [`cascade`]: RuntimeDependentSpec::cascade
311pub struct RuntimeDependentSpec {
312 /// The child foreign-key column referencing the parent id. Informational:
313 /// the [`cascade`] thunk already binds it. Mirrors the repository-attribute
314 /// `fk = "…"`.
315 ///
316 /// [`cascade`]: RuntimeDependentSpec::cascade
317 pub fk: &'static str,
318 /// What to do with the children when the parent is deleted.
319 pub action: DependentAction,
320 /// Type-erased entry into the child repository's cascade leaf executor.
321 pub cascade: RuntimeDependentCascadeFn,
322}
323
324/// Exposes a model's runtime dependent-cascade specs to the parent
325/// repository's generated `delete_by_id` (#1738).
326///
327/// A blanket impl returns an empty slice for every type; `#[model]` emits an
328/// *inherent* `dependents()` associated function — which shadows this trait
329/// method under Rust's inherent-before-trait resolution, mirroring the
330/// [`AutumnLockVersionModelExt`] pattern — only when the model declares at
331/// least one `#[has_many(…, dependent = …)]` / `#[has_one(…, dependent = …)]`
332/// association. The generated repository calls `Model::dependents()`, so a
333/// model with no dependents (or one defined without `#[model]`) resolves to the
334/// empty blanket and keeps its exact prior delete codegen.
335///
336/// Precedence: the repository-attribute `dependent(…)` form is the explicit
337/// escape hatch (needed for child repositories that do not follow the
338/// `Pg{Child}Repository` convention, e.g. cross-crate children). When a
339/// repository declares `dependent(…)`, its compile-time specs are authoritative
340/// and the model-declared runtime specs are ignored; a repository with no
341/// `dependent(…)` drives the cascade from `Model::dependents()`.
342pub trait AutumnDependents {
343 /// The model's dependent-cascade specs, in declaration order. Defaults to
344 /// none; `#[model]` overrides via an inherent shadow when dependents exist.
345 #[must_use]
346 fn dependents() -> &'static [RuntimeDependentSpec] {
347 &[]
348 }
349}
350
351// Blanket fallback — any type without an inherent `dependents()` (i.e. a model
352// with no dependent associations, or a type not built through `#[model]`)
353// resolves to the empty slice.
354impl<T: ?Sized> AutumnDependents for T {}
355
356/// Publish a batch of deferred dependent-cascade OOB *delete* broadcasts.
357///
358/// Accumulated during a `dependent = destroy` cascade over `broadcasts = true`
359/// children and published **after** the parent transaction commits (#1369).
360///
361/// Each entry is `(topic, dom_id)`; the fragment is empty and the swap is
362/// [`OobSwap::Delete`](crate::htmx::OobSwap::Delete). Deferring to post-commit
363/// means a rolled-back cascade publishes nothing, so live clients are never told
364/// to remove a child that still exists. Commit-hook children defer via the
365/// durable outbox instead and are not routed through here.
366///
367/// Framework plumbing; not a public API. No-op when the live-channel /
368/// `maud` / `htmx` features are not built in (the buffer is always empty then).
369#[cfg(all(feature = "ws", feature = "maud", feature = "htmx"))]
370#[doc(hidden)]
371pub fn publish_deferred_dependent_broadcasts(broadcasts: Vec<(String, String)>) {
372 if broadcasts.is_empty() {
373 return;
374 }
375 if let Some(channels) = crate::__private::get_global_channels() {
376 let fragment = crate::html! {};
377 // Consume the buffer so the owned (topic, dom_id) strings move straight
378 // into the publish call (no needless clone, and the `Vec` is consumed —
379 // avoiding `clippy::needless_pass_by_value`).
380 for (topic, dom_id) in broadcasts {
381 if let Err(err) = channels.broadcast().publish_oob(
382 &topic,
383 &dom_id,
384 &crate::htmx::OobSwap::Delete,
385 &fragment,
386 ) {
387 tracing::warn!(
388 error = %err,
389 "auto-broadcast dependent-destroy delete failed"
390 );
391 }
392 }
393 }
394}
395
396/// No-op fallback when live broadcasting is not compiled in.
397///
398/// The accumulation
399/// side only runs for `broadcasts = true` children (which require these
400/// features), so the buffer is always empty in this configuration.
401#[cfg(not(all(feature = "ws", feature = "maud", feature = "htmx")))]
402#[doc(hidden)]
403pub fn publish_deferred_dependent_broadcasts(_broadcasts: Vec<(String, String)>) {}
404
405/// Extension trait that provides a fallback `None` for model structs that do
406/// not have a `#[lock_version]` field — or that are defined manually without
407/// going through `#[model]`.
408///
409/// `#[model]` generates an *inherent* method with the same name on the model
410/// and on `UpdateModel`; inherent methods take priority over trait methods in
411/// Rust's method-resolution order. For types without `#[lock_version]` (or
412/// without `#[model]` altogether), the trait provides the `None` fallback so
413/// the generated repository code can call these methods unconditionally.
414#[doc(hidden)]
415pub trait AutumnLockVersionModelExt {
416 fn __autumn_lock_version_actual(&self) -> Option<i64> {
417 None
418 }
419}
420
421#[doc(hidden)]
422pub trait AutumnLockVersionUpdateExt {
423 fn __autumn_lock_version_expected(&self) -> Option<i64> {
424 None
425 }
426}
427
428// Blanket impls — any type that doesn't have an inherent implementation
429// (generated by `#[model]`) falls through to these, returning `None`.
430impl<T: ?Sized> AutumnLockVersionModelExt for T {}
431impl<T: ?Sized> AutumnLockVersionUpdateExt for T {}
432
433#[doc(hidden)]
434pub trait AutumnColumnCountExt {
435 fn __autumn_column_count(&self) -> usize;
436}
437
438#[doc(hidden)]
439pub trait AutumnColumnCountSpecific {
440 fn __autumn_column_count(self) -> usize;
441}
442impl<T: AutumnColumnCountExt> AutumnColumnCountSpecific for &T {
443 fn __autumn_column_count(self) -> usize {
444 self.__autumn_column_count()
445 }
446}
447
448#[doc(hidden)]
449pub trait AutumnColumnCountFallback {
450 fn __autumn_column_count(self) -> usize;
451}
452impl<T: ?Sized> AutumnColumnCountFallback for &&T {
453 fn __autumn_column_count(self) -> usize {
454 30
455 }
456}
457
458#[doc(hidden)]
459pub trait AutumnUpsertSetExt {
460 type UpsertSet;
461 fn __autumn_upsert_set() -> Self::UpsertSet;
462}
463
464#[doc(hidden)]
465pub trait AutumnUpsertExecutionExt {
466 type Model;
467 fn __autumn_execute_upsert<'a>(
468 chunk: &'a [Self::Model],
469 tenant_id: ::core::option::Option<&'a str>,
470 conn: &'a mut crate::db::RuntimeConnection,
471 ) -> impl ::std::future::Future<
472 Output = ::core::result::Result<::std::vec::Vec<Self::Model>, ::diesel::result::Error>,
473 > + Send
474 + 'a;
475}
476
477#[doc(hidden)]
478pub trait AutumnCorrelateExt: Sized {
479 type NewModel: Sized;
480 fn __autumn_correlate_new(
481 inputs: &[Self::NewModel],
482 record: &Self,
483 matched: &mut [bool],
484 ) -> ::core::option::Option<usize>;
485
486 fn __autumn_correlate_model(
487 inputs: &[Self],
488 record: &Self,
489 matched: &mut [bool],
490 ) -> ::core::option::Option<usize>;
491}
492
493/// Extension trait to override `tenant_id` on changesets in tenant-scoped updates.
494pub trait CanSetTenantId {
495 fn set_tenant_id(&mut self, tenant_id: String);
496}
497
498/// Trait implemented by models to expose their primary key value.
499pub trait ModelPrimaryKey {
500 type IdType: ::std::fmt::Display + ::core::clone::Clone + Send + Sync + 'static;
501 fn primary_key_value(&self) -> Self::IdType;
502}
503
504/// Framework plumbing bridging a `#[repository]`-generated struct to the
505/// many-to-many (`#[has_many(Target, through = join_table)]`) mutation
506/// helpers `#[model]` generates.
507///
508/// `#[repository(Model, ...)]` implements this
509/// once, unconditionally, alongside the model's other generated impls; the
510/// `Model` associated type is what keeps `add_*`/`remove_*`/`set_*` method
511/// resolution unambiguous when more than one m2m trait is in scope (e.g. two
512/// models both associate `through` the same join table, or a model has more
513/// than one m2m association) — each mutation trait is blanket-implemented
514/// only for `M2mConnSource<Model = TheAssociationsOwner>`.
515///
516/// Not part of the public API; not implemented by hand.
517#[cfg(feature = "db")]
518#[doc(hidden)]
519pub trait M2mConnSource: Send + Sync {
520 /// The model whose `#[has_many(..., through = ...)]` associations this
521 /// repository's mutation helpers operate on.
522 type Model;
523
524 /// Acquire a primary-pool connection for an `add_*`/`remove_*`/`set_*`
525 /// many-to-many mutation.
526 ///
527 /// Mirrors the write-connection acquisition every
528 /// other mutating generated method uses (marks the read-your-writes pin
529 /// on success).
530 fn __autumn_m2m_write_conn(
531 &self,
532 ) -> impl ::std::future::Future<
533 Output = crate::AutumnResult<
534 diesel_async::pooled_connection::deadpool::Object<crate::db::RuntimeConnection>,
535 >,
536 > + Send;
537}
538
539/// Metadata trait implemented for model structs to expose FTS configuration.
540pub trait AutumnSearchableModel {
541 const IS_SEARCHABLE: bool;
542 const SEARCH_LANGUAGE: &'static str;
543 const SEARCH_FIELDS: &'static [(&'static str, char)];
544}
545
546/// Build a fail-closed `SQLite` FTS5 `MATCH` query string from raw user input
547/// (issue #1910).
548///
549/// `SQLite` FTS5's `MATCH` right-hand operand is a query *language*, not a plain
550/// string: bare words are terms, but `AND` / `OR` / `NOT` / `NEAR`, a `col:`
551/// prefix, `*` (prefix), `^` (initial-token), `(`/`)` grouping, `"` phrases and
552/// a leading `-` are all operators. Passing raw user input straight to `MATCH`
553/// would let a caller inject FTS5 query syntax — or, more commonly, trigger a
554/// hard syntax error on innocuous punctuation. This helper neutralizes all of
555/// it: the input is split on Unicode whitespace and each token is emitted as a
556/// quoted FTS5 string literal (a `"..."` phrase), with any embedded `"` doubled
557/// (`""`) per FTS5's own string escaping. The quoted tokens are joined with a
558/// single space, which FTS5 reads as an implicit AND of literal phrases. Every
559/// operator character therefore becomes part of a literal term and can never
560/// change the query's structure (the analogue of the Postgres path's
561/// `websearch_to_tsquery` and the old LIKE path's metacharacter escaping).
562///
563/// Returns `None` when the input yields no tokens (empty or whitespace-only).
564/// The caller MUST treat `None` as an empty result set and run **no** query —
565/// never an unfiltered scan.
566#[must_use]
567pub fn sqlite_fts5_match_query(input: &str) -> Option<String> {
568 // Worst case (no embedded quotes to double): every byte kept plus the two
569 // surrounding quotes of a single token — pre-size to avoid reallocation.
570 let mut out = String::with_capacity(input.len() + 2);
571 for token in input.split_whitespace() {
572 if !out.is_empty() {
573 out.push(' ');
574 }
575 out.push('"');
576 for ch in token.chars() {
577 if ch == '"' {
578 // FTS5 escapes a literal double-quote inside a "..." string by
579 // doubling it.
580 out.push('"');
581 }
582 out.push(ch);
583 }
584 out.push('"');
585 }
586 if out.is_empty() { None } else { Some(out) }
587}
588
589/// Derive a stable signed 64-bit advisory lock key for repository upserts.
590///
591/// Generated versioned repositories use this before pre-reading rows for
592/// `upsert_many`, so concurrent generated upserts for the same table/id cannot
593/// classify audit history from a stale missing-row snapshot.
594#[doc(hidden)]
595#[must_use]
596pub fn repository_upsert_advisory_lock_key(table_name: &str, record_id: i64) -> i64 {
597 let mut hasher = Sha256::new();
598 hasher.update(b"repository_upsert\0");
599 hasher.update(table_name.as_bytes());
600 hasher.update(b"\0");
601 hasher.update(record_id.to_be_bytes());
602 let digest = hasher.finalize();
603 let mut bytes = [0_u8; 8];
604 bytes.copy_from_slice(&digest[..8]);
605 i64::from_be_bytes(bytes)
606}
607
608/// Trait for formatting repository model fields into SSE broadcast topic segments.
609///
610/// This provides a uniform display format for both primitive types and optional/nullable values.
611pub trait DisplayTopicField {
612 /// Formats the field into a string suitable for a topic name.
613 fn to_topic_string(&self) -> String;
614}
615
616impl DisplayTopicField for String {
617 fn to_topic_string(&self) -> String {
618 self.clone()
619 }
620}
621
622impl DisplayTopicField for &str {
623 fn to_topic_string(&self) -> String {
624 (*self).to_string()
625 }
626}
627
628impl DisplayTopicField for bool {
629 fn to_topic_string(&self) -> String {
630 self.to_string()
631 }
632}
633
634impl DisplayTopicField for char {
635 fn to_topic_string(&self) -> String {
636 self.to_string()
637 }
638}
639
640macro_rules! impl_display_topic_field_num {
641 ($($t:ty),*) => {
642 $(
643 impl DisplayTopicField for $t {
644 fn to_topic_string(&self) -> String {
645 self.to_string()
646 }
647 }
648 )*
649 };
650}
651
652impl_display_topic_field_num!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
653
654impl DisplayTopicField for ::uuid::Uuid {
655 fn to_topic_string(&self) -> String {
656 self.to_string()
657 }
658}
659
660impl DisplayTopicField for ::chrono::NaiveDateTime {
661 fn to_topic_string(&self) -> String {
662 self.to_string()
663 }
664}
665
666impl DisplayTopicField for ::chrono::NaiveDate {
667 fn to_topic_string(&self) -> String {
668 self.to_string()
669 }
670}
671
672impl DisplayTopicField for ::chrono::NaiveTime {
673 fn to_topic_string(&self) -> String {
674 self.to_string()
675 }
676}
677
678impl<T> DisplayTopicField for ::chrono::DateTime<T>
679where
680 T: ::chrono::TimeZone,
681 T::Offset: std::fmt::Display,
682{
683 fn to_topic_string(&self) -> String {
684 self.to_string()
685 }
686}
687
688impl<T: DisplayTopicField> DisplayTopicField for Option<T> {
689 fn to_topic_string(&self) -> String {
690 self.as_ref()
691 .map_or_else(|| "none".to_string(), DisplayTopicField::to_topic_string)
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
700 fn fts5_match_quotes_plain_terms_and_ands_them() {
701 // A plain multi-word query becomes an implicit-AND of quoted phrases.
702 assert_eq!(
703 sqlite_fts5_match_query("rust web").as_deref(),
704 Some("\"rust\" \"web\"")
705 );
706 assert_eq!(
707 sqlite_fts5_match_query("hello").as_deref(),
708 Some("\"hello\"")
709 );
710 }
711
712 #[test]
713 fn fts5_match_neutralizes_operators_as_literals() {
714 // FTS5 operators are quoted into literal terms — never parsed as syntax.
715 assert_eq!(
716 sqlite_fts5_match_query("foo OR bar").as_deref(),
717 Some("\"foo\" \"OR\" \"bar\"")
718 );
719 assert_eq!(
720 sqlite_fts5_match_query("title:secret").as_deref(),
721 Some("\"title:secret\"")
722 );
723 assert_eq!(sqlite_fts5_match_query("pre*").as_deref(), Some("\"pre*\""));
724 assert_eq!(
725 sqlite_fts5_match_query("NEAR(a b)").as_deref(),
726 Some("\"NEAR(a\" \"b)\"")
727 );
728 }
729
730 #[test]
731 fn fts5_match_doubles_embedded_quotes() {
732 // An embedded double-quote is escaped by doubling, so it can never close
733 // the phrase early and inject trailing syntax.
734 assert_eq!(
735 sqlite_fts5_match_query("a\"b").as_deref(),
736 Some("\"a\"\"b\"")
737 );
738 assert_eq!(
739 sqlite_fts5_match_query("\" OR x").as_deref(),
740 Some("\"\"\"\" \"OR\" \"x\"")
741 );
742 }
743
744 #[test]
745 fn fts5_match_empty_input_is_none() {
746 // Empty / whitespace-only / no-token input yields None so the caller
747 // returns an empty page WITHOUT running any query (fail-closed).
748 assert_eq!(sqlite_fts5_match_query(""), None);
749 assert_eq!(sqlite_fts5_match_query(" "), None);
750 assert_eq!(sqlite_fts5_match_query("\t\n \r"), None);
751 }
752
753 #[test]
754 fn conflict_variant_stores_all_fields() {
755 let err = RepositoryError::Conflict {
756 id: 42,
757 expected_version: 3,
758 actual_version: Some(4),
759 };
760 match err {
761 RepositoryError::Conflict {
762 id,
763 expected_version,
764 actual_version,
765 } => {
766 assert_eq!(id, 42);
767 assert_eq!(expected_version, 3);
768 assert_eq!(actual_version, Some(4));
769 }
770 }
771 }
772
773 #[test]
774 fn conflict_with_no_actual_version() {
775 let err = RepositoryError::Conflict {
776 id: 1,
777 expected_version: 0,
778 actual_version: None,
779 };
780 assert!(matches!(
781 err,
782 RepositoryError::Conflict {
783 actual_version: None,
784 ..
785 }
786 ));
787 }
788
789 #[test]
790 fn conflict_display_includes_id_and_expected_version() {
791 let err = RepositoryError::Conflict {
792 id: 99,
793 expected_version: 7,
794 actual_version: Some(8),
795 };
796 let s = err.to_string();
797 assert!(s.contains("99"), "display should include id");
798 assert!(s.contains('7'), "display should include expected_version");
799 }
800
801 #[test]
802 fn conflict_is_clone() {
803 let err = RepositoryError::Conflict {
804 id: 1,
805 expected_version: 0,
806 actual_version: Some(1),
807 };
808 let cloned = err.clone();
809 assert!(matches!(err, RepositoryError::Conflict { id: 1, .. }));
810 assert!(matches!(cloned, RepositoryError::Conflict { id: 1, .. }));
811 }
812
813 #[test]
814 fn conflict_implements_std_error() {
815 let err = RepositoryError::Conflict {
816 id: 1,
817 expected_version: 0,
818 actual_version: None,
819 };
820 let _: &dyn std::error::Error = &err;
821 }
822
823 #[test]
824 fn repository_upsert_advisory_lock_key_is_stable_for_same_table_and_id() {
825 let a = repository_upsert_advisory_lock_key("posts", 42);
826 let b = repository_upsert_advisory_lock_key("posts", 42);
827
828 assert_eq!(a, b);
829 assert_ne!(a, 0);
830 }
831
832 #[test]
833 fn repository_upsert_advisory_lock_key_separates_table_and_id() {
834 let key = repository_upsert_advisory_lock_key("posts", 42);
835
836 assert_ne!(key, repository_upsert_advisory_lock_key("comments", 42));
837 assert_ne!(key, repository_upsert_advisory_lock_key("posts", 43));
838 }
839}