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