Skip to main content

cratestack_sql/descriptor/
read_source.rs

1//! Abstract read/write source traits shared between
2//! [`ModelDescriptor`](super::ModelDescriptor) and
3//! [`ViewDescriptor`](super::ViewDescriptor).
4//!
5//! These traits exist so the read-builder family
6//! (`FindMany` / `FindUnique` / `Aggregate` / `push_scoped_conditions`)
7//! can take *either* descriptor without the macro having to duplicate
8//! the entire query surface for views. The read builders in
9//! `cratestack-sqlx` (`FindMany`, `FindUnique`, `Aggregate`, ...) and
10//! `push_scoped_conditions` hold or take a `&'static dyn
11//! ReadSource<M, PK>` (`&dyn ReadSource<M, PK>` for the latter), which
12//! both `ModelDescriptor` and `ViewDescriptor` implement. The write
13//! builders still take `&'static ModelDescriptor<M, PK>`.
14//!
15//! **Why two traits?** [`ReadSource`] captures everything a read
16//! builder needs (table / view name, columns, primary key, soft-delete
17//! gate, read/detail policies, projection emission). [`WriteSource`]
18//! extends it with the create/update/delete-only state — defaults,
19//! audit, retention, versioning, upsert columns, write policy slots.
20//!
21//! Views deliberately do not implement `WriteSource`, so the macro
22//! cannot accidentally wire a view through a write builder. The
23//! read-only-ness guarantee for views is enforced at the type level.
24
25use cratestack_core::ModelEventKind;
26use cratestack_policy::ReadPolicy;
27
28use super::{CreateDefault, ModelColumn};
29
30/// Anything a read-path query builder needs to plan and emit SQL.
31///
32/// `M` is the Rust struct deserialized from a row; `PK` is the
33/// primary-key Rust type. Both descriptors and the read builders are
34/// generic over the same `(M, PK)` pair so the bounds line up.
35///
36/// `Send + Sync` are required so that `&'static dyn ReadSource<M, PK>`
37/// is `Send`. Axum handler futures capture the trait object across
38/// `await` points; without these bounds those futures stop being
39/// `Send`, which makes them unusable as Axum handlers. Both
40/// first-party impls (`ModelDescriptor`, `ViewDescriptor`) are
41/// trivially `Send + Sync` — every field is either a `&'static`
42/// reference to a primitive slice or a `PhantomData<fn() -> _>`, all
43/// of which are themselves `Send + Sync` regardless of `M` / `PK`.
44pub trait ReadSource<M, PK>: Send + Sync {
45    /// Logical schema name the model / view lives under. Currently
46    /// always the dataset schema declared in `datasource db { ... }`;
47    /// kept on the trait so future per-source schemas (e.g. analytics
48    /// views in a dedicated schema) are a non-breaking change.
49    fn schema_name(&self) -> &'static str;
50
51    /// SQL identifier of the table *or* view this source reads from.
52    /// Both backends quote it verbatim when constructing `FROM`
53    /// clauses.
54    fn table_name(&self) -> &'static str;
55
56    /// All projectable columns, ordered as the descriptor declares
57    /// them. The read builder relies on this order when binding row
58    /// decoders.
59    fn columns(&self) -> &'static [ModelColumn];
60
61    /// SQL column name of the primary key. For views declared with
62    /// `@@no_unique` (ADR-0003 §"Schema surface") this is the empty
63    /// string — `find_unique` is not emitted on the delegate so the
64    /// builder never reads this slot.
65    fn primary_key(&self) -> &'static str;
66
67    /// Names accepted in `where = { <name>: <op> }` filter payloads
68    /// — the same allow-list the model uses for read-policy scoping.
69    fn allowed_fields(&self) -> &'static [&'static str];
70
71    /// Names accepted in `include = { <name>: ... }` payloads. Empty
72    /// on views in v1 (relation-follow off a view is out of scope —
73    /// see ADR-0003 "Deferred").
74    fn allowed_includes(&self) -> &'static [&'static str];
75
76    /// Names accepted in `orderBy = [ <name>, ... ]` payloads.
77    fn allowed_sorts(&self) -> &'static [&'static str];
78
79    /// `@@allow("read", ...)` policy literals for the list / search
80    /// shape (returns one row per matching record).
81    fn read_allow_policies(&self) -> &'static [ReadPolicy];
82
83    /// `@@deny("read", ...)` policy literals for the list shape.
84    fn read_deny_policies(&self) -> &'static [ReadPolicy];
85
86    /// `@@allow("read", ...)` policy literals for the detail shape
87    /// (`find_unique` — returns at most one record). Models can carry
88    /// stricter detail policies than list ones; views inherit a
89    /// single set declared via `@@allow("read", ...)` on the view
90    /// itself.
91    fn detail_allow_policies(&self) -> &'static [ReadPolicy];
92
93    /// `@@deny("read", ...)` policy literals for the detail shape.
94    fn detail_deny_policies(&self) -> &'static [ReadPolicy];
95
96    /// Soft-delete sentinel column name. `None` on views (and on
97    /// models without `@@soft_delete`), in which case the read
98    /// builder skips the `<col> IS NULL` predicate it would otherwise
99    /// inject.
100    fn soft_delete_column(&self) -> Option<&'static str>;
101
102    /// Returns the `<col> AS "<alias>", ...` projection list the
103    /// builder splices into `SELECT`. The default impl delegates to
104    /// [`Self::columns`] so any descriptor that just stores a column
105    /// list gets a working projection for free.
106    fn select_projection(&self) -> String {
107        use std::fmt::Write;
108        let mut sql = String::new();
109        for (index, column) in self.columns().iter().enumerate() {
110            if index > 0 {
111                sql.push_str(", ");
112            }
113            let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
114        }
115        sql
116    }
117
118    /// Like [`Self::select_projection`] but emits only the named
119    /// columns. Unknown names are silently dropped — same contract
120    /// as [`super::ModelDescriptor::select_projection_subset`].
121    fn select_projection_subset(&self, columns: &[&str]) -> String {
122        use std::fmt::Write;
123        let mut sql = String::new();
124        let mut emitted = false;
125        for column in self.columns().iter() {
126            if columns.contains(&column.sql_name) {
127                if emitted {
128                    sql.push_str(", ");
129                }
130                let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
131                emitted = true;
132            }
133        }
134        if !emitted
135            && let Some(pk_column) = self
136                .columns()
137                .iter()
138                .find(|column| column.sql_name == self.primary_key())
139        {
140            let _ = write!(sql, "{} AS \"{}\"", pk_column.sql_name, pk_column.rust_name);
141        }
142        sql
143    }
144}
145
146/// Anything a write-path query builder needs on top of
147/// [`ReadSource`] — create defaults, update / delete policy slots,
148/// audit + retention + versioning state, upsert column list, emitted
149/// event topics.
150///
151/// Implemented by [`ModelDescriptor`](super::ModelDescriptor) only.
152/// Views do not implement this trait, so the type system refuses to
153/// route a view through `CreateRecord` / `UpdateRecord` /
154/// `DeleteRecord` / `UpsertModelInput`.
155pub trait WriteSource<M, PK>: ReadSource<M, PK> {
156    fn create_allow_policies(&self) -> &'static [ReadPolicy];
157    fn create_deny_policies(&self) -> &'static [ReadPolicy];
158    fn update_allow_policies(&self) -> &'static [ReadPolicy];
159    fn update_deny_policies(&self) -> &'static [ReadPolicy];
160    fn delete_allow_policies(&self) -> &'static [ReadPolicy];
161    fn delete_deny_policies(&self) -> &'static [ReadPolicy];
162
163    fn create_defaults(&self) -> &'static [CreateDefault];
164    fn emitted_events(&self) -> &'static [ModelEventKind];
165
166    /// Optimistic-locking version column (`@version`). `None` for
167    /// non-versioned models.
168    fn version_column(&self) -> Option<&'static str>;
169
170    /// `true` when the model declared `@@audit`.
171    fn audit_enabled(&self) -> bool;
172
173    fn pii_columns(&self) -> &'static [&'static str];
174    fn sensitive_columns(&self) -> &'static [&'static str];
175
176    /// Soft-delete retention window. Surfaced here (alongside the
177    /// soft-delete column on [`ReadSource`]) so the operator's GC
178    /// job can read both pieces from one place.
179    fn retention_days(&self) -> Option<u32>;
180
181    /// Columns the upsert primitive is allowed to overwrite on
182    /// conflict.
183    fn upsert_update_columns(&self) -> &'static [&'static str];
184}