From 3a1abeaf4cce7e9bb5d9ecc526e8a042bacf4648 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 10:55:34 +0700 Subject: [PATCH] #215 feat: joined read models generated from join declarations --- crates/entity-derive-impl/src/entity.rs | 3 + .../src/entity/parse/entity.rs | 1 + .../src/entity/parse/entity/constructor.rs | 11 + .../src/entity/parse/entity/def.rs | 6 + .../src/entity/parse/entity/join.rs | 200 +++++++++++++++++ crates/entity-derive-impl/src/entity/view.rs | 211 ++++++++++++++++++ crates/entity-derive-impl/src/lib.rs | 2 +- wiki/Attributes-en.md | 33 +++ 8 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 crates/entity-derive-impl/src/entity/parse/entity/join.rs create mode 100644 crates/entity-derive-impl/src/entity/view.rs diff --git a/crates/entity-derive-impl/src/entity.rs b/crates/entity-derive-impl/src/entity.rs index bf29b09..cb759cf 100644 --- a/crates/entity-derive-impl/src/entity.rs +++ b/crates/entity-derive-impl/src/entity.rs @@ -86,6 +86,7 @@ mod sql; mod streams; #[cfg(feature = "transactions")] mod transaction; +mod view; use proc_macro::TokenStream; use quote::quote; @@ -114,6 +115,7 @@ fn generate(entity: EntityDef) -> TokenStream { let insertable = insertable::generate(&entity); let mappers = mappers::generate(&entity); let sql = sql::generate(&entity); + let view = view::generate(&entity); // Opt-out generators. Each entity-attribute group is gated behind a // Cargo feature so users can shrink their build by switching them @@ -173,6 +175,7 @@ fn generate(entity: EntityDef) -> TokenStream { #api #repository #row + #view #insertable #mappers #new_entity diff --git a/crates/entity-derive-impl/src/entity/parse/entity.rs b/crates/entity-derive-impl/src/entity/parse/entity.rs index 9aebfab..8185069 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity.rs @@ -143,6 +143,7 @@ mod constructor; mod def; mod helpers; mod index; +mod join; mod projection; mod upsert; diff --git a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs index bf91d52..dce5060 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/constructor.rs @@ -132,6 +132,7 @@ impl EntityDef { let command_defs = parse_command_attrs(&input.attrs).map_err(darling::Error::from)?; let api_config = parse_api_attr(&input.attrs); let indexes = parse_index_attrs(&input.attrs); + let joins = super::join::parse_join_attrs(&input.attrs).map_err(darling::Error::from)?; let field_names: Vec = fields .iter() .map(super::super::field::FieldDef::name_str) @@ -146,6 +147,15 @@ impl EntityDef { } } } + for join in &joins { + if !field_names.iter().any(|c| c == &join.local_column) { + return Err(darling::Error::custom(format!( + "join column `{}` does not match any entity column", + join.local_column + )) + .with_span(&input.ident)); + } + } let custom_constraints = parse_constraint_attrs(&input.attrs).map_err(darling::Error::from)?; if !custom_constraints.is_empty() && !attrs.typed_constraints { @@ -283,6 +293,7 @@ impl EntityDef { audit: attrs.migrations.audit, extensions: attrs.migrations.extensions, indexes, + joins, aggregate_root: attrs.aggregate_root, upsert: attrs.upsert, typed_constraints: attrs.typed_constraints, diff --git a/crates/entity-derive-impl/src/entity/parse/entity/def.rs b/crates/entity-derive-impl/src/entity/parse/entity/def.rs index f684f83..56032b2 100644 --- a/crates/entity-derive-impl/src/entity/parse/entity/def.rs +++ b/crates/entity-derive-impl/src/entity/parse/entity/def.rs @@ -65,6 +65,7 @@ use super::{ }, CompositeIndexDef, ProjectionDef, helpers::HasManyDef, + join::JoinDef, upsert::UpsertDef }; @@ -235,6 +236,11 @@ pub struct EntityDef { /// Each entry defines an index spanning multiple columns. pub indexes: Vec, + /// Joined read-model declarations from `#[join(...)]`. + /// + /// Non-empty when the entity generates a `{Entity}View`. + pub joins: Vec, + /// Enable aggregate root pattern. /// /// When `true`, generates `New{Name}` structs for create-only DTOs, diff --git a/crates/entity-derive-impl/src/entity/parse/entity/join.rs b/crates/entity-derive-impl/src/entity/parse/entity/join.rs new file mode 100644 index 0000000..5c8a788 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/parse/entity/join.rs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Joined read-model declarations from `#[join(...)]`. +//! +//! ```rust,ignore +//! #[join(airports as origin, on = origin_iata = iata, fields( +//! lat as origin_lat: f64, +//! lon as origin_lon: f64, +//! city as origin_city: String +//! ))] +//! ``` +//! +//! Each declaration contributes one `INNER JOIN` to the generated +//! `{Entity}View` read model: the joined table gets the declared alias, +//! the join condition matches a local entity column against a column of +//! the joined table, and every listed field is selected under its alias +//! with the declared Rust type (the macro cannot see the foreign +//! table's schema, so the type is part of the declaration). + +use syn::{Attribute, Ident, Type}; + +/// One selected column of a joined table. +#[derive(Debug, Clone)] +pub struct JoinFieldDef { + /// Column name on the joined table. + pub source: String, + + /// Field/alias name in the generated view struct. + pub alias: Ident, + + /// Rust type the column decodes to. + pub ty: Type +} + +/// One `#[join(...)]` declaration. +#[derive(Debug, Clone)] +pub struct JoinDef { + /// Joined table name. + pub table: String, + + /// SQL alias for the joined table. + pub alias: String, + + /// Entity column on the local side of the join condition. + pub local_column: String, + + /// Column of the joined table on the foreign side. + pub foreign_column: String, + + /// Selected columns. + pub fields: Vec +} + +/// Parse all `#[join(...)]` attributes. +/// +/// # Errors +/// +/// Returns a `syn::Error` for malformed declarations: missing `as` +/// alias, missing/invalid `on`, empty or malformed `fields(...)`. +pub fn parse_join_attrs(attrs: &[Attribute]) -> syn::Result> { + let mut joins = Vec::new(); + + for attr in attrs { + if !attr.path().is_ident("join") { + continue; + } + joins.push(attr.parse_args_with(parse_join_body)?); + } + + Ok(joins) +} + +/// Parse the body of one `#[join(...)]` attribute. +fn parse_join_body(input: syn::parse::ParseStream<'_>) -> syn::Result { + let table: Ident = input.parse()?; + let _: syn::Token![as] = input.parse()?; + let alias: Ident = input.parse()?; + + let _: syn::Token![,] = input.parse()?; + let on_kw: Ident = input.parse()?; + if on_kw != "on" { + return Err(syn::Error::new( + on_kw.span(), + "expected `on = local_column = foreign_column`" + )); + } + let _: syn::Token![=] = input.parse()?; + let local: Ident = input.parse()?; + let _: syn::Token![=] = input.parse()?; + let foreign: Ident = input.parse()?; + + let _: syn::Token![,] = input.parse()?; + let fields_kw: Ident = input.parse()?; + if fields_kw != "fields" { + return Err(syn::Error::new( + fields_kw.span(), + "expected `fields(source as alias: Type, ...)`" + )); + } + let content; + syn::parenthesized!(content in input); + + let mut fields = Vec::new(); + while !content.is_empty() { + let source: Ident = content.parse()?; + let _: syn::Token![as] = content.parse()?; + let field_alias: Ident = content.parse()?; + let _: syn::Token![:] = content.parse()?; + let ty: Type = content.parse()?; + fields.push(JoinFieldDef { + source: source.to_string(), + alias: field_alias, + ty + }); + if content.peek(syn::Token![,]) { + let _: syn::Token![,] = content.parse()?; + } + } + if fields.is_empty() { + return Err(syn::Error::new( + fields_kw.span(), + "join requires at least one field in fields(...)" + )); + } + + Ok(JoinDef { + table: table.to_string(), + alias: alias.to_string(), + local_column: local.to_string(), + foreign_column: foreign.to_string(), + fields + }) +} + +#[cfg(test)] +mod tests { + use quote::quote; + + use super::*; + + fn parse(tokens: proc_macro2::TokenStream) -> syn::Result> { + let input: syn::DeriveInput = syn::parse_quote! { + #tokens + pub struct Ticket { + pub id: uuid::Uuid, + } + }; + parse_join_attrs(&input.attrs) + } + + #[test] + fn parses_full_declaration() { + let joins = parse(quote! { + #[join(airports as origin, on = origin_iata = iata, fields( + lat as origin_lat: f64, + city as origin_city: String + ))] + }) + .expect("valid join must parse"); + assert_eq!(joins.len(), 1); + let j = &joins[0]; + assert_eq!(j.table, "airports"); + assert_eq!(j.alias, "origin"); + assert_eq!(j.local_column, "origin_iata"); + assert_eq!(j.foreign_column, "iata"); + assert_eq!(j.fields.len(), 2); + assert_eq!(j.fields[0].source, "lat"); + assert_eq!(j.fields[0].alias.to_string(), "origin_lat"); + } + + #[test] + fn parses_multiple_joins() { + let joins = parse(quote! { + #[join(airports as origin, on = origin_iata = iata, fields(lat as origin_lat: f64))] + #[join(airports as dest, on = destination_iata = iata, fields(lat as destination_lat: f64))] + }) + .expect("valid joins must parse"); + assert_eq!(joins.len(), 2); + assert_eq!(joins[1].alias, "dest"); + } + + #[test] + fn rejects_empty_fields() { + let err = parse(quote! { + #[join(airports as origin, on = origin_iata = iata, fields())] + }) + .expect_err("empty fields must fail"); + assert!(err.to_string().contains("at least one field")); + } + + #[test] + fn rejects_missing_on() { + let err = parse(quote! { + #[join(airports as origin, at = origin_iata = iata, fields(lat as l: f64))] + }) + .expect_err("missing on must fail"); + assert!(err.to_string().contains("expected `on")); + } +} diff --git a/crates/entity-derive-impl/src/entity/view.rs b/crates/entity-derive-impl/src/entity/view.rs new file mode 100644 index 0000000..26349d4 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/view.rs @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Joined read-model generation from `#[join(...)]` declarations. +//! +//! For an entity with at least one `#[join(...)]`, generates: +//! +//! - `{Entity}View` — a flat struct with every entity column plus the declared +//! joined columns, deriving `sqlx::FromRow` and `serde::Serialize` +//! - `{Entity}View::SELECT` — the canonical `SELECT ... FROM ... JOIN ...` +//! fragment (no WHERE), for custom filters via `format!("{} WHERE ...", +//! TicketView::SELECT)` +//! - `{Entity}View::find_by_id(pool, id)` — single row by primary key +//! - `{Entity}View::list(pool, limit, offset)` — id-descending page +//! +//! Joins are `INNER JOIN`s; the base table is aliased by its own name +//! and every column is qualified, so join aliases can never collide +//! with it. The joined columns' Rust types are part of the `#[join]` +//! declaration — the macro cannot see the foreign table's schema. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use super::parse::{EntityDef, SqlLevel}; +use crate::utils::marker; + +/// Generate the read-model struct and its inherent impl. +/// +/// Returns empty tokens when the entity has no `#[join(...)]` +/// declarations or `sql = "none"`. +pub fn generate(entity: &EntityDef) -> TokenStream { + if entity.joins.is_empty() || entity.sql == SqlLevel::None { + return TokenStream::new(); + } + + let vis = &entity.vis; + let entity_name = entity.name(); + let view_name = format_ident!("{}View", entity_name); + let marker = marker::generated(); + let id_type = entity.id_field().ty(); + let id_name = entity.id_field().name_str(); + let table = entity.table_name(); + + let entity_fields: Vec = entity + .column_fields() + .into_iter() + .map(|f| { + let name = f.name(); + let ty = f.ty(); + quote! { pub #name: #ty } + }) + .collect(); + let join_fields: Vec = entity + .joins + .iter() + .flat_map(|j| &j.fields) + .map(|f| { + let name = &f.alias; + let ty = &f.ty; + quote! { pub #name: #ty } + }) + .collect(); + + let select_sql = build_select(entity); + let find_sql = format!("{select_sql} WHERE {table}.{id_name} = $1"); + let list_sql = format!("{select_sql} ORDER BY {table}.{id_name} DESC LIMIT $1 OFFSET $2"); + + let doc = format!( + "Joined read model for [`{entity_name}`], generated from its `#[join(...)]` declarations." + ); + let select_doc = format!( + "Canonical `SELECT ... FROM ... JOIN ...` fragment (no WHERE clause).\n\n\ + Compose custom filters with\n\ + `sqlx::query_as::<_, {view_name}>(&format!(\"{{}} WHERE ...\", Self::SELECT))`." + ); + + quote! { + #marker + #[doc = #doc] + #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] + #vis struct #view_name { + #(#entity_fields,)* + #(#join_fields,)* + } + + impl #view_name { + #[doc = #select_doc] + pub const SELECT: &'static str = #select_sql; + + /// Fetch a single row of the read model by primary key. + pub async fn find_by_id( + pool: &sqlx::PgPool, + id: #id_type + ) -> Result, sqlx::Error> { + sqlx::query_as(#find_sql).bind(&id).fetch_optional(pool).await + } + + /// List the read model, newest ids first. + pub async fn list( + pool: &sqlx::PgPool, + limit: i64, + offset: i64 + ) -> Result, sqlx::Error> { + sqlx::query_as(#list_sql) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await + } + } + } +} + +/// Build the canonical SELECT fragment for the view. +fn build_select(entity: &EntityDef) -> String { + let table = entity.table_name(); + + let mut columns: Vec = entity + .column_fields() + .into_iter() + .map(|f| format!("{table}.{}", f.name_str())) + .collect(); + for join in &entity.joins { + for field in &join.fields { + columns.push(format!( + "{}.{} AS {}", + join.alias, field.source, field.alias + )); + } + } + + let mut sql = format!("SELECT {} FROM {table}", columns.join(", ")); + for join in &entity.joins { + sql.push_str(&format!( + " JOIN {} AS {} ON {}.{} = {table}.{}", + join.table, join.alias, join.alias, join.foreign_column, join.local_column + )); + } + sql +} + +#[cfg(test)] +mod tests { + use quote::quote; + use syn::DeriveInput; + + use super::*; + + fn parse_entity(tokens: proc_macro2::TokenStream) -> EntityDef { + let input: DeriveInput = syn::parse2(tokens).expect("test entity must parse"); + EntityDef::from_derive_input(&input).expect("test entity must be valid") + } + + fn ticket_entity() -> EntityDef { + parse_entity(quote! { + #[entity(table = "tickets")] + #[join(airports as origin, on = origin_iata = iata, fields( + lat as origin_lat: f64, + city as origin_city: String + ))] + #[join(airports as dest, on = destination_iata = iata, fields( + lat as destination_lat: f64 + ))] + pub struct Ticket { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub origin_iata: String, + #[field(create, response)] + pub destination_iata: String, + } + }) + } + + #[test] + fn generates_view_struct_and_methods() { + let code = generate(&ticket_entity()).to_string(); + assert!(code.contains("pub struct TicketView")); + assert!(code.contains("pub origin_lat : f64")); + assert!(code.contains("pub const SELECT")); + assert!(code.contains("pub async fn find_by_id")); + assert!(code.contains("pub async fn list")); + } + + #[test] + fn select_qualifies_and_aliases_columns() { + let sql = build_select(&ticket_entity()); + assert_eq!( + sql, + "SELECT tickets.id, tickets.origin_iata, tickets.destination_iata, \ + origin.lat AS origin_lat, origin.city AS origin_city, dest.lat AS destination_lat \ + FROM tickets \ + JOIN airports AS origin ON origin.iata = tickets.origin_iata \ + JOIN airports AS dest ON dest.iata = tickets.destination_iata" + ); + } + + #[test] + fn no_joins_generates_nothing() { + let entity = parse_entity(quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, response)] + pub name: String, + } + }); + assert!(generate(&entity).is_empty()); + } +} diff --git a/crates/entity-derive-impl/src/lib.rs b/crates/entity-derive-impl/src/lib.rs index 41c42fc..6fee393 100644 --- a/crates/entity-derive-impl/src/lib.rs +++ b/crates/entity-derive-impl/src/lib.rs @@ -470,7 +470,7 @@ use proc_macro::TokenStream; Entity, attributes( entity, field, id, auto, owner, sort, version, embed, validate, belongs_to, has_many, - projection, filter, command, example, column, map + projection, filter, command, example, column, map, join ) )] pub fn derive_entity(input: TokenStream) -> TokenStream { diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 5b2c3ce..91398f2 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -573,6 +573,39 @@ pub user_id: Uuid, **Generated:** `find_user()` method in repository. +### `#[join(...)]` — joined read models + +Declares an `INNER JOIN` contributing columns to a generated `{Entity}View` read model. Repeatable; the joined columns' Rust types are part of the declaration (the macro cannot see the foreign table's schema). + +```rust +#[derive(Entity)] +#[entity(table = "tickets")] +#[join(airports as origin, on = origin_iata = iata, fields( + lat as origin_lat: f64, + lon as origin_lon: f64, + city as origin_city: String +))] +#[join(airports as dest, on = destination_iata = iata, fields( + lat as destination_lat: f64, + lon as destination_lon: f64, + city as destination_city: String +))] +pub struct Ticket { /* ... */ } +``` + +**Generated:** +- `TicketView` — flat struct with every entity column plus the joined columns; derives `sqlx::FromRow` and `serde::Serialize` +- `TicketView::SELECT` — the canonical `SELECT ... FROM ... JOIN ...` fragment (no WHERE) for custom filters: + ```rust + let rows: Vec = sqlx::query_as(::sqlx::AssertSqlSafe(format!( + "{} WHERE tickets.verified = true ORDER BY tickets.departs_at ASC", + TicketView::SELECT + ))).fetch_all(&pool).await?; + ``` +- `TicketView::find_by_id(pool, id)` and `TicketView::list(pool, limit, offset)` + +Base-table columns are qualified with the table name; a `join` column that does not match an entity column fails the build. + ### `#[has_many(Entity)]` One-to-many relation (entity-level). See [[Relations]] for details.