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