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