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