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. They're additive — the
9//! existing builders still take `&'static ModelDescriptor<M, PK>`
10//! today; the genericization to `&'static dyn ReadSource<M, PK>` (or
11//! a blanket `D: ReadSource<M, PK>` bound) lands in a follow-up PR
12//! once the trait shape has settled.
13//!
14//! **Why two traits?** [`ReadSource`] captures everything a read
15//! builder needs (table / view name, columns, primary key, soft-delete
16//! gate, read/detail policies, projection emission). [`WriteSource`]
17//! extends it with the create/update/delete-only state — defaults,
18//! audit, retention, versioning, upsert columns, write policy slots.
19//!
20//! Views deliberately do not implement `WriteSource`, so the macro
21//! cannot accidentally wire a view through a write builder. The
22//! read-only-ness guarantee for views is enforced at the type level.
23
24use cratestack_core::ModelEventKind;
25use cratestack_policy::ReadPolicy;
26
27use super::{CreateDefault, ModelColumn};
28
29/// Anything a read-path query builder needs to plan and emit SQL.
30///
31/// `M` is the Rust struct deserialized from a row; `PK` is the
32/// primary-key Rust type. Both descriptors and the read builders are
33/// generic over the same `(M, PK)` pair so the bounds line up.
34///
35/// `Send + Sync` are required so that `&'static dyn ReadSource<M, PK>`
36/// is `Send`. Axum handler futures capture the trait object across
37/// `await` points; without these bounds those futures stop being
38/// `Send`, which makes them unusable as Axum handlers. Both
39/// first-party impls (`ModelDescriptor`, `ViewDescriptor`) are
40/// trivially `Send + Sync` — every field is either a `&'static`
41/// reference to a primitive slice or a `PhantomData<fn() -> _>`, all
42/// of which are themselves `Send + Sync` regardless of `M` / `PK`.
43pub trait ReadSource<M, PK>: Send + Sync {
44    /// Logical schema name the model / view lives under. Currently
45    /// always the dataset schema declared in `datasource db { ... }`;
46    /// kept on the trait so future per-source schemas (e.g. analytics
47    /// views in a dedicated schema) are a non-breaking change.
48    fn schema_name(&self) -> &'static str;
49
50    /// SQL identifier of the table *or* view this source reads from.
51    /// Both backends quote it verbatim when constructing `FROM`
52    /// clauses.
53    fn table_name(&self) -> &'static str;
54
55    /// All projectable columns, ordered as the descriptor declares
56    /// them. The read builder relies on this order when binding row
57    /// decoders.
58    fn columns(&self) -> &'static [ModelColumn];
59
60    /// SQL column name of the primary key. For views declared with
61    /// `@@no_unique` (ADR-0003 §"Schema surface") this is the empty
62    /// string — `find_unique` is not emitted on the delegate so the
63    /// builder never reads this slot.
64    fn primary_key(&self) -> &'static str;
65
66    /// Names accepted in `where = { <name>: <op> }` filter payloads
67    /// — the same allow-list the model uses for read-policy scoping.
68    fn allowed_fields(&self) -> &'static [&'static str];
69
70    /// Names accepted in `include = { <name>: ... }` payloads. Empty
71    /// on views in v1 (relation-follow off a view is out of scope —
72    /// see ADR-0003 "Deferred").
73    fn allowed_includes(&self) -> &'static [&'static str];
74
75    /// Names accepted in `orderBy = [ <name>, ... ]` payloads.
76    fn allowed_sorts(&self) -> &'static [&'static str];
77
78    /// `@@allow("read", ...)` policy literals for the list / search
79    /// shape (returns one row per matching record).
80    fn read_allow_policies(&self) -> &'static [ReadPolicy];
81
82    /// `@@deny("read", ...)` policy literals for the list shape.
83    fn read_deny_policies(&self) -> &'static [ReadPolicy];
84
85    /// `@@allow("read", ...)` policy literals for the detail shape
86    /// (`find_unique` — returns at most one record). Models can carry
87    /// stricter detail policies than list ones; views inherit a
88    /// single set declared via `@@allow("read", ...)` on the view
89    /// itself.
90    fn detail_allow_policies(&self) -> &'static [ReadPolicy];
91
92    /// `@@deny("read", ...)` policy literals for the detail shape.
93    fn detail_deny_policies(&self) -> &'static [ReadPolicy];
94
95    /// Soft-delete sentinel column name. `None` on views (and on
96    /// models without `@@soft_delete`), in which case the read
97    /// builder skips the `<col> IS NULL` predicate it would otherwise
98    /// inject.
99    fn soft_delete_column(&self) -> Option<&'static str>;
100
101    /// Returns the `<col> AS "<alias>", ...` projection list the
102    /// builder splices into `SELECT`. The default impl delegates to
103    /// [`Self::columns`] so any descriptor that just stores a column
104    /// list gets a working projection for free.
105    fn select_projection(&self) -> String {
106        use std::fmt::Write;
107        let mut sql = String::new();
108        for (index, column) in self.columns().iter().enumerate() {
109            if index > 0 {
110                sql.push_str(", ");
111            }
112            let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
113        }
114        sql
115    }
116
117    /// Like [`Self::select_projection`] but emits only the named
118    /// columns. Unknown names are silently dropped — same contract
119    /// as [`super::ModelDescriptor::select_projection_subset`].
120    fn select_projection_subset(&self, columns: &[&str]) -> String {
121        use std::fmt::Write;
122        let mut sql = String::new();
123        let mut emitted = false;
124        for column in self.columns().iter() {
125            if columns.iter().any(|name| *name == column.sql_name) {
126                if emitted {
127                    sql.push_str(", ");
128                }
129                let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
130                emitted = true;
131            }
132        }
133        if !emitted {
134            if let Some(pk_column) = self
135                .columns()
136                .iter()
137                .find(|column| column.sql_name == self.primary_key())
138            {
139                let _ = write!(sql, "{} AS \"{}\"", pk_column.sql_name, pk_column.rust_name);
140            }
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}