Skip to content
Merged

215 #221

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/entity-derive-impl/src/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ mod sql;
mod streams;
#[cfg(feature = "transactions")]
mod transaction;
mod view;

use proc_macro::TokenStream;
use quote::quote;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -173,6 +175,7 @@ fn generate(entity: EntityDef) -> TokenStream {
#api
#repository
#row
#view
#insertable
#mappers
#new_entity
Expand Down
1 change: 1 addition & 0 deletions crates/entity-derive-impl/src/entity/parse/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ mod constructor;
mod def;
mod helpers;
mod index;
mod join;
mod projection;
mod upsert;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = fields
.iter()
.map(super::super::field::FieldDef::name_str)
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ use super::{
},
CompositeIndexDef, ProjectionDef,
helpers::HasManyDef,
join::JoinDef,
upsert::UpsertDef
};

Expand Down Expand Up @@ -235,6 +236,11 @@ pub struct EntityDef {
/// Each entry defines an index spanning multiple columns.
pub indexes: Vec<CompositeIndexDef>,

/// Joined read-model declarations from `#[join(...)]`.
///
/// Non-empty when the entity generates a `{Entity}View`.
pub joins: Vec<JoinDef>,

/// Enable aggregate root pattern.
///
/// When `true`, generates `New{Name}` structs for create-only DTOs,
Expand Down
200 changes: 200 additions & 0 deletions crates/entity-derive-impl/src/entity/parse/entity/join.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
// 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<JoinFieldDef>
}

/// 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<Vec<JoinDef>> {
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<JoinDef> {
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<Vec<JoinDef>> {
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"));
}
}
Loading
Loading