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}