Skip to main content

queryweave_sql/
columns.rs

1//! Mapping domain field names onto the columns they are stored in.
2//!
3//! The domain derives describe filters, sorting and writes in terms of domain
4//! field names, and publish which fields reach the database
5//! (`queryweave::PersistedFields`). An entity deriving `Columns`
6//! (`queryweave-derive`) says which column each domain field lives in.
7//! Every SQL helper of this crate checks, when it is instantiated for an
8//! entity and a model, that each of the model's fields has a column, and
9//! [`assert_columns!`](crate::assert_columns) does the same as a standalone
10//! item, so a missing column is a build error rather than a runtime SQL error.
11
12use crate::BindValues;
13#[cfg(feature = "merge-patch")]
14use queryweave::FieldValues;
15use queryweave::{Conditions, PersistedFields, SortKeys};
16use sqlx::QueryBuilder;
17#[cfg(feature = "merge-patch")]
18use sqlx::query_builder::Separated;
19
20/// A table (usually a `sqlx` entity) whose columns store a domain model's
21/// fields, and the SQL written for that model's data against those columns.
22///
23/// The SQL methods are generic over the database (`DB: BindValues`: Postgres,
24/// MySQL/MariaDB, SQLite) and over the model `M`, both taken from the
25/// arguments: `Order::push_assignments(&mut query_builder,
26/// order.changed_values(&diff))`. Each fails to build unless every field of
27/// `M` has a column on the table.
28pub trait Columns {
29    /// The table's type name, for compile errors about it.
30    const NAME: &'static str;
31
32    /// `(domain field, column)` for every column of the table.
33    const COLUMNS: &'static [(&'static str, &'static str)];
34
35    /// The column a domain field is stored in, if the table has one.
36    #[must_use]
37    fn column(field: &str) -> Option<&'static str> {
38        column_for(Self::COLUMNS, field)
39    }
40
41    /// Push the conditions of the model `M` to a query, joined by `separator`
42    /// (e.g. `AND`), each a `column operator value` comparison against the
43    /// column the table stores its domain field in.
44    ///
45    /// The conditions are a generated filter-options model's `conditions()`.
46    /// Values are bound with their own SQL type; the `LIKE` operators bind an
47    /// escaped pattern (`… LIKE ? ESCAPE '!'`), so user input matches
48    /// literally.
49    ///
50    /// Fails to build unless every field of `M` has a column on the table.
51    fn push_conditions<DB: BindValues, M: PersistedFields>(
52        query_builder: &mut QueryBuilder<'_, DB>,
53        conditions: Conditions<M>,
54        separator: &str,
55    ) {
56        const { assert_model_columns::<Self, M>() };
57
58        crate::sql::push_conditions::<Self, DB, M>(query_builder, conditions, separator);
59    }
60
61    /// Push an `ORDER BY column direction, …` clause for the sort keys of the
62    /// model `M`; nothing when there are none.
63    ///
64    /// The sort keys are a generated query model's `sort_keys()`, written in
65    /// order, each by the column the table stores its domain field in.
66    ///
67    /// Fails to build unless every field of `M` has a column on the table.
68    fn push_order_by<DB: BindValues, M: PersistedFields>(
69        query_builder: &mut QueryBuilder<'_, DB>,
70        sort_keys: SortKeys<M>,
71    ) {
72        const { assert_model_columns::<Self, M>() };
73
74        crate::sql::push_order_by::<Self, DB, M>(query_builder, sort_keys);
75    }
76
77    #[cfg(feature = "merge-patch")]
78    /// Push `column = value` assignments of the model `M` for an
79    /// `UPDATE … SET`, comma separated, returning the separated builder so
80    /// more can be added.
81    ///
82    /// The assignments are a `MergePatch` model's `changed_values`, each to
83    /// the column the table stores its domain field in.
84    ///
85    /// Fails to build unless every field of `M` has a column on the table.
86    fn push_assignments<'qb, 'args, DB: BindValues, M: PersistedFields>(
87        query_builder: &'qb mut QueryBuilder<'args, DB>,
88        assignments: FieldValues<M>,
89    ) -> Separated<'qb, 'args, DB, &'static str> {
90        const { assert_model_columns::<Self, M>() };
91
92        crate::sql::push_assignments::<Self, DB, M>(query_builder, assignments)
93    }
94}
95
96/// Byte-wise string equality, usable in `const` code.
97const fn str_eq(left: &str, right: &str) -> bool {
98    let (left, right) = (left.as_bytes(), right.as_bytes());
99    if left.len() != right.len() {
100        return false;
101    }
102
103    let mut index = 0;
104    while index < left.len() {
105        if left[index] != right[index] {
106            return false;
107        }
108        index += 1;
109    }
110
111    true
112}
113
114/// The column `field` is stored in, per a `(domain field, column)` table.
115#[must_use]
116pub const fn column_for(
117    columns: &'static [(&'static str, &'static str)],
118    field: &str,
119) -> Option<&'static str> {
120    let mut index = 0;
121    while index < columns.len() {
122        let (domain_field, column) = columns[index];
123        if str_eq(domain_field, field) {
124            return Some(column);
125        }
126        index += 1;
127    }
128
129    None
130}
131
132/// The first of `fields` without a column in the `(domain field, column)`
133/// table, if any. Usable in `const` code, which is how
134/// [`assert_columns!`](crate::assert_columns) checks coverage at compile time.
135#[must_use]
136pub const fn first_field_without_column(
137    columns: &'static [(&'static str, &'static str)],
138    fields: &'static [&'static str],
139) -> Option<&'static str> {
140    let mut index = 0;
141    while index < fields.len() {
142        if column_for(columns, fields[index]).is_none() {
143            return Some(fields[index]);
144        }
145        index += 1;
146    }
147
148    None
149}
150
151/// The largest message [`missing_column_message`] builds; longer ones are cut.
152const MESSAGE_CAPACITY: usize = 512;
153
154/// A message built at compile time (see [`missing_column_message`]).
155pub struct Message {
156    bytes: [u8; MESSAGE_CAPACITY],
157    len: usize,
158}
159
160impl Message {
161    /// Append `text`, cutting it at the capacity.
162    const fn push(mut self, text: &str) -> Self {
163        let text = text.as_bytes();
164        let mut index = 0;
165        while index < text.len() && self.len < MESSAGE_CAPACITY {
166            self.bytes[self.len] = text[index];
167            self.len += 1;
168            index += 1;
169        }
170
171        self
172    }
173
174    /// The message text.
175    #[must_use]
176    pub const fn as_str(&self) -> &str {
177        match core::str::from_utf8(self.bytes.split_at(self.len).0) {
178            Ok(text) => text,
179            // Only a cut through a multi-byte character gets here.
180            Err(_) => "a domain field has no column",
181        }
182    }
183}
184
185/// The compile error [`assert_columns!`](crate::assert_columns) reports for a
186/// domain field without a column on `entity`. Built in `const` code, since a
187/// `const` panic cannot format its message.
188#[must_use]
189pub const fn missing_column_message(entity: &str, field: &str) -> Message {
190    Message {
191        bytes: [0; MESSAGE_CAPACITY],
192        len: 0,
193    }
194    .push("domain field `")
195    .push(field)
196    .push("` has no column on `")
197    .push(entity)
198    .push("`: add a field of that name to the entity, or map one onto it with `#[domain_field(")
199    .push(field)
200    .push(")]`")
201}
202
203/// Assert at compile time that every one of `fields` has a column on `entity`.
204///
205/// `fields` is a `&'static [&'static str]`, e.g. a model's
206/// `<Model as queryweave::PersistedFields>::FIELD_NAMES`; `entity` a type
207/// implementing [`Columns`].
208///
209/// Expands to an anonymous `const` item, so a missing column fails the build,
210/// `cargo check` included, with a message naming the field and the entity. The
211/// SQL helpers of this crate run the same check themselves when instantiated
212/// for an entity and a model; this standalone form additionally reports it in
213/// `cargo check` and the editor.
214///
215/// # Examples
216///
217/// ```rust
218/// struct Order;
219///
220/// impl queryweave_sql::Columns for Order {
221///     const NAME: &'static str = "Order";
222///     const COLUMNS: &'static [(&'static str, &'static str)] =
223///         &[("number", "number"), ("delivery_date", "delivered_at")];
224/// }
225///
226/// queryweave_sql::assert_columns!(Order, &["number", "delivery_date"]);
227/// ```
228#[macro_export]
229macro_rules! assert_columns {
230    ($entity:ty, $fields:expr $(,)?) => {
231        const _: () = {
232            if let ::core::option::Option::Some(field) =
233                $crate::first_field_without_column(<$entity as $crate::Columns>::COLUMNS, $fields)
234            {
235                ::core::panic!(
236                    "{}",
237                    $crate::missing_column_message(<$entity as $crate::Columns>::NAME, field)
238                        .as_str()
239                );
240            }
241        };
242    };
243}
244
245/// Fail const evaluation unless every field of the model `M` has a column on
246/// the entity `C`.
247///
248/// The SQL helpers call it in an inline `const` block, evaluated once per
249/// instantiation, so writing SQL for a model through an entity lacking a
250/// column for one of its fields fails to build. Being evaluated on
251/// instantiation, it is reported by `cargo build` / `cargo test`, not by
252/// `cargo check`.
253///
254/// # Panics
255///
256/// When a field of `M` has no column on `C`; called in a `const` block, that
257/// panic is a compile error naming the field and the entity.
258#[allow(
259    clippy::panic,
260    reason = "a const-evaluation panic is how a compile-time check fails the build"
261)]
262// fails the build, not a test; covered by the trybuild UI cases (excluded from mutation runs)
263#[mutants::skip]
264pub const fn assert_model_columns<C: Columns + ?Sized, M: PersistedFields>() {
265    if let Some(field) = first_field_without_column(C::COLUMNS, M::FIELD_NAMES) {
266        panic!("{}", missing_column_message(C::NAME, field).as_str());
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{Columns, column_for, first_field_without_column, missing_column_message, str_eq};
273    use pretty_assertions::assert_eq;
274
275    struct Order;
276
277    impl Columns for Order {
278        const NAME: &'static str = "Order";
279        const COLUMNS: &'static [(&'static str, &'static str)] =
280            &[("number", "number"), ("delivery_date", "delivered_at")];
281    }
282
283    #[test]
284    fn str_eq_compares_whole_strings() {
285        assert!(str_eq("number", "number"));
286        assert!(!str_eq("number", "numbers"));
287        assert!(!str_eq("number", "Number"));
288        assert!(str_eq("", ""));
289    }
290
291    #[test]
292    fn a_field_maps_onto_its_column() {
293        assert_eq!(Order::column("number"), Some("number"));
294        assert_eq!(Order::column("delivery_date"), Some("delivered_at"));
295        assert_eq!(Order::column("client"), None);
296        assert_eq!(column_for(Order::COLUMNS, "delivered_at"), None);
297    }
298
299    #[test]
300    fn the_first_field_without_a_column_is_reported() {
301        assert_eq!(
302            first_field_without_column(Order::COLUMNS, &["number", "delivery_date"]),
303            None
304        );
305        assert_eq!(
306            first_field_without_column(Order::COLUMNS, &["number", "client", "value"]),
307            Some("client")
308        );
309    }
310
311    // The check runs in `const` code; this compiling is the passing case.
312    crate::assert_columns!(Order, &["delivery_date", "number"]);
313
314    #[test]
315    fn the_message_names_the_field_and_the_entity() {
316        assert_eq!(
317            missing_column_message("Order", "client").as_str(),
318            "domain field `client` has no column on `Order`: add a field of that name to the entity, or map one onto it with `#[domain_field(client)]`"
319        );
320    }
321
322    #[test]
323    fn an_overlong_message_is_cut_at_the_capacity() {
324        let field = "f".repeat(600);
325
326        assert_eq!(
327            missing_column_message("Order", &field).as_str().len(),
328            super::MESSAGE_CAPACITY
329        );
330    }
331}