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}