Skip to main content

cratestack_sql/descriptor/
mod.rs

1use std::fmt::Write;
2use std::marker::PhantomData;
3
4use cratestack_core::ModelEventKind;
5use cratestack_policy::ReadPolicy;
6
7mod defaults;
8mod embedded_defaults;
9mod model_impls;
10mod read_source;
11mod view;
12
13#[cfg(test)]
14mod tests_view;
15
16pub use defaults::{CreateDefault, CreateDefaultType, GeneratedDefault, GeneratedValue};
17pub use embedded_defaults::{DatabaseDefault, LiteralDefault};
18pub use read_source::{ReadSource, WriteSource};
19pub use view::ViewDescriptor;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct ModelColumn {
23    pub rust_name: &'static str,
24    pub sql_name: &'static str,
25}
26
27#[derive(Debug, Clone, Copy)]
28pub struct ModelDescriptor<M, PK> {
29    pub schema_name: &'static str,
30    pub table_name: &'static str,
31    pub columns: &'static [ModelColumn],
32    pub primary_key: &'static str,
33    pub allowed_fields: &'static [&'static str],
34    pub allowed_includes: &'static [&'static str],
35    pub allowed_sorts: &'static [&'static str],
36    pub read_allow_policies: &'static [ReadPolicy],
37    pub read_deny_policies: &'static [ReadPolicy],
38    pub detail_allow_policies: &'static [ReadPolicy],
39    pub detail_deny_policies: &'static [ReadPolicy],
40    pub create_allow_policies: &'static [ReadPolicy],
41    pub create_deny_policies: &'static [ReadPolicy],
42    pub update_allow_policies: &'static [ReadPolicy],
43    pub update_deny_policies: &'static [ReadPolicy],
44    pub delete_allow_policies: &'static [ReadPolicy],
45    pub delete_deny_policies: &'static [ReadPolicy],
46    pub create_defaults: &'static [CreateDefault],
47    /// `@default(now())`, `uuid()`, `cuid()` columns; see [`GeneratedDefault`].
48    pub generated_defaults: &'static [GeneratedDefault],
49    /// Literal `@default` columns, which the embedded runtime binds itself;
50    /// see [`LiteralDefault`]. The server leaves them to the migration's
51    /// `DEFAULT` clause.
52    pub literal_defaults: &'static [LiteralDefault],
53    /// Required columns only the database can fill; see [`DatabaseDefault`].
54    pub database_defaults: &'static [DatabaseDefault],
55    pub emitted_events: &'static [ModelEventKind],
56    /// Column name of the optimistic-locking version field, set when the
57    /// model declares an `@version` field. `None` for non-versioned models,
58    /// which keeps update semantics unchanged.
59    pub version_column: Option<&'static str>,
60    /// `true` when the model declared `@@audit`. Mutations on audit-enabled
61    /// models capture before/after snapshots and persist them into
62    /// `cratestack_audit` inside the same transaction.
63    pub audit_enabled: bool,
64    /// SQL column names of fields declared `@pii`. The audit-log writer
65    /// replaces these values with `"[redacted-pii]"` in the persisted JSON
66    /// snapshots; a follow-up will extend the same redaction to error
67    /// detail and tracing. `@pii` and `@sensitive` are off the per-op
68    /// client contract (`cratestack_core`'s `DROPPED_ATTRIBUTES`) only while
69    /// this stays audit-only: redacting a response, error or event body
70    /// retypes a field on the wire, and must put both back on it (the
71    /// `redaction_stays_in_the_audit_writer` test pins the readers).
72    pub pii_columns: &'static [&'static str],
73    /// SQL column names of fields declared `@sensitive`. Redacted in audit
74    /// snapshots as `"[redacted-sensitive]"`.
75    pub sensitive_columns: &'static [&'static str],
76    /// Column name for the soft-delete timestamp. When `Some`, DELETE
77    /// operations become UPDATE-of-`deleted_at` and every SELECT through
78    /// `push_scoped_conditions` filters out rows where the column is
79    /// non-null. Defaults to `Some("deleted_at")` when `@@soft_delete` is
80    /// declared.
81    pub soft_delete_column: Option<&'static str>,
82    /// Retention window in days for soft-deleted rows. The runtime does
83    /// not auto-GC; banks run their own scheduled job that deletes rows
84    /// where `deleted_at < NOW() - retention`. Surfaced here so the GC
85    /// can read the policy from one place.
86    pub retention_days: Option<u32>,
87    /// Columns the upsert primitive is allowed to overwrite on conflict.
88    /// Populated by the macro to be every scalar column *except* the
89    /// primary key, `created_at`, and the `@version` column. Empty when
90    /// the model has no eligible columns (e.g. PK-only); in that case
91    /// the macro doesn't emit an `UpsertModelInput` impl either, so this
92    /// is just a belt-and-braces.
93    pub upsert_update_columns: &'static [&'static str],
94    _marker: PhantomData<fn() -> (M, PK)>,
95}
96
97impl<M, PK> ModelDescriptor<M, PK> {
98    // The argument count mirrors the flat metadata struct this builds, not a
99    // design that's worth threading through a builder pattern.
100    #[allow(clippy::too_many_arguments)]
101    pub const fn new(
102        schema_name: &'static str,
103        table_name: &'static str,
104        columns: &'static [ModelColumn],
105        primary_key: &'static str,
106        allowed_fields: &'static [&'static str],
107        allowed_includes: &'static [&'static str],
108        allowed_sorts: &'static [&'static str],
109        read_allow_policies: &'static [ReadPolicy],
110        read_deny_policies: &'static [ReadPolicy],
111        detail_allow_policies: &'static [ReadPolicy],
112        detail_deny_policies: &'static [ReadPolicy],
113        create_allow_policies: &'static [ReadPolicy],
114        create_deny_policies: &'static [ReadPolicy],
115        update_allow_policies: &'static [ReadPolicy],
116        update_deny_policies: &'static [ReadPolicy],
117        delete_allow_policies: &'static [ReadPolicy],
118        delete_deny_policies: &'static [ReadPolicy],
119        create_defaults: &'static [CreateDefault],
120        emitted_events: &'static [ModelEventKind],
121        version_column: Option<&'static str>,
122        audit_enabled: bool,
123        pii_columns: &'static [&'static str],
124        sensitive_columns: &'static [&'static str],
125        soft_delete_column: Option<&'static str>,
126        retention_days: Option<u32>,
127        upsert_update_columns: &'static [&'static str],
128    ) -> Self {
129        Self {
130            schema_name,
131            table_name,
132            columns,
133            primary_key,
134            allowed_fields,
135            allowed_includes,
136            allowed_sorts,
137            read_allow_policies,
138            read_deny_policies,
139            detail_allow_policies,
140            detail_deny_policies,
141            create_allow_policies,
142            create_deny_policies,
143            update_allow_policies,
144            update_deny_policies,
145            delete_allow_policies,
146            delete_deny_policies,
147            create_defaults,
148            generated_defaults: &[],
149            literal_defaults: &[],
150            database_defaults: &[],
151            emitted_events,
152            version_column,
153            audit_enabled,
154            pii_columns,
155            sensitive_columns,
156            soft_delete_column,
157            retention_days,
158            upsert_update_columns,
159            _marker: PhantomData,
160        }
161    }
162
163    pub fn emits(&self, operation: ModelEventKind) -> bool {
164        self.emitted_events.contains(&operation)
165    }
166
167    pub fn select_projection(&self) -> String {
168        let mut sql = String::new();
169        for (index, column) in self.columns.iter().enumerate() {
170            if index > 0 {
171                sql.push_str(", ");
172            }
173            let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
174        }
175        sql
176    }
177
178    /// Like [`Self::select_projection`] but emits only the named
179    /// columns, in the order they appear in the model descriptor.
180    /// Unknown column names are silently dropped — the caller is
181    /// expected to have validated the request via `FieldRef` already
182    /// (typed-builder path) or via schema validation
183    /// (string-name path). When no columns survive the filter, the
184    /// primary key is emitted as a fallback so the SQL still binds
185    /// at least one column to the projection.
186    pub fn select_projection_subset(&self, columns: &[&str]) -> String {
187        let mut sql = String::new();
188        let mut emitted = false;
189        for column in self.columns.iter() {
190            if columns.contains(&column.sql_name) && {
191                if emitted {
192                    sql.push_str(", ");
193                }
194                let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
195                emitted = true;
196                true
197            } {}
198        }
199        if !emitted {
200            // Fallback: always project the primary key so the
201            // emitted SQL is valid and downstream code can still
202            // identify rows. Callers asking for an empty projection
203            // are misusing the API — but we soft-handle it rather
204            // than producing `SELECT FROM table` which PG rejects.
205            if let Some(pk_column) = self
206                .columns
207                .iter()
208                .find(|column| column.sql_name == self.primary_key)
209            {
210                let _ = write!(sql, "{} AS \"{}\"", pk_column.sql_name, pk_column.rust_name,);
211            }
212        }
213        sql
214    }
215}
216
217// `ReadSource` / `WriteSource` impls for `ModelDescriptor` live in
218// `descriptor/model_impls.rs` — pulled out to keep this file under the
219// 200-LoC ceiling.