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