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    pub const fn new(
85        schema_name: &'static str,
86        table_name: &'static str,
87        columns: &'static [ModelColumn],
88        primary_key: &'static str,
89        allowed_fields: &'static [&'static str],
90        allowed_includes: &'static [&'static str],
91        allowed_sorts: &'static [&'static str],
92        read_allow_policies: &'static [ReadPolicy],
93        read_deny_policies: &'static [ReadPolicy],
94        detail_allow_policies: &'static [ReadPolicy],
95        detail_deny_policies: &'static [ReadPolicy],
96        create_allow_policies: &'static [ReadPolicy],
97        create_deny_policies: &'static [ReadPolicy],
98        update_allow_policies: &'static [ReadPolicy],
99        update_deny_policies: &'static [ReadPolicy],
100        delete_allow_policies: &'static [ReadPolicy],
101        delete_deny_policies: &'static [ReadPolicy],
102        create_defaults: &'static [CreateDefault],
103        emitted_events: &'static [ModelEventKind],
104        version_column: Option<&'static str>,
105        audit_enabled: bool,
106        pii_columns: &'static [&'static str],
107        sensitive_columns: &'static [&'static str],
108        soft_delete_column: Option<&'static str>,
109        retention_days: Option<u32>,
110        upsert_update_columns: &'static [&'static str],
111    ) -> Self {
112        Self {
113            schema_name,
114            table_name,
115            columns,
116            primary_key,
117            allowed_fields,
118            allowed_includes,
119            allowed_sorts,
120            read_allow_policies,
121            read_deny_policies,
122            detail_allow_policies,
123            detail_deny_policies,
124            create_allow_policies,
125            create_deny_policies,
126            update_allow_policies,
127            update_deny_policies,
128            delete_allow_policies,
129            delete_deny_policies,
130            create_defaults,
131            emitted_events,
132            version_column,
133            audit_enabled,
134            pii_columns,
135            sensitive_columns,
136            soft_delete_column,
137            retention_days,
138            upsert_update_columns,
139            _marker: PhantomData,
140        }
141    }
142
143    pub fn emits(&self, operation: ModelEventKind) -> bool {
144        self.emitted_events.contains(&operation)
145    }
146
147    pub fn select_projection(&self) -> String {
148        let mut sql = String::new();
149        for (index, column) in self.columns.iter().enumerate() {
150            if index > 0 {
151                sql.push_str(", ");
152            }
153            let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
154        }
155        sql
156    }
157
158    /// Like [`Self::select_projection`] but emits only the named
159    /// columns, in the order they appear in the model descriptor.
160    /// Unknown column names are silently dropped — the caller is
161    /// expected to have validated the request via `FieldRef` already
162    /// (typed-builder path) or via schema validation
163    /// (string-name path). When no columns survive the filter, the
164    /// primary key is emitted as a fallback so the SQL still binds
165    /// at least one column to the projection.
166    pub fn select_projection_subset(&self, columns: &[&str]) -> String {
167        let mut sql = String::new();
168        let mut emitted = false;
169        for column in self.columns.iter() {
170            if columns.iter().any(|name| *name == column.sql_name) && {
171                if emitted {
172                    sql.push_str(", ");
173                }
174                let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
175                emitted = true;
176                true
177            } {}
178        }
179        if !emitted {
180            // Fallback: always project the primary key so the
181            // emitted SQL is valid and downstream code can still
182            // identify rows. Callers asking for an empty projection
183            // are misusing the API — but we soft-handle it rather
184            // than producing `SELECT FROM table` which PG rejects.
185            if let Some(pk_column) = self
186                .columns
187                .iter()
188                .find(|column| column.sql_name == self.primary_key)
189            {
190                let _ = write!(sql, "{} AS \"{}\"", pk_column.sql_name, pk_column.rust_name,);
191            }
192        }
193        sql
194    }
195}
196
197// `ReadSource` / `WriteSource` impls for `ModelDescriptor` live in
198// `descriptor/model_impls.rs` — pulled out to keep this file under the
199// 200-LoC ceiling.