Skip to main content

queryweave_codegen/
conventions.rs

1//! How the derives name what they generate, and which names they require of
2//! the structs they are placed on or link to.
3//!
4//! Every naming rule goes through a [`Conventions`] implementation, chosen
5//! by the derive's entry point (`DefaultConventions` for this project's
6//! derives), so the rules can be swapped without touching a derive's steps.
7//!
8//! A derive never rebuilds another type's generated *type* names from these
9//! rules: it links to the path the user wrote (see `queryweave::Filterable`),
10//! so each layer may name its types under different conventions. Generated
11//! *member* names (sort variants, filter fields) are different: a mirror (an
12//! API DTO) names the mirrored model's members by applying its own conventions to
13//! the model's field names, so the layers must agree on
14//! [`Conventions::sort_variant`] and [`Conventions::filter_field`].
15//!
16//! The conventions also say where the runtime crates the generated code
17//! names live ([`Conventions::core_crate`], [`Conventions::api_crate`],
18//! [`Conventions::sql_crate`]), so a crate that renames or re-exports them
19//! can still use the derives; `#[queryweave(crate = path)]` overrides them on
20//! a single struct. Each derive names exactly one runtime crate: the one of its
21//! layer (`queryweave-api` and `queryweave-sql` re-export the core).
22//!
23//! Last, they list the integrations a `Filters` mirror may opt into
24//! ([`Conventions::integrations`], see [`crate::integrations`]).
25
26use quote::{ToTokens, format_ident};
27
28/// The names of the types `#[derive(Filters)]` generates for a struct (a
29/// domain model or a DTO).
30#[derive(Debug, PartialEq, Eq, Clone)]
31pub struct FiltersNames {
32    /// The query model: paging, sorting and the filter options.
33    pub query: syn::Ident,
34    /// The filter options model.
35    pub filters: syn::Ident,
36    /// The sortable-fields enum.
37    pub sort_field: syn::Ident,
38    /// The sort details: a sort field and its direction.
39    pub sort_details: syn::Ident,
40    /// The struct the derive is placed on.
41    pub input: syn::Ident,
42    /// The page of the struct.
43    pub page: syn::Ident,
44    /// The query parameters (API `Filters` only).
45    pub query_params: syn::Ident,
46}
47
48/// The naming rules of the derives.
49///
50/// Its functions report a name that does not fit through
51/// [`crate::errors`], at what the user wrote: the struct's name, or the path
52/// inside the attribute naming another struct.
53pub trait Conventions {
54    /// The names of the types `#[derive(Filters)]` generates for `input`.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error, at `input`, when the struct's name does not fit.
59    fn filters_names(input: &syn::Ident) -> syn::Result<FiltersNames>;
60
61    /// Check the name of a model deriving `MergePatch`.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error, at `name`, when it does not fit.
66    fn check_model_name(name: &syn::Ident) -> syn::Result<()>;
67
68    /// Check the name of a merge patch struct: the struct a derive is placed
69    /// on (`MergePatchNullableFields`, `ValidateNonNullable`), or one an
70    /// attribute links to (`#[merge_patch(patch = path)]`, or
71    /// `#[validate_non_nullable(mirrors = path)]`), calling it
72    /// `what`.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error, at `span`, when the name does not fit.
77    fn check_merge_patch_name<S: ToTokens + ?Sized>(
78        name: &syn::Ident,
79        span: &S,
80        what: &str,
81    ) -> syn::Result<()>;
82
83    /// The variant of the generated sortable-fields enum for the `#[sortable]`
84    /// field `field`, spanned to `span`.
85    ///
86    /// Shared across layers: a mirror names the mirrored model's variants with
87    /// it.
88    fn sort_variant(field: &syn::Ident, span: proc_macro2::Span) -> syn::Ident;
89
90    /// The generated filter field (and, on a DTO, query parameter) filtering
91    /// `field` with the operator written `operator` in `#[filter(...)]` (`lt`,
92    /// `eq`, `gt`, `sw`, `c`, `ew` or `bool`), spanned to `span`.
93    ///
94    /// Shared across layers: a mirror names the mirrored model's filter fields
95    /// with it.
96    fn filter_field(field: &syn::Ident, operator: &str, span: proc_macro2::Span) -> syn::Ident;
97
98    /// Check the name of a wrapper an attribute links to
99    /// (`#[append_to_query(filters = path)]` of `AppendToQuery`), calling it
100    /// `what`.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error, at `span`, when the name does not fit.
105    fn check_wrapper_name<S: ToTokens + ?Sized>(
106        name: &syn::Ident,
107        span: &S,
108        what: &str,
109    ) -> syn::Result<()>;
110
111    /// The path of the core runtime crate, named by the code generated for a
112    /// root model (`Filters`, `MergePatch`, `MergePatchNullableFields`,
113    /// `RawValue`).
114    ///
115    /// Defaults to `::queryweave`: absolute, so a local item of the same name
116    /// cannot shadow it. Override it when the crate is renamed or re-exported.
117    #[must_use]
118    fn core_crate() -> syn::Path {
119        syn::parse_quote!(::queryweave)
120    }
121
122    /// The path of the API runtime crate, named by the code generated for an
123    /// API type (a `Filters` mirror with `serde` or `utoipa`,
124    /// `ValidateNonNullable`). Defaults to `::queryweave_api`.
125    #[must_use]
126    fn api_crate() -> syn::Path {
127        syn::parse_quote!(::queryweave_api)
128    }
129
130    /// The path of the SQL runtime crate, named by the code generated for the
131    /// persistence layer (`Columns`, `AppendToQuery`,
132    /// `AppendConditions`). Defaults to `::queryweave_sql`.
133    #[must_use]
134    fn sql_crate() -> syn::Path {
135        syn::parse_quote!(::queryweave_sql)
136    }
137
138    /// The integrations a `Filters` mirror may opt into, each with the flag
139    /// of `#[filters(...)]` named after it, in the order their additions are
140    /// generated. Their names must differ from each other and from `mirrors`.
141    ///
142    /// Defaults to the built-in ones ([`crate::integrations::BUILT_IN`]:
143    /// `serde`, `utoipa`, `validator`). Override it to add a crate's own (e.g.
144    /// a GraphQL integration) or to replace some (e.g. serde by another
145    /// serializer).
146    #[must_use]
147    fn integrations() -> Vec<&'static dyn crate::integrations::Integration> {
148        crate::integrations::BUILT_IN.to_vec()
149    }
150}
151
152/// This project's conventions.
153///
154/// `Filters` names its types after the struct's pascal-case segments, the
155/// last one being the layer's suffix (`OrderModel` → `OrdersQueryModel`,
156/// `OrderSortFieldModel`, ...; `OrderDto` → `OrdersQueryParams`, ...), so the
157/// struct's name needs at least two segments, as does a model deriving
158/// `MergePatch`. A merge patch struct is named `MergePatch…`
159/// (`MergePatchOrderModel`), and a wrapper is pascal cased.
160///
161/// The runtime crates keep their default, absolute paths (`::queryweave`,
162/// ...).
163///
164/// A sort variant is its field pascal cased (`delivery_date` →
165/// `DeliveryDate`), and a filter field is its field followed by the operator
166/// (`username_sw`), except `bool`, which keeps the bare field (`is_active`).
167pub struct DefaultConventions;
168
169impl Conventions for DefaultConventions {
170    fn filters_names(input: &syn::Ident) -> syn::Result<FiltersNames> {
171        let parts = crate::names::pascal_case_segments(input, input, "struct", 2)?;
172        // At least two segments, so there is a last one.
173        let (last, init) = parts
174            .split_last()
175            .map_or(("", &[][..]), |(last, init)| (last.as_str(), init));
176        let without_last = init.join("");
177
178        Ok(FiltersNames {
179            query: format_ident!("{without_last}sQuery{last}"),
180            filters: format_ident!("{without_last}sFilterOptions{last}"),
181            sort_field: format_ident!("{without_last}SortField{last}"),
182            sort_details: format_ident!("{without_last}SortDetails{last}"),
183            input: input.clone(),
184            page: format_ident!("{without_last}sPage{last}"),
185            query_params: format_ident!("{without_last}sQueryParams"),
186        })
187    }
188
189    fn check_model_name(name: &syn::Ident) -> syn::Result<()> {
190        crate::names::pascal_case_segments(name, name, "struct", 2).map(drop)
191    }
192
193    fn check_merge_patch_name<S: ToTokens + ?Sized>(
194        name: &syn::Ident,
195        span: &S,
196        what: &str,
197    ) -> syn::Result<()> {
198        crate::names::merge_patch_segments(name, span, what).map(drop)
199    }
200
201    fn sort_variant(field: &syn::Ident, span: proc_macro2::Span) -> syn::Ident {
202        let pascal_cased = field
203            .to_string()
204            .split('_')
205            .map(crate::utils::uppercase_first_letter)
206            .collect::<String>();
207
208        format_ident!("{pascal_cased}", span = span)
209    }
210
211    fn filter_field(field: &syn::Ident, operator: &str, span: proc_macro2::Span) -> syn::Ident {
212        if operator == "bool" {
213            format_ident!("{field}", span = span)
214        } else {
215            format_ident!("{field}_{operator}", span = span)
216        }
217    }
218
219    fn check_wrapper_name<S: ToTokens + ?Sized>(
220        name: &syn::Ident,
221        span: &S,
222        what: &str,
223    ) -> syn::Result<()> {
224        crate::names::pascal_case_segments(name, span, what, 1).map(drop)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::{Conventions, DefaultConventions, FiltersNames};
231    use pretty_assertions::assert_eq;
232    use quote::format_ident;
233
234    #[test]
235    fn the_default_filters_names_follow_the_struct_segments() {
236        assert_eq!(
237            DefaultConventions::filters_names(&format_ident!("OrderLineModel")).unwrap(),
238            FiltersNames {
239                query: format_ident!("OrderLinesQueryModel"),
240                filters: format_ident!("OrderLinesFilterOptionsModel"),
241                sort_field: format_ident!("OrderLineSortFieldModel"),
242                sort_details: format_ident!("OrderLineSortDetailsModel"),
243                input: format_ident!("OrderLineModel"),
244                page: format_ident!("OrderLinesPageModel"),
245                query_params: format_ident!("OrderLinesQueryParams"),
246            }
247        );
248    }
249
250    #[test]
251    fn the_default_filters_names_need_two_segments() {
252        assert!(DefaultConventions::filters_names(&format_ident!("Order")).is_err());
253        assert!(DefaultConventions::filters_names(&format_ident!("order_model")).is_err());
254    }
255
256    #[test]
257    fn the_default_model_name_needs_two_segments() {
258        assert!(DefaultConventions::check_model_name(&format_ident!("OrderModel")).is_ok());
259        assert!(DefaultConventions::check_model_name(&format_ident!("Order")).is_err());
260    }
261
262    #[test]
263    fn the_default_merge_patch_name_starts_with_merge_patch() {
264        let name = format_ident!("MergePatchOrderModel");
265        assert!(DefaultConventions::check_merge_patch_name(&name, &name, "struct").is_ok());
266
267        let name = format_ident!("OrderPatch");
268        assert!(DefaultConventions::check_merge_patch_name(&name, &name, "struct").is_err());
269    }
270
271    #[test]
272    fn the_default_sort_variant_is_the_field_pascal_cased() {
273        let span = proc_macro2::Span::call_site();
274
275        assert_eq!(
276            DefaultConventions::sort_variant(&format_ident!("delivery_date"), span),
277            format_ident!("DeliveryDate")
278        );
279        assert_eq!(
280            DefaultConventions::sort_variant(&format_ident!("value"), span),
281            format_ident!("Value")
282        );
283    }
284
285    #[test]
286    fn the_default_filter_field_is_suffixed_with_the_operator_except_bool() {
287        let span = proc_macro2::Span::call_site();
288        let field = format_ident!("username");
289
290        assert_eq!(
291            DefaultConventions::filter_field(&field, "sw", span),
292            format_ident!("username_sw")
293        );
294        assert_eq!(
295            DefaultConventions::filter_field(&field, "bool", span),
296            format_ident!("username")
297        );
298    }
299
300    #[test]
301    fn the_default_runtime_crates_are_named_by_absolute_path() {
302        let path = |path: syn::Path| quote::ToTokens::to_token_stream(&path).to_string();
303
304        assert_eq!(path(DefaultConventions::core_crate()), ":: queryweave");
305        assert_eq!(path(DefaultConventions::api_crate()), ":: queryweave_api");
306        assert_eq!(path(DefaultConventions::sql_crate()), ":: queryweave_sql");
307    }
308
309    #[test]
310    fn the_default_wrapper_name_is_pascal_cased() {
311        let name = format_ident!("OrdersQueryModelWrapper");
312        assert!(DefaultConventions::check_wrapper_name(&name, &name, "wrapper").is_ok());
313
314        let name = format_ident!("orders_wrapper");
315        assert!(DefaultConventions::check_wrapper_name(&name, &name, "wrapper").is_err());
316    }
317}