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. `@pii` and `@sensitive` are off the per-op
58    /// client contract (`cratestack_core`'s `DROPPED_ATTRIBUTES`) only while
59    /// this stays audit-only: redacting a response, error or event body
60    /// retypes a field on the wire, and must put both back on it (the
61    /// `redaction_stays_in_the_audit_writer` test pins the readers).
62    pub pii_columns: &'static [&'static str],
63    /// SQL column names of fields declared `@sensitive`. Redacted in audit
64    /// snapshots as `"[redacted-sensitive]"`.
65    pub sensitive_columns: &'static [&'static str],
66    /// Column name for the soft-delete timestamp. When `Some`, DELETE
67    /// operations become UPDATE-of-`deleted_at` and every SELECT through
68    /// `push_scoped_conditions` filters out rows where the column is
69    /// non-null. Defaults to `Some("deleted_at")` when `@@soft_delete` is
70    /// declared.
71    pub soft_delete_column: Option<&'static str>,
72    /// Retention window in days for soft-deleted rows. The runtime does
73    /// not auto-GC; banks run their own scheduled job that deletes rows
74    /// where `deleted_at < NOW() - retention`. Surfaced here so the GC
75    /// can read the policy from one place.
76    pub retention_days: Option<u32>,
77    /// Columns the upsert primitive is allowed to overwrite on conflict.
78    /// Populated by the macro to be every scalar column *except* the
79    /// primary key, `created_at`, and the `@version` column. Empty when
80    /// the model has no eligible columns (e.g. PK-only); in that case
81    /// the macro doesn't emit an `UpsertModelInput` impl either, so this
82    /// is just a belt-and-braces.
83    pub upsert_update_columns: &'static [&'static str],
84    _marker: PhantomData<fn() -> (M, PK)>,
85}
86
87impl<M, PK> ModelDescriptor<M, PK> {
88    // The argument count mirrors the flat metadata struct this builds, not a
89    // design that's worth threading through a builder pattern.
90    #[allow(clippy::too_many_arguments)]
91    pub const fn new(
92        schema_name: &'static str,
93        table_name: &'static str,
94        columns: &'static [ModelColumn],
95        primary_key: &'static str,
96        allowed_fields: &'static [&'static str],
97        allowed_includes: &'static [&'static str],
98        allowed_sorts: &'static [&'static str],
99        read_allow_policies: &'static [ReadPolicy],
100        read_deny_policies: &'static [ReadPolicy],
101        detail_allow_policies: &'static [ReadPolicy],
102        detail_deny_policies: &'static [ReadPolicy],
103        create_allow_policies: &'static [ReadPolicy],
104        create_deny_policies: &'static [ReadPolicy],
105        update_allow_policies: &'static [ReadPolicy],
106        update_deny_policies: &'static [ReadPolicy],
107        delete_allow_policies: &'static [ReadPolicy],
108        delete_deny_policies: &'static [ReadPolicy],
109        create_defaults: &'static [CreateDefault],
110        emitted_events: &'static [ModelEventKind],
111        version_column: Option<&'static str>,
112        audit_enabled: bool,
113        pii_columns: &'static [&'static str],
114        sensitive_columns: &'static [&'static str],
115        soft_delete_column: Option<&'static str>,
116        retention_days: Option<u32>,
117        upsert_update_columns: &'static [&'static str],
118    ) -> Self {
119        Self {
120            schema_name,
121            table_name,
122            columns,
123            primary_key,
124            allowed_fields,
125            allowed_includes,
126            allowed_sorts,
127            read_allow_policies,
128            read_deny_policies,
129            detail_allow_policies,
130            detail_deny_policies,
131            create_allow_policies,
132            create_deny_policies,
133            update_allow_policies,
134            update_deny_policies,
135            delete_allow_policies,
136            delete_deny_policies,
137            create_defaults,
138            emitted_events,
139            version_column,
140            audit_enabled,
141            pii_columns,
142            sensitive_columns,
143            soft_delete_column,
144            retention_days,
145            upsert_update_columns,
146            _marker: PhantomData,
147        }
148    }
149
150    pub fn emits(&self, operation: ModelEventKind) -> bool {
151        self.emitted_events.contains(&operation)
152    }
153
154    pub fn select_projection(&self) -> String {
155        let mut sql = String::new();
156        for (index, column) in self.columns.iter().enumerate() {
157            if index > 0 {
158                sql.push_str(", ");
159            }
160            let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
161        }
162        sql
163    }
164
165    /// Like [`Self::select_projection`] but emits only the named
166    /// columns, in the order they appear in the model descriptor.
167    /// Unknown column names are silently dropped — the caller is
168    /// expected to have validated the request via `FieldRef` already
169    /// (typed-builder path) or via schema validation
170    /// (string-name path). When no columns survive the filter, the
171    /// primary key is emitted as a fallback so the SQL still binds
172    /// at least one column to the projection.
173    pub fn select_projection_subset(&self, columns: &[&str]) -> String {
174        let mut sql = String::new();
175        let mut emitted = false;
176        for column in self.columns.iter() {
177            if columns.contains(&column.sql_name) && {
178                if emitted {
179                    sql.push_str(", ");
180                }
181                let _ = write!(sql, "{} AS \"{}\"", column.sql_name, column.rust_name);
182                emitted = true;
183                true
184            } {}
185        }
186        if !emitted {
187            // Fallback: always project the primary key so the
188            // emitted SQL is valid and downstream code can still
189            // identify rows. Callers asking for an empty projection
190            // are misusing the API — but we soft-handle it rather
191            // than producing `SELECT FROM table` which PG rejects.
192            if let Some(pk_column) = self
193                .columns
194                .iter()
195                .find(|column| column.sql_name == self.primary_key)
196            {
197                let _ = write!(sql, "{} AS \"{}\"", pk_column.sql_name, pk_column.rust_name,);
198            }
199        }
200        sql
201    }
202}
203
204// `ReadSource` / `WriteSource` impls for `ModelDescriptor` live in
205// `descriptor/model_impls.rs` — pulled out to keep this file under the
206// 200-LoC ceiling.