From 94c1d6054b91e572633141e5b650cdba389f9b3a Mon Sep 17 00:00:00 2001 From: themixednuts <118081862+themixednuts@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:47:51 -0500 Subject: [PATCH] feat(query): concrete With* relation fields and reverse FK naming Return assembled *With* structs from find_many/find_first so loaded relations are normal fields (user.posts) instead of QueryRow methods. Also generate reverse accessors for multi-FK and self-referential columns, with optional relation = override and RELATION hover docs. Co-authored-by: Cursor --- README.md | 16 +- benches/postgres.rs | 2 +- benches/sqlite.rs | 2 +- core/src/lib.rs | 2 +- core/src/query/builder.rs | 58 +++- core/src/query/find.rs | 43 --- core/src/query/mod.rs | 14 +- core/src/query/row.rs | 8 +- core/src/query/store.rs | 14 +- core/src/relation.rs | 31 ++ examples/orm-comparison/drizzle/src/main.rs | 5 +- examples/readme_smoke.rs | 9 +- postgres/src/attrs.rs | 27 ++ procmacros/src/common/diagnostics.rs | 6 + procmacros/src/common/mod.rs | 2 +- procmacros/src/common/query.rs | 242 ++++++++------ procmacros/src/postgres/field.rs | 59 +++- procmacros/src/postgres/table/ddl.rs | 2 + procmacros/src/postgres/table/mod.rs | 1 + .../src/postgres/table/models/insert.rs | 1 + procmacros/src/sqlite/field.rs | 52 ++- procmacros/src/sqlite/table/ddl.rs | 1 + procmacros/src/sqlite/table/mod.rs | 1 + sqlite/src/attrs.rs | 27 ++ src/builder/postgres/postgres_sync/mod.rs | 72 ++--- src/builder/postgres/tokio_postgres/mod.rs | 72 ++--- src/builder/sqlite/libsql/mod.rs | 72 ++--- src/builder/sqlite/rusqlite/mod.rs | 72 ++--- src/builder/sqlite/turso/mod.rs | 72 ++--- tests/postgres/query.rs | 297 +++++++++++------- tests/sqlite/query.rs | 294 ++++++++++------- 31 files changed, 989 insertions(+), 587 deletions(-) delete mode 100644 core/src/query/find.rs diff --git a/README.md b/README.md index b3520efa7..520b944e3 100644 --- a/README.md +++ b/README.md @@ -517,11 +517,11 @@ let users = db.query(users) .find_many()?; for user in &users { - println!("{}: {} posts", user.name, user.posts().len()); + println!("{}: {} posts", user.name, user.posts.len()); } ``` -`.find_first()` returns `Option>` instead of `Vec`: +`.find_first()` returns `Option<...>` instead of `Vec`: ```rust let user = db.query(users) @@ -537,8 +537,8 @@ let users = db.query(users) .with(users.posts().with(posts.comments())) .find_many()?; -let first_post = &users[0].posts()[0]; -println!("{} comments", first_post.comments().len()); +let first_post = &users[0].posts[0]; +println!("{} comments", first_post.comments.len()); ``` Supports `where`, `order_by`, `limit`, and `offset` on the root query: @@ -572,15 +572,15 @@ for u in &users { Each table generates convenient type aliases for use in function signatures: ```rust -fn print_user_posts(user: &UsersQueryRow) { - println!("{} has {} posts", user.name, user.posts().len()); +fn print_user_posts(user: &UsersWithPosts) { + println!("{} has {} posts", user.name, user.posts.len()); } ``` -`UsersQueryRow` is the row type returned by `db.query(users)`, parameterized over the `with(...)` shape (`UsersWithPosts` here means "include `posts`"). +`UsersWithPosts` is the row type returned by `db.query(users).with(users.posts())`. Nest `*With*` types when composing multiple relations. > [!NOTE] -> Accessing a relation on a returned row (`user.posts()`, `post.comments()`) requires the generated `Query{Table}{Relation}` trait to be in scope — import it from your schema module (e.g. `use crate::schema::{QueryUsersPosts, QueryPostsComments};`). +> Relation data is exposed as fields on the generated `*With*` row type (e.g. `user.posts`, `post.comments`). Base columns remain available via deref. ## Transactions diff --git a/benches/postgres.rs b/benches/postgres.rs index 7634815ca..5931e42bc 100644 --- a/benches/postgres.rs +++ b/benches/postgres.rs @@ -353,7 +353,7 @@ fn bench_sync(c: &mut Criterion) { .order_by([asc(user.id)]) .find_many() .expect("relations"); - let post_count = out.iter().map(|row| row.posts().len()).sum::(); + let post_count = out.iter().map(|row| row.posts.len()).sum::(); black_box(post_count); black_box(out); }, diff --git a/benches/sqlite.rs b/benches/sqlite.rs index 2f8154168..e8f1cca5b 100644 --- a/benches/sqlite.rs +++ b/benches/sqlite.rs @@ -399,7 +399,7 @@ fn bench_rusqlite(c: &mut Criterion) { .order_by([asc(user.id)]) .find_many() .expect("relations"); - let post_count = out.iter().map(|row| row.posts().len()).sum::(); + let post_count = out.iter().map(|row| row.posts.len()).sum::(); black_box(post_count); black_box(out); }, diff --git a/core/src/lib.rs b/core/src/lib.rs index 3e086ef36..c4f68eec4 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -96,7 +96,7 @@ pub use pagination::PaginationArg; pub use param::{OwnedParam, Param, ParamBind, ParamSet}; pub use placeholder::*; #[cfg(feature = "query")] -pub use relation::{CardWrap, Many, One, OptionalOne, RelationDef}; +pub use relation::{AssembleRel, CardWrap, Many, One, OptionalOne, RelationDef}; pub use relation::{Joinable, Relation, SchemaHasTable}; pub use row::{ AfterFullJoin, AfterJoin, AfterLeftJoin, AfterRightJoin, DecodeSelectedRef, ExprValueType, diff --git a/core/src/query/builder.rs b/core/src/query/builder.rs index ec2f5ac2a..57a049130 100644 --- a/core/src/query/builder.rs +++ b/core/src/query/builder.rs @@ -307,12 +307,12 @@ impl<'a, V: SQLParam, T: QueryTable, Rels, Cl> QueryBuilder<'a, V, T, Rels, AllC } // ============================================================================= -// BuildStore +// BuildStore / BuildRow // ============================================================================= -/// Maps a builder's type-level relation list to a `RelEntry` storage chain. +/// Maps a builder's relation list to the JSON-decode store type. pub trait BuildStore { - /// The concrete storage type for this relation configuration. + /// Storage type for the configured relations. type Store; } @@ -338,6 +338,58 @@ where >; } +/// Assembles a decoded relation store into the public query row type. +/// +/// With no relations, `Row` is the base select / partial-select model. Each +/// `.with(...)` wraps that row in a generated `*With*` struct that exposes the +/// relation as a named field. +pub trait BuildRow: BuildStore { + /// Row type returned by `find_many` / `find_first`. + type Row; + + /// Builds the public row from a base model and decoded relation store. + fn assemble(base: Base, store: Self::Store) -> Self::Row; +} + +impl BuildRow for () { + type Row = Base; + + fn assemble(base: Base, (): ()) -> Self::Row { + base + } +} + +impl<'a, V: SQLParam, R, Nested, Rest, Cols, Cl, Base> BuildRow + for (RelationHandle<'a, V, R, Nested, Cols, Cl>, Rest) +where + R: RelationDef + crate::relation::AssembleRel, + R::Target: QueryTable, + Cols: ResolveSelect, + Nested: BuildRow<>::Model>, + Rest: BuildRow, + Self: BuildStore< + Store = RelEntry< + R, + ::Wrap< + QueryRow<>::Model, Nested::Store>, + >, + Rest::Store, + >, + >, +{ + type Row = ::Row; + + fn assemble(base: Base, store: Self::Store) -> Self::Row { + let (data, rest) = store.into_parts(); + let inner = Rest::assemble(base, rest); + let children = R::Card::map_wrap(data, |child_row| { + let (child_base, child_store) = child_row.into_parts(); + Nested::assemble(child_base, child_store) + }); + R::assemble_row(inner, children) + } +} + // ============================================================================= // QueryTable // ============================================================================= diff --git a/core/src/query/find.rs b/core/src/query/find.rs deleted file mode 100644 index 601e7f9ee..000000000 --- a/core/src/query/find.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Type-level witness search for relation data in a `RelEntry` chain. - -use core::marker::PhantomData; - -use super::store::RelEntry; - -/// Witness: the relation was found at the head of the chain. -pub struct Here; - -/// Witness: the relation was found deeper in the chain. -pub struct There(PhantomData); - -/// Type-level search for relation `Rel` in a `RelEntry` chain. -/// -/// The witness `W` is inferred by the compiler (unique because each -/// relation ZST appears at most once in the chain). -pub trait FindRel { - /// The data type stored for this relation. - type Data; - /// Returns a reference to the stored data. - fn get(&self) -> &Self::Data; -} - -// Base case: found at head of chain. -impl FindRel for RelEntry { - type Data = Data; - - fn get(&self) -> &Data { - &self.data - } -} - -// Recursive case: search in the tail. -impl FindRel> for RelEntry -where - Rest: FindRel, -{ - type Data = >::Data; - - fn get(&self) -> &Self::Data { - self.rest.get() - } -} diff --git a/core/src/query/mod.rs b/core/src/query/mod.rs index 47b876de0..25a9bc600 100644 --- a/core/src/query/mod.rs +++ b/core/src/query/mod.rs @@ -2,15 +2,14 @@ //! //! Provides type-safe relational queries with nested relation loading. //! -//! The pipeline: [`QueryBuilder`] configures filtering/pagination and collects -//! [`RelationHandle`]s via `.with()`. At execution time, handles are rendered -//! into SQL via [`RenderRelations`], the query is built by [`build_query_sql`], -//! and results are deserialized via [`DeserializeStore`] + [`FromJsonObject`] -//! into [`QueryRow`](QueryRow) values. +//! [`QueryBuilder`] collects [`RelationHandle`]s via `.with()`. At execution +//! time, handles are rendered into SQL via [`RenderRelations`] and +//! [`build_query_sql`]. Results decode through [`DeserializeStore`] / +//! [`FromJsonObject`] and are assembled into concrete `*With*` row types by +//! [`BuildRow`]. mod builder; mod deser; -mod find; #[doc(hidden)] pub mod handle; mod row; @@ -18,7 +17,7 @@ mod sql; mod store; pub use builder::{ - AllColumns, BuildStore, Clauses, HasLimit, HasOffset, HasOrderBy, HasWhere, + AllColumns, BuildRow, BuildStore, Clauses, HasLimit, HasOffset, HasOrderBy, HasWhere, IntoColumnSelection, NoLimit, NoOrderBy, NoWhere, PartialColumns, QueryBuilder, QueryTable, ResolveSelect, }; @@ -26,7 +25,6 @@ pub use deser::{ DeserializeStore, FromJsonColumn, FromJsonField, FromJsonObject, JsonBool, JsonObjectDecoder, JsonOptionalBool, }; -pub use find::{FindRel, Here, There}; pub use handle::RelationHandle; pub use row::QueryRow; pub use sql::{RelCardinality, RenderRelations, RenderedRelation, build_query_sql}; diff --git a/core/src/query/row.rs b/core/src/query/row.rs index 97574bc16..0b7dbeb3b 100644 --- a/core/src/query/row.rs +++ b/core/src/query/row.rs @@ -1,11 +1,11 @@ -//! `QueryRow` — wrapper for a row with relation data. +//! `QueryRow` — nested relation JSON decode target. use core::ops::Deref; -/// A query result row that wraps a base select model with optional relation data. +/// A base select model paired with a decoded relation store. /// -/// Users access base fields via `Deref` (transparent) and relation data -/// via extension trait methods generated by the table macro. +/// Used while deserializing nested relation JSON columns. Public query APIs +/// assemble this into generated `*With*` row structs via [`super::BuildRow`]. #[derive(Debug, Clone)] pub struct QueryRow { base: Base, diff --git a/core/src/query/store.rs b/core/src/query/store.rs index e3bf96522..8c96870b6 100644 --- a/core/src/query/store.rs +++ b/core/src/query/store.rs @@ -2,14 +2,9 @@ use core::marker::PhantomData; -/// Stores one relation's data plus the rest of the chain. +/// One loaded relation's data plus the remaining store chain. /// -/// A `QueryRow` with two included relations has store type: -/// ```rust -/// # let _ = r####" -/// RelEntry, RelEntry, ()>> -/// # "####; -/// ``` +/// Built during JSON column decode and consumed by [`super::BuildRow`]. #[derive(Debug, Clone)] pub struct RelEntry { pub(crate) data: Data, @@ -26,4 +21,9 @@ impl RelEntry { _rel: PhantomData, } } + + /// Splits this entry into its relation data and the remaining chain. + pub(crate) fn into_parts(self) -> (Data, Rest) { + (self.data, self.rest) + } } diff --git a/core/src/relation.rs b/core/src/relation.rs index a7f3cd7ce..21da18756 100644 --- a/core/src/relation.rs +++ b/core/src/relation.rs @@ -60,6 +60,25 @@ pub trait CardWrap: private::Sealed { /// The cardinality for runtime SQL generation decisions. const CARDINALITY: crate::query::RelCardinality; type Wrap; + + /// Maps the wrapped value from `A` to `B`. + fn map_wrap(data: Self::Wrap, f: impl FnMut(A) -> B) -> Self::Wrap; +} + +/// Constructs the public with-row type for a loaded relation. +/// +/// Implemented by table macros for each relation definition. `Row` +/// is the generated struct (for example `UsersWithPosts`). +#[cfg(feature = "query")] +pub trait AssembleRel: RelationDef { + /// Row type produced when this relation is included in a query. + type Row; + + /// Builds a with-row from the composed inner row and this relation's data. + fn assemble_row( + inner: Inner, + data: ::Wrap, + ) -> Self::Row; } /// Many-cardinality: wraps data as `Vec`. @@ -97,18 +116,30 @@ impl private::Sealed for OptionalOne {} impl CardWrap for Many { const CARDINALITY: crate::query::RelCardinality = crate::query::RelCardinality::Many; type Wrap = crate::prelude::Vec; + + fn map_wrap(data: Self::Wrap, f: impl FnMut(A) -> B) -> Self::Wrap { + data.into_iter().map(f).collect() + } } #[cfg(feature = "query")] impl CardWrap for One { const CARDINALITY: crate::query::RelCardinality = crate::query::RelCardinality::One; type Wrap = T; + + fn map_wrap(data: Self::Wrap, mut f: impl FnMut(A) -> B) -> Self::Wrap { + f(data) + } } #[cfg(feature = "query")] impl CardWrap for OptionalOne { const CARDINALITY: crate::query::RelCardinality = crate::query::RelCardinality::OptionalOne; type Wrap = Option; + + fn map_wrap(data: Self::Wrap, f: impl FnMut(A) -> B) -> Self::Wrap { + data.map(f) + } } #[cfg(feature = "query")] diff --git a/examples/orm-comparison/drizzle/src/main.rs b/examples/orm-comparison/drizzle/src/main.rs index 7116837e0..c8849c9d9 100644 --- a/examples/orm-comparison/drizzle/src/main.rs +++ b/examples/orm-comparison/drizzle/src/main.rs @@ -6,8 +6,7 @@ use drizzle::sqlite::prelude::*; use drizzle::sqlite::rusqlite::Drizzle; use schema::{ - InsertComments, InsertPosts, InsertUsers, Posts, QueryUsersPosts, Schema, SelectUsers, - UpdateUsers, Users, + InsertComments, InsertPosts, InsertUsers, Posts, Schema, SelectUsers, UpdateUsers, Users, }; fn main() -> drizzle::Result<()> { @@ -72,7 +71,7 @@ fn main() -> drizzle::Result<()> { println!("--- relations ---"); let loaded = db.query(users).with(users.posts()).find_many()?; for u in &loaded { - println!("{}: {} posts", u.name, u.posts().len()); + println!("{}: {} posts", u.name, u.posts.len()); } Ok(()) diff --git a/examples/readme_smoke.rs b/examples/readme_smoke.rs index ec7bf6a37..e2f8b5fc2 100644 --- a/examples/readme_smoke.rs +++ b/examples/readme_smoke.rs @@ -61,8 +61,7 @@ fn main() -> Result<(), Box> { use drizzle::sqlite::rusqlite::Drizzle; use readme::{ - InsertComments, InsertPosts, InsertUsers, Posts, QueryPostsComments, QueryUsersPosts, - Schema, SelectUsers, UpdateUsers, Users, + InsertComments, InsertPosts, InsertUsers, Posts, Schema, SelectUsers, UpdateUsers, Users, }; // -------- 4. Connect & Query -------- @@ -254,7 +253,7 @@ fn main() -> Result<(), Box> { // -------- Relational Queries -------- let user_rows = db.query(users).with(users.posts()).find_many()?; for u in &user_rows { - let _ = u.posts().len(); + let _ = u.posts.len(); } let _found = db @@ -268,7 +267,7 @@ fn main() -> Result<(), Box> { .with(users.posts().with(posts.comments())) .find_many()?; if let Some(first) = nested.first() { - let _ = first.posts().first().map(|p| p.comments().len()); + let _ = first.posts.first().map(|p| p.comments.len()); } let _paged = db @@ -290,7 +289,7 @@ fn main() -> Result<(), Box> { } // Type aliases used in function signatures (no body needed — just verify they exist). - fn _consume(_: &readme::UsersQueryRow) {} + fn _consume(_: &readme::UsersWithPosts) {} // -------- Transactions -------- use drizzle::sqlite::connection::SQLiteTransactionType; diff --git a/postgres/src/attrs.rs b/postgres/src/attrs.rs index 9e1a46a21..0f8a20154 100644 --- a/postgres/src/attrs.rs +++ b/postgres/src/attrs.rs @@ -350,6 +350,33 @@ pub const DEFAULT: ColumnMarker = ColumnMarker; /// See: pub const REFERENCES: ColumnMarker = ColumnMarker; +/// Sets the reverse relation accessor name on the referenced table. +/// +/// By default, reverse relations are named from the source table +/// (`posts` for a `Post` table). When multiple foreign keys target the +/// same table — or the FK is self-referential — the name is disambiguated +/// as `{forward}_{plural}` (e.g. `author_posts`). Use `relation` to pick +/// an explicit reverse name instead. +/// +/// The forward relation (on this table) is unchanged; only the reverse +/// accessor on the referenced table is renamed. +/// +/// ## Example +/// ```rust +/// # let _ = r####" +/// // Users get `.authored()` instead of `.author_posts()` +/// #[column(REFERENCES = User::id, RELATION = "authored")] +/// author_id: i32, +/// +/// // Still auto-disambiguated: Users get `.editor_posts()` +/// #[column(REFERENCES = User::id)] +/// editor_id: Option, +/// # "####; +/// ``` +/// +/// Requires a `REFERENCES` attribute on the same column. +pub const RELATION: ColumnMarker = ColumnMarker; + /// Specifies the ON DELETE action for foreign key references. /// /// ## Example diff --git a/procmacros/src/common/diagnostics.rs b/procmacros/src/common/diagnostics.rs index 49a54b779..5e94ba217 100644 --- a/procmacros/src/common/diagnostics.rs +++ b/procmacros/src/common/diagnostics.rs @@ -15,3 +15,9 @@ pub fn references_required_message(on_delete: bool, on_update: bool) -> String { .to_string() } } + +/// Error when `relation = "..."` is set without a foreign key reference. +pub fn relation_requires_references_message() -> &'static str { + "relation requires a references attribute.\n\ + Example: #[column(references = Table::column, relation = \"posts\")]" +} diff --git a/procmacros/src/common/mod.rs b/procmacros/src/common/mod.rs index b9d50c77a..10e81194d 100644 --- a/procmacros/src/common/mod.rs +++ b/procmacros/src/common/mod.rs @@ -22,7 +22,7 @@ pub mod view_query; pub use constraint::Constraint; pub use context::ModelType; -pub use diagnostics::references_required_message; +pub use diagnostics::{references_required_message, relation_requires_references_message}; #[cfg(feature = "sqlite")] pub use helpers::has_json_attribute; pub use helpers::{extract_struct_fields, make_uppercase_path, parse_column_reference}; diff --git a/procmacros/src/common/query.rs b/procmacros/src/common/query.rs index 100a624ff..6c7700af0 100644 --- a/procmacros/src/common/query.rs +++ b/procmacros/src/common/query.rs @@ -1,7 +1,7 @@ //! Shared query API code generation for both `SQLite` and `PostgreSQL`. //! -//! Generates relation ZSTs, `RelationDef` impls, inherent accessor methods, -//! result accessor traits, type aliases, JSON decoder impls, and column +//! Generates relation ZSTs, `RelationDef` impls, inherent builder accessors, +//! concrete `*With*` result row structs, JSON decoder impls, and column //! selectors from FK declarations. use heck::{ToSnakeCase, ToUpperCamelCase}; @@ -19,6 +19,9 @@ pub struct FkInfo { pub target_column_ident: Ident, /// Whether the source field is nullable (Option). pub is_nullable: bool, + /// Optional reverse-relation name from `#[column(relation = "...")]`. + /// When set, overrides the auto-derived reverse accessor name. + pub relation_name: Option, } /// How an enum field is stored in the database. @@ -72,8 +75,8 @@ pub struct FieldJsonInfo { /// /// Returns a `TokenStream` containing: /// - `QueryTable` impl for the table ZST -/// - Forward relation items (ZST, `RelationDef` impl, accessor method, result accessor trait, type alias) -/// - Reverse relation items (ZST, `RelationDef` impl, accessor method, result accessor trait, type alias) +/// - Forward relation items (ZST, `RelationDef` impl, builder accessor, `*With*` row struct) +/// - Reverse relation items (ZST, `RelationDef` impl, builder accessor, `*With*` row struct) /// - JSON decoder impl for the select model /// - JSON decoder impl for the partial select model /// - Column selector struct and `.columns()` method @@ -107,20 +110,14 @@ pub fn generate_query_api( &blob_column_names, )); - // 1b. Generate QueryRow type alias for cleaner function signatures + // 1b. Type alias for a query result row with no relations loaded. let query_row_alias = format_ident!("{}QueryRow", struct_ident); tokens.extend(quote! { - /// Type alias for a query result row from this table. + /// Type alias for a query result row from this table with no relations loaded. /// - /// Use `S` to specify loaded relations: - /// ```rust - /// # type UsersWithPosts = (); - /// # type UsersQueryRow = T; - /// fn process(rows: &[UsersQueryRow]) { - /// let _ = rows; - /// } - /// ``` - #struct_vis type #query_row_alias = drizzle::core::query::QueryRow<#select_model_ident, S>; + /// Relation-loaded results use the generated `*With*` structs instead + /// (for example `UsersWithPosts`). + #struct_vis type #query_row_alias = #select_model_ident; }); // 2. Generate forward relations (from this table to target) @@ -225,28 +222,55 @@ fn reverse_method_name(source_table_ident: &Ident) -> String { pluralize(&source_table_ident.to_string().to_snake_case()) } +/// Resolve the reverse relation accessor name for one FK. +/// +/// Priority: +/// 1. Explicit `relation = "..."` override +/// 2. Disambiguated `{forward}_{plural(source)}` when self-ref or multi-FK +/// 3. Plain `plural(source)` otherwise +fn reverse_relation_method_name( + fk: &FkInfo, + source_table_ident: &Ident, + needs_disambiguation: bool, +) -> String { + if let Some(name) = &fk.relation_name { + return name.clone(); + } + let base = reverse_method_name(source_table_ident); + if needs_disambiguation { + format!("{}_{base}", forward_method_name(&fk.source_column)) + } else { + base + } +} + /// Pluralize an English word using the `pluralizer` crate. fn pluralize(s: &str) -> String { pluralizer::pluralize(s, 2, false) } +/// Cardinality of a generated relation — controls the with-row field type. +enum RelCardKind { + Many, + One, + OptionalOne, +} + /// Parameters needed to emit one relation (ZST + `RelationDef` + accessor -/// method + result accessor trait + type alias) regardless of cardinality. +/// method + concrete with-row struct) regardless of cardinality. /// /// Forward, reverse, and many-to-many generation all produce the same /// skeleton; only the source/target types, cardinality, FK columns body, -/// optional junction body, accessor receiver, and the type alias's data -/// slot differ. `RelEmitter::emit` is the sole place that knows the skeleton. +/// optional junction body, and accessor receiver differ. +/// `RelEmitter::emit` is the sole place that knows the skeleton. struct RelEmitter<'a> { /// `pub` / `pub(crate)` from the host struct. vis: &'a Visibility, /// `__Rel_X_Y` — the ZST that implements `RelationDef`. rel_zst: Ident, - /// `QueryXY` — the result accessor trait. - accessor_trait: Ident, - /// `XWithY` — public type alias for the loaded-relation row. + /// `XWithY` — public concrete with-row struct. type_alias: Ident, - /// Method ident on both the accessor receiver and the accessor trait. + /// Method / field ident (`posts`, `author`, …). method: Ident, /// `RelationDef::NAME` — the method name string for runtime use. method_str: String, @@ -254,8 +278,12 @@ struct RelEmitter<'a> { source: TokenStream, /// `RelationDef::Target` type tokens. target: TokenStream, - /// `RelationDef::Card` (One / OptionalOne / Many). - card: TokenStream, + /// Default `Inner` select model (usually `Select{Source}`). + source_select: Ident, + /// Default `Child` select model (usually `Select{Target}`). + target_select: Ident, + /// Cardinality kind for the with-row field wrapper. + card_kind: RelCardKind, /// Body of `fn fk_columns()` — usually `&[(...)]` or `&[]` for M2M. fk_columns_body: TokenStream, /// `Some(body)` to emit `fn junction()`, `None` to omit it. @@ -263,9 +291,6 @@ struct RelEmitter<'a> { /// Receiver of the accessor method's inherent impl /// (e.g. `__XForwardRels`, the target table ZST, the source table ZST). accessor_receiver: TokenStream, - /// Data slot of `RelEntry` in the type alias - /// (e.g. `QueryRow<...>`, `Option>`, `Vec>`). - data_type: TokenStream, } impl RelEmitter<'_> { @@ -273,19 +298,31 @@ impl RelEmitter<'_> { let RelEmitter { vis, rel_zst, - accessor_trait, type_alias, method, method_str, source, target, - card, + source_select, + target_select, + card_kind, fk_columns_body, junction_body, accessor_receiver, - data_type, } = self; + let card = match card_kind { + RelCardKind::Many => quote!(drizzle::core::relation::Many), + RelCardKind::One => quote!(drizzle::core::relation::One), + RelCardKind::OptionalOne => quote!(drizzle::core::relation::OptionalOne), + }; + + let field_ty = match card_kind { + RelCardKind::Many => quote!(::std::vec::Vec), + RelCardKind::One => quote!(Child), + RelCardKind::OptionalOne => quote!(::std::option::Option), + }; + let junction_fn = junction_body.as_ref().map(|body| { quote! { fn junction() -> Option { #body } @@ -311,30 +348,45 @@ impl RelEmitter<'_> { #junction_fn } - /// Type alias for query results with this relation loaded. + /// Query result row with the `#method` relation loaded. /// - /// Nest `Rest` to compose multiple relations. - #vis type #type_alias = - drizzle::core::query::RelEntry<#rel_zst, #data_type, Rest>; + /// Base columns are available through [`Deref`](core::ops::Deref). + /// The `#method` field holds the loaded relation. Compose multiple + /// relations by nesting another `*With*` type as `Inner`. + #[derive(Debug, Clone)] + #vis struct #type_alias { + /// Inner row — the root select model, or a previously composed with-row. + pub inner: Inner, + /// Loaded `#method` relation. + pub #method: #field_ty, + } - impl #accessor_receiver { - #vis fn #method<'a, __V: drizzle::core::SQLParam>(&self) -> drizzle::core::query::RelationHandle<'a, __V, #rel_zst> { - drizzle::core::query::RelationHandle::new() + impl ::core::ops::Deref for #type_alias { + type Target = Inner; + #[inline] + fn deref(&self) -> &Inner { + &self.inner } } - #vis trait #accessor_trait { - type Data; - fn #method(&self) -> &Self::Data; + impl drizzle::core::relation::AssembleRel for #rel_zst { + type Row = #type_alias; + + #[inline] + fn assemble_row( + inner: Inner, + data: ::Wrap, + ) -> Self::Row { + #type_alias { + inner, + #method: data, + } + } } - impl #accessor_trait for drizzle::core::query::QueryRow - where - Store: drizzle::core::query::FindRel<#rel_zst, W>, - { - type Data = >::Data; - fn #method(&self) -> &Self::Data { - self.store.get() + impl #accessor_receiver { + #vis fn #method<'a, __V: drizzle::core::SQLParam>(&self) -> drizzle::core::query::RelationHandle<'a, __V, #rel_zst> { + drizzle::core::query::RelationHandle::new() } } } @@ -375,6 +427,8 @@ fn generate_forward_relations( } }); + let source_select = format_ident!("Select{struct_ident}"); + for fk in fk_infos { let method_name_str = forward_method_name(&fk.source_column); let target_table = &fk.target_table_ident; @@ -383,34 +437,27 @@ fn generate_forward_relations( let target_col = fk.target_column_ident.to_string(); let method_pascal = to_pascal(&method_name_str); - // Nullable FK -> OptionalOne + Option<...> data slot, else One. - let (card, data_type) = if fk.is_nullable { - ( - quote!(drizzle::core::relation::OptionalOne), - quote!(Option>), - ) + let card_kind = if fk.is_nullable { + RelCardKind::OptionalOne } else { - ( - quote!(drizzle::core::relation::One), - quote!(drizzle::core::query::QueryRow<#target_select, ()>), - ) + RelCardKind::One }; tokens.extend( RelEmitter { vis, rel_zst: format_ident!("__Rel_{struct_ident}_{method_pascal}"), - accessor_trait: format_ident!("Query{struct_ident}{method_pascal}"), type_alias: format_ident!("{struct_ident}With{method_pascal}"), method: format_ident!("{method_name_str}"), method_str: method_name_str.clone(), source: quote!(#struct_ident), target: quote!(#target_table), - card, + source_select: source_select.clone(), + target_select, + card_kind, fk_columns_body: quote!(&[(#target_col, #source_col)]), junction_body: None, accessor_receiver: quote!(#rels_struct), - data_type, } .emit(), ); @@ -420,6 +467,11 @@ fn generate_forward_relations( } /// Generates reverse relations (Many from target tables back to this table). +/// +/// Unique FKs use the pluralized source table name (`posts`). Self-referential +/// FKs and multiple FKs to the same target are disambiguated as +/// `{forward}_{plural}` (e.g. `author_posts`), unless `relation = "..."` +/// overrides the reverse name. fn generate_reverse_relations( struct_ident: &Ident, vis: &Visibility, @@ -434,49 +486,64 @@ fn generate_reverse_relations( *target_fk_counts.entry(target_name).or_insert(0usize) += 1; } - // Track which targets already had reverse generated to avoid duplicates - let mut seen_targets = std::collections::HashSet::new(); + // Planned reverses: (fk, method_name). Collision-checked per target. + let mut planned: Vec<(&FkInfo, String)> = Vec::with_capacity(fk_infos.len()); + let mut names_by_target: std::collections::HashMap> = + std::collections::HashMap::new(); for fk in fk_infos { let target_name = fk.target_table_ident.to_string(); + let is_self_ref = fk.target_table_ident == *struct_ident; + let needs_disambiguation = is_self_ref || target_fk_counts[&target_name] > 1; + let method_name_str = reverse_relation_method_name(fk, struct_ident, needs_disambiguation); + + names_by_target + .entry(target_name) + .or_default() + .push((method_name_str.clone(), fk.source_column.clone())); + planned.push((fk, method_name_str)); + } - // Skip self-referential FKs - if fk.target_table_ident == *struct_ident { - continue; - } - - // Skip multiple FKs to same target (ambiguous reverse) - if target_fk_counts[&target_name] > 1 { - continue; - } - - // Skip if already generated for this target - if !seen_targets.insert(target_name.clone()) { - continue; + for (target, entries) in &names_by_target { + let mut seen = std::collections::HashMap::<&str, &str>::new(); + for (method, col) in entries { + if let Some(prev_col) = seen.insert(method.as_str(), col.as_str()) { + let msg = format!( + "duplicate reverse relation `{method}` on `{target}` \ + (from columns `{prev_col}` and `{col}`). \ + Use `#[column(relation = \"...\")]` to disambiguate." + ); + tokens.extend(quote! { + ::core::compile_error!(#msg); + }); + } } + } + for (fk, method_name_str) in planned { let target_table = &fk.target_table_ident; - let source_select = format_ident!("Select{}", struct_ident); - let method_name_str = reverse_method_name(struct_ident); + let child_select = format_ident!("Select{struct_ident}"); + let parent_select = format_ident!("Select{target_table}"); let method_pascal = to_pascal(&method_name_str); let source_col = &fk.source_column; let target_col = fk.target_column_ident.to_string(); + let method = format_ident!("{method_name_str}"); tokens.extend( RelEmitter { vis, rel_zst: format_ident!("__Rel_{target_table}_{method_pascal}"), - accessor_trait: format_ident!("Query{target_table}{method_pascal}"), type_alias: format_ident!("{target_table}With{method_pascal}"), - method: format_ident!("{method_name_str}"), - method_str: method_name_str.clone(), + method, + method_str: method_name_str, source: quote!(#target_table), target: quote!(#struct_ident), - card: quote!(drizzle::core::relation::Many), + source_select: parent_select, + target_select: child_select, + card_kind: RelCardKind::Many, fk_columns_body: quote!(&[(#source_col, #target_col)]), junction_body: None, accessor_receiver: quote!(#target_table), - data_type: quote!(Vec>), } .emit(), ); @@ -520,7 +587,8 @@ fn generate_many_to_many_relations( for (source_fk, target_fk) in [(fk_a, fk_b), (fk_b, fk_a)] { let source_table = &source_fk.target_table_ident; let target_table = &target_fk.target_table_ident; - let target_select = format_ident!("Select{}", target_table); + let source_select = format_ident!("Select{source_table}"); + let target_select = format_ident!("Select{target_table}"); let method_name_str = reverse_method_name(target_table); let method_pascal = to_pascal(&method_name_str); @@ -535,15 +603,14 @@ fn generate_many_to_many_relations( vis, // Include junction table name to avoid collisions with reverse relations rel_zst: format_ident!("__Rel_{source_table}_Via{junction_pascal}_{method_pascal}"), - accessor_trait: format_ident!( - "Query{source_table}Via{junction_pascal}{method_pascal}" - ), type_alias: format_ident!("{source_table}Via{junction_pascal}With{method_pascal}"), method: format_ident!("{method_name_str}"), method_str: method_name_str.clone(), source: quote!(#source_table), target: quote!(#target_table), - card: quote!(drizzle::core::relation::Many), + source_select, + target_select, + card_kind: RelCardKind::Many, fk_columns_body: quote!(&[]), junction_body: Some(quote! { Some(drizzle::core::relation::JunctionMeta { @@ -553,7 +620,6 @@ fn generate_many_to_many_relations( }) }), accessor_receiver: quote!(#source_table), - data_type: quote!(Vec>), } .emit(), ); diff --git a/procmacros/src/postgres/field.rs b/procmacros/src/postgres/field.rs index 394c983fe..6b7fe63e9 100644 --- a/procmacros/src/postgres/field.rs +++ b/procmacros/src/postgres/field.rs @@ -7,13 +7,14 @@ use syn::{Attribute, Error, Expr, ExprPath, Field, Ident, Lit, Result, Token, Ty use crate::common::make_uppercase_path; use crate::common::{ - is_option_type, option_inner_type, references_required_message, type_is_array_char, - type_is_array_string, type_is_array_u8, type_is_arrayvec_u8, type_is_bit_vec, type_is_bool, - type_is_datetime_tz, type_is_float, type_is_geo_linestring, type_is_geo_point, - type_is_geo_rect, type_is_int, type_is_ip_addr, type_is_ip_cidr, type_is_json_value, - type_is_mac_addr, type_is_naive_date, type_is_naive_datetime, type_is_naive_time, - type_is_offset_datetime, type_is_primitive_date_time, type_is_string_like, type_is_time_date, - type_is_time_time, type_is_uuid, type_is_vec_u8, unwrap_option, vec_inner_type, + is_option_type, option_inner_type, references_required_message, + relation_requires_references_message, type_is_array_char, type_is_array_string, + type_is_array_u8, type_is_arrayvec_u8, type_is_bit_vec, type_is_bool, type_is_datetime_tz, + type_is_float, type_is_geo_linestring, type_is_geo_point, type_is_geo_rect, type_is_int, + type_is_ip_addr, type_is_ip_cidr, type_is_json_value, type_is_mac_addr, type_is_naive_date, + type_is_naive_datetime, type_is_naive_time, type_is_offset_datetime, + type_is_primitive_date_time, type_is_string_like, type_is_time_date, type_is_time_time, + type_is_uuid, type_is_vec_u8, unwrap_option, vec_inner_type, }; // Note: drizzle_types::postgres::TypeCategory exists but has different feature gates. @@ -681,6 +682,8 @@ pub struct FieldInfo { pub default_fn: Option, pub check_constraint: Option, pub foreign_key: Option, + /// Optional reverse-relation name from `#[column(relation = "...")]`. + pub relation_name: Option, pub has_default: bool, pub marker_exprs: Vec, /// True for unknown types that are validated at type-check time via `DrizzlePostgresColumn` trait @@ -809,6 +812,7 @@ impl FieldInfo { let mut is_explicit_jsonb = false; let mut column_name = None; let mut collate: Option = None; + let mut relation_name: Option = None; for attr in &field.attrs { if let Some(column_info) = Self::parse_column_attribute(attr, type_category, name.span())? @@ -829,6 +833,7 @@ impl FieldInfo { is_explicit_jsonb = column_info.is_jsonb; column_name = column_info.column_name; collate = column_info.collate; + relation_name = column_info.relation_name; marker_exprs = column_info.marker_exprs; break; } @@ -950,6 +955,7 @@ impl FieldInfo { default_fn, check_constraint, foreign_key, + relation_name, has_default, marker_exprs, is_custom_type, @@ -1003,6 +1009,7 @@ impl FieldInfo { let enum_type_name: Option = None; let mut column_name = None; let mut collate: Option = None; + let mut relation_name: Option = None; let mut marker_exprs = Vec::new(); // Parse attribute arguments: #[column(primary, unique, default = "foo")] @@ -1276,7 +1283,32 @@ impl FieldInfo { if meta.input.peek(Token![=]) { meta.input.parse::()?; let path: ExprPath = meta.input.parse()?; - foreign_key = Some(Self::parse_reference(&path)?); marker_exprs.push(make_uppercase_path(path_ident, "REFERENCES")); + foreign_key = Some(Self::parse_reference(&path)?); + marker_exprs.push(make_uppercase_path(path_ident, "REFERENCES")); + } + } + "RELATION" => { + if meta.input.peek(Token![=]) { + meta.input.parse::()?; + let lit: Lit = meta.input.parse()?; + if let Lit::Str(s) = lit { + let name = s.value(); + if syn::parse_str::(&name).is_err() { + return Err(syn::Error::new_spanned( + &s, + format!( + "relation = \"{name}\" must be a valid Rust identifier" + ), + )); + } + relation_name = Some(name); + marker_exprs.push(make_uppercase_path(path_ident, "RELATION")); + } else { + return Err(syn::Error::new_spanned( + lit, + "relation requires a string literal, e.g. relation = \"authored\"", + )); + } } } "ON_DELETE" => { @@ -1346,7 +1378,7 @@ impl FieldInfo { format!("unknown #[column] attribute `{path_ident}`.\n\ Supported: primary, unique, serial, bigserial, smallserial, identity, \ generated, json, jsonb, enum, name, default, default_fn, default_sql, check, references, \ - on_delete, on_update, deferrable, initially_deferred"), + relation, on_delete, on_update, deferrable, initially_deferred"), )); } } @@ -1369,12 +1401,20 @@ impl FieldInfo { )); } + if relation_name.is_some() && foreign_key.is_none() { + return Err(syn::Error::new( + span, + relation_requires_references_message(), + )); + } + Ok(Some(ColumnInfo { flags, default, default_fn, check_constraint, foreign_key, + relation_name, is_serial, is_smallserial, is_bigserial, @@ -1824,6 +1864,7 @@ struct ColumnInfo { default_fn: Option, check_constraint: Option, foreign_key: Option, + relation_name: Option, is_serial: bool, is_smallserial: bool, is_bigserial: bool, diff --git a/procmacros/src/postgres/table/ddl.rs b/procmacros/src/postgres/table/ddl.rs index 2b1621e98..a18ff4741 100644 --- a/procmacros/src/postgres/table/ddl.rs +++ b/procmacros/src/postgres/table/ddl.rs @@ -825,6 +825,7 @@ mod tests { default_fn: None, check_constraint: None, foreign_key: None, + relation_name: None, has_default: false, marker_exprs: Vec::new(), constraint: crate::common::Constraint::None, @@ -872,6 +873,7 @@ mod tests { deferrable: false, initially_deferred: false, }), + relation_name: None, has_default: false, marker_exprs: Vec::new(), constraint: crate::common::Constraint::None, diff --git a/procmacros/src/postgres/table/mod.rs b/procmacros/src/postgres/table/mod.rs index ddcc022b8..96f7b613d 100644 --- a/procmacros/src/postgres/table/mod.rs +++ b/procmacros/src/postgres/table/mod.rs @@ -214,6 +214,7 @@ pub fn generate_query_api_impls(ctx: &MacroContext) -> TokenStream { target_table_ident: fk.table.clone(), target_column_ident: fk.column.clone(), is_nullable: f.is_nullable, + relation_name: f.relation_name.clone(), }) }) .collect(); diff --git a/procmacros/src/postgres/table/models/insert.rs b/procmacros/src/postgres/table/models/insert.rs index 4528b026e..970cf223d 100644 --- a/procmacros/src/postgres/table/models/insert.rs +++ b/procmacros/src/postgres/table/models/insert.rs @@ -236,6 +236,7 @@ mod tests { default_fn: None, check_constraint: None, foreign_key: None, + relation_name: None, has_default: false, marker_exprs: Vec::new(), constraint: crate::common::Constraint::None, diff --git a/procmacros/src/sqlite/field.rs b/procmacros/src/sqlite/field.rs index 75037edc4..b385dd93d 100644 --- a/procmacros/src/sqlite/field.rs +++ b/procmacros/src/sqlite/field.rs @@ -10,9 +10,10 @@ use syn::{ use crate::common::make_uppercase_path; use crate::common::{ - is_option_type, option_inner_type, references_required_message, type_is_array_string, - type_is_array_u8, type_is_arrayvec_u8, type_is_bool, type_is_byte_slice, type_is_datetime_tz, - type_is_float, type_is_int, type_is_json_value, type_is_naive_date, type_is_naive_datetime, + is_option_type, option_inner_type, references_required_message, + relation_requires_references_message, type_is_array_string, type_is_array_u8, + type_is_arrayvec_u8, type_is_bool, type_is_byte_slice, type_is_datetime_tz, type_is_float, + type_is_int, type_is_json_value, type_is_naive_date, type_is_naive_datetime, type_is_naive_time, type_is_offset_datetime, type_is_primitive_date_time, type_is_string_like, type_is_time_date, type_is_time_time, type_is_uuid, type_is_vec_u8, unwrap_option, }; @@ -250,6 +251,9 @@ pub struct FieldInfo<'a> { // Foreign key support pub(crate) foreign_key: Option, + /// Optional reverse-relation name from `#[column(relation = "...")]`. + pub(crate) relation_name: Option, + /// Resolved primary-key / unique state. /// /// Set in `from_field` once the table-level `is_composite_pk` decision @@ -350,6 +354,8 @@ struct ParsedArgs { references: Option, on_delete: Option, on_update: Option, + /// Reverse-relation accessor name from `relation = "..."`. + relation: Option, name: Option, /// SQLite collation name from `collate = "NOCASE"` (or other built-in / /// custom registered collation). Stored as the literal name; emitted @@ -377,6 +383,8 @@ struct AttributeData { references_path: Option, on_delete: Option, on_update: Option, + /// Reverse-relation accessor name from `relation = "..."`. + relation: Option, attr_name: Option, /// SQLite collation name. See [`ParsedArgs::collate`]. collate: Option, @@ -512,6 +520,32 @@ impl<'a> FieldInfo<'a> { args.marker_exprs .push(make_uppercase_path(param, "REFERENCES")); } + "RELATION" => { + if let Expr::Lit(syn::ExprLit { + lit: Lit::Str(lit_str), + .. + }) = &*assign.right + { + let name = lit_str.value(); + // Must be a valid Rust identifier for the generated accessor. + if syn::parse_str::(&name).is_err() { + return Err(Error::new_spanned( + lit_str, + format!( + "relation = \"{name}\" must be a valid Rust identifier" + ), + )); + } + args.relation = Some(name); + args.marker_exprs + .push(make_uppercase_path(param, "RELATION")); + } else { + return Err(Error::new_spanned( + &assign.right, + "relation requires a string literal, e.g. relation = \"authored\"", + )); + } + } "ON_DELETE" => { if let Expr::Path(action_path) = &*assign.right && let Some(action_ident) = action_path.path.get_ident() @@ -724,6 +758,7 @@ impl<'a> FieldInfo<'a> { data.marker_exprs.extend(args.marker_exprs); data.on_delete = data.on_delete.or(args.on_delete); data.on_update = data.on_update.or(args.on_update); + data.relation = data.relation.or(args.relation); data.collate = data.collate.or(args.collate); if let Some(Expr::Path(path)) = args.references { @@ -768,6 +803,7 @@ impl<'a> FieldInfo<'a> { data.marker_exprs.extend(args.marker_exprs); data.on_delete = data.on_delete.or(args.on_delete); data.on_update = data.on_update.or(args.on_update); + data.relation = data.relation.or(args.relation); data.collate = data.collate.or(args.collate); if let Some(Expr::Path(path)) = args.references { @@ -794,6 +830,15 @@ impl<'a> FieldInfo<'a> { return Err(Error::new(proc_macro2::Span::call_site(), msg)); } + // Validate: relation requires references + if data.relation.is_some() && data.references_path.is_none() { + let msg = relation_requires_references_message(); + if let Some(marker) = data.marker_exprs.first() { + return Err(Error::new_spanned(marker, msg)); + } + return Err(Error::new(proc_macro2::Span::call_site(), msg)); + } + Ok(data) } @@ -905,6 +950,7 @@ impl<'a> FieldInfo<'a> { is_custom_type, column_type, foreign_key, + relation_name: attrs.relation, constraint: crate::common::Constraint::from_flags( is_primary, is_unique, diff --git a/procmacros/src/sqlite/table/ddl.rs b/procmacros/src/sqlite/table/ddl.rs index c6f536288..cf4d190de 100644 --- a/procmacros/src/sqlite/table/ddl.rs +++ b/procmacros/src/sqlite/table/ddl.rs @@ -798,6 +798,7 @@ mod tests { is_custom_type: false, column_type: SQLiteType::Text, foreign_key: None, + relation_name: None, constraint: Constraint::None, collate: None, default_value: default, diff --git a/procmacros/src/sqlite/table/mod.rs b/procmacros/src/sqlite/table/mod.rs index db34ac1c5..7af2b79ee 100644 --- a/procmacros/src/sqlite/table/mod.rs +++ b/procmacros/src/sqlite/table/mod.rs @@ -214,6 +214,7 @@ pub fn generate_query_api_impls(ctx: &MacroContext) -> TokenStream { target_table_ident: fk.table_ident.clone(), target_column_ident: fk.column_ident.clone(), is_nullable: f.is_nullable, + relation_name: f.relation_name.clone(), }) }) .collect(); diff --git a/sqlite/src/attrs.rs b/sqlite/src/attrs.rs index 154526123..96e95230b 100644 --- a/sqlite/src/attrs.rs +++ b/sqlite/src/attrs.rs @@ -232,6 +232,33 @@ pub const CHECK: ColumnMarker = ColumnMarker; /// See: pub const REFERENCES: ColumnMarker = ColumnMarker; +/// Sets the reverse relation accessor name on the referenced table. +/// +/// By default, reverse relations are named from the source table +/// (`posts` for a `Post` table). When multiple foreign keys target the +/// same table — or the FK is self-referential — the name is disambiguated +/// as `{forward}_{plural}` (e.g. `author_posts`). Use `relation` to pick +/// an explicit reverse name instead. +/// +/// The forward relation (on this table) is unchanged; only the reverse +/// accessor on the referenced table is renamed. +/// +/// ## Example +/// ```rust +/// # let _ = r####" +/// // Users get `.authored()` instead of `.author_posts()` +/// #[column(references = User::id, relation = "authored")] +/// author_id: i32, +/// +/// // Still auto-disambiguated: Users get `.editor_posts()` +/// #[column(references = User::id)] +/// editor_id: Option, +/// # "####; +/// ``` +/// +/// Requires a `references` attribute on the same column. +pub const RELATION: ColumnMarker = ColumnMarker; + /// Specifies the ON DELETE action for foreign key references. /// /// ## Example diff --git a/src/builder/postgres/postgres_sync/mod.rs b/src/builder/postgres/postgres_sync/mod.rs index acd074d45..06c044cd5 100644 --- a/src/builder/postgres/postgres_sync/mod.rs +++ b/src/builder/postgres/postgres_sync/mod.rs @@ -1204,10 +1204,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1215,7 +1214,7 @@ impl<'db, 'a, Schema, T, Rels, Cl> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1265,7 +1264,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1291,10 +1292,9 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1302,7 +1302,7 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1331,16 +1331,15 @@ impl<'db, 'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1393,7 +1392,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1419,16 +1420,15 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1447,10 +1447,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1458,7 +1457,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1500,7 +1499,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1515,10 +1516,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1526,7 +1526,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(client, params)?.into_iter().next()) @@ -1544,16 +1544,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1596,7 +1595,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1611,16 +1612,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(client, params)?.into_iter().next()) diff --git a/src/builder/postgres/tokio_postgres/mod.rs b/src/builder/postgres/tokio_postgres/mod.rs index 778006815..d47d32c39 100644 --- a/src/builder/postgres/tokio_postgres/mod.rs +++ b/src/builder/postgres/tokio_postgres/mod.rs @@ -1278,10 +1278,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1289,7 +1288,7 @@ impl<'db, 'a, Schema, T, Rels, Cl> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1341,7 +1340,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1367,10 +1368,9 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1378,7 +1378,7 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1407,16 +1407,15 @@ impl<'db, 'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1471,7 +1470,9 @@ impl<'db, 'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1497,16 +1498,15 @@ impl<'db, 'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, PostgresValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -1525,10 +1525,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1536,7 +1535,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1578,7 +1577,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1593,10 +1594,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1604,7 +1604,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r Row>, for<'r> <::Select as TryFrom<&'r Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(client, params).await?.into_iter().next()) @@ -1622,16 +1622,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1674,7 +1673,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1689,16 +1690,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, PostgresValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(client, params).await?.into_iter().next()) diff --git a/src/builder/sqlite/libsql/mod.rs b/src/builder/sqlite/libsql/mod.rs index 29b2b2b77..14179b172 100644 --- a/src/builder/sqlite/libsql/mod.rs +++ b/src/builder/sqlite/libsql/mod.rs @@ -691,10 +691,9 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -702,7 +701,7 @@ impl<'a, Schema, T, Rels, Cl> ::Select: for<'r> TryFrom<&'r ::libsql::Row>, for<'r> <::Select as TryFrom<&'r ::libsql::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -761,7 +760,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } drop(raw_rows); @@ -789,10 +790,9 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -800,7 +800,7 @@ impl<'a, Schema, T, Rels, W, Ord> ::Select: for<'r> TryFrom<&'r ::libsql::Row>, for<'r> <::Select as TryFrom<&'r ::libsql::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -829,16 +829,15 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -902,7 +901,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } drop(raw_rows); @@ -930,16 +931,15 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -958,10 +958,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -969,7 +968,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::libsql::Row>, for<'r> <::Select as TryFrom<&'r ::libsql::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1005,7 +1004,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1020,10 +1021,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1031,7 +1031,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::libsql::Row>, for<'r> <::Select as TryFrom<&'r ::libsql::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params).await?.into_iter().next()) @@ -1049,16 +1049,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1097,7 +1096,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1112,16 +1113,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params).await?.into_iter().next()) diff --git a/src/builder/sqlite/rusqlite/mod.rs b/src/builder/sqlite/rusqlite/mod.rs index 411170bb5..1a50c399f 100644 --- a/src/builder/sqlite/rusqlite/mod.rs +++ b/src/builder/sqlite/rusqlite/mod.rs @@ -646,10 +646,9 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -657,7 +656,7 @@ impl<'a, Schema, T, Rels, Cl> ::Select: for<'r> TryFrom<&'r ::rusqlite::Row<'r>>, for<'r> <::Select as TryFrom<&'r ::rusqlite::Row<'r>>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -709,7 +708,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -735,10 +736,9 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -746,7 +746,7 @@ impl<'a, Schema, T, Rels, W, Ord> ::Select: for<'r> TryFrom<&'r ::rusqlite::Row<'r>>, for<'r> <::Select as TryFrom<&'r ::rusqlite::Row<'r>>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -775,16 +775,15 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -839,7 +838,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -865,16 +866,15 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -893,10 +893,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -904,7 +903,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::rusqlite::Row<'r>>, for<'r> <::Select as TryFrom<&'r ::rusqlite::Row<'r>>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -934,7 +933,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -949,10 +950,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -960,7 +960,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::rusqlite::Row<'r>>, for<'r> <::Select as TryFrom<&'r ::rusqlite::Row<'r>>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params)?.into_iter().next()) @@ -978,16 +978,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1018,7 +1017,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1033,16 +1034,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params)?.into_iter().next()) diff --git a/src/builder/sqlite/turso/mod.rs b/src/builder/sqlite/turso/mod.rs index cda51c272..bd6bc61c5 100644 --- a/src/builder/sqlite/turso/mod.rs +++ b/src/builder/sqlite/turso/mod.rs @@ -724,10 +724,9 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -735,7 +734,7 @@ impl<'a, Schema, T, Rels, Cl> ::Select: for<'r> TryFrom<&'r ::turso::Row>, for<'r> <::Select as TryFrom<&'r ::turso::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -788,7 +787,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -814,10 +815,9 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -825,7 +825,7 @@ impl<'a, Schema, T, Rels, W, Ord> ::Select: for<'r> TryFrom<&'r ::turso::Row>, for<'r> <::Select as TryFrom<&'r ::turso::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::Select> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -854,16 +854,15 @@ impl<'a, Schema, T, Rels, Cl> self, ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -919,7 +918,9 @@ impl<'a, Schema, T, Rels, Cl> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -945,16 +946,15 @@ impl<'a, Schema, T, Rels, W, Ord> self, ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore + Rels: drizzle_core::query::BuildRow<::PartialSelect> + drizzle_core::query::RenderRelations<'a, SQLiteValue<'a>>, ::Store: drizzle_core::query::DeserializeStore, { @@ -973,10 +973,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -984,7 +983,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::turso::Row>, for<'r> <::Select as TryFrom<&'r ::turso::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1017,7 +1016,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1032,10 +1033,9 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::Select, - ::Store, - >, + >>::Row, >, > where @@ -1043,7 +1043,7 @@ impl<'a, T, Rels> ::Select: for<'r> TryFrom<&'r ::turso::Row>, for<'r> <::Select as TryFrom<&'r ::turso::Row>>::Error: Into, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::Select>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params).await?.into_iter().next()) @@ -1061,16 +1061,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Vec< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { debug_assert_eq!( @@ -1106,7 +1105,9 @@ impl<'a, T, Rels> let store = ::Store::from_json_columns(&mut next_rel)?; - results.push(drizzle_core::query::QueryRow::new(base, store)); + results.push(>::assemble( + base, store, + )); } Ok(results) @@ -1121,16 +1122,15 @@ impl<'a, T, Rels> params: [drizzle_core::param::ParamBind<'a, SQLiteValue<'a>>; N], ) -> drizzle_core::error::Result< Option< - drizzle_core::query::QueryRow< + ::PartialSelect, - ::Store, - >, + >>::Row, >, > where T: drizzle_core::query::QueryTable, ::PartialSelect: drizzle_core::query::FromJsonObject, - Rels: drizzle_core::query::BuildStore, + Rels: drizzle_core::query::BuildRow<::PartialSelect>, ::Store: drizzle_core::query::DeserializeStore, { Ok(self.find_many(conn, params).await?.into_iter().next()) diff --git a/tests/postgres/query.rs b/tests/postgres/query.rs index 69c7662e7..4dfb8b9ca 100644 --- a/tests/postgres/query.rs +++ b/tests/postgres/query.rs @@ -14,16 +14,7 @@ use drizzle::core::{asc, desc}; use drizzle::postgres::prelude::*; use uuid::Uuid; -// Import generated query result accessor traits from the common schema. -// Relation accessors (e.g., users.posts()) and column selectors are now -// inherent methods — no import needed. Only QueryAccess traits for result -// field access still require imports. -#[allow(unused_imports)] -use crate::common::schema::postgres::{ - ComplexId, ComplexWithInvitedBy, ComplexWithPosts, QueryCategoryViaPostCategoryPosts, - QueryCommentReplies, QueryComplexInvitedBy, QueryComplexPosts, QueryPostAuthor, - QueryPostComments, QueryPostViaPostCategoryCategories, -}; +use crate::common::schema::postgres::{ComplexId, ComplexWithInvitedBy, ComplexWithPosts}; // ============================================================================= // Schemas for different test scenarios @@ -153,14 +144,14 @@ fn query_reverse_relation_many(db: &mut TestDb) { // Alice has 2 posts let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert_eq!(alice.posts().len(), 2); - assert_eq!(alice.posts()[0].title, "Alice Post 1"); - assert_eq!(alice.posts()[1].title, "Alice Post 2"); + assert_eq!(alice.posts.len(), 2); + assert_eq!(alice.posts[0].title, "Alice Post 1"); + assert_eq!(alice.posts[1].title, "Alice Post 2"); // Bob has 1 post let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert_eq!(bob.posts().len(), 1); - assert_eq!(bob.posts()[0].title, "Bob Post 1"); + assert_eq!(bob.posts.len(), 1); + assert_eq!(bob.posts[0].title, "Bob Post 1"); } // -- Forward relation: Post -> Author (OptionalOne since author_id is nullable) -- @@ -184,7 +175,7 @@ fn query_forward_relation_one(db: &mut TestDb) { assert_eq!(posts.len(), 1); assert_eq!(posts[0].title, "Hello World"); - assert_eq!(posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(posts[0].author.as_ref().unwrap().name, "Alice"); } // -- Forward relation: OptionalOne (nullable FK, self-referential) -- @@ -212,12 +203,12 @@ fn query_forward_optional_one(db: &mut TestDb) { // Alice has no inviter let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert!(alice.invited_by().is_none()); + assert!(alice.invited_by.is_none()); // Bob was invited by Alice let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert!(bob.invited_by().is_some()); - assert_eq!(bob.invited_by().as_ref().unwrap().name, "Alice"); + assert!(bob.invited_by.is_some()); + assert_eq!(bob.invited_by.as_ref().unwrap().name, "Alice"); } // -- Nested relations: Complex -> Posts -> Comments -- @@ -271,15 +262,15 @@ fn query_nested_relations(db: &mut TestDb) { assert_eq!(users.len(), 1); let alice = &users[0]; assert_eq!(alice.name, "Alice"); - assert_eq!(alice.posts().len(), 2); + assert_eq!(alice.posts.len(), 2); // Find post 1 and check its comments - let p1 = alice.posts().iter().find(|p| p.title == "Post 1").unwrap(); - assert_eq!(p1.comments().len(), 2); + let p1 = alice.posts.iter().find(|p| p.title == "Post 1").unwrap(); + assert_eq!(p1.comments.len(), 2); - let p2 = alice.posts().iter().find(|p| p.title == "Post 2").unwrap(); - assert_eq!(p2.comments().len(), 1); - assert_eq!(p2.comments()[0].body, "Comment on P2"); + let p2 = alice.posts.iter().find(|p| p.title == "Post 2").unwrap(); + assert_eq!(p2.comments.len(), 1); + assert_eq!(p2.comments[0].body, "Comment on P2"); } // -- Multiple relations: Complex with posts AND invited_by -- @@ -322,15 +313,15 @@ fn query_multiple_relations(db: &mut TestDb) { // Bob has 1 post and was invited by Alice let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert_eq!(bob.posts().len(), 1); - assert_eq!(bob.posts()[0].title, "Bob's Post"); - assert!(bob.invited_by().is_some()); - assert_eq!(bob.invited_by().as_ref().unwrap().name, "Alice"); + assert_eq!(bob.posts.len(), 1); + assert_eq!(bob.posts[0].title, "Bob's Post"); + assert!(bob.invited_by.is_some()); + assert_eq!(bob.invited_by.as_ref().unwrap().name, "Alice"); // Alice has no posts and no inviter let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert_eq!(alice.posts().len(), 0); - assert!(alice.invited_by().is_none()); + assert_eq!(alice.posts.len(), 0); + assert!(alice.invited_by.is_none()); } // -- Empty relation (Many with no rows) -- @@ -345,7 +336,7 @@ fn query_empty_many_relation(db: &mut TestDb) { let users = db.query(complex).with(complex.posts()).find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 0); + assert_eq!(users[0].posts.len(), 0); } // -- Typed WHERE on root query (tests $N placeholder renumbering) -- @@ -427,7 +418,7 @@ fn query_relation_where_typed(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); } // -- Typed ORDER BY + LIMIT on relation subquery -- @@ -457,9 +448,9 @@ fn query_relation_order_limit_typed(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "Post C"); - assert_eq!(users[0].posts()[1].title, "Post B"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "Post C"); + assert_eq!(users[0].posts[1].title, "Post B"); } // -- Forward relation with NULL FK -- @@ -493,9 +484,9 @@ fn query_forward_relation_null_fk(db: &mut TestDb) { assert_eq!(posts.len(), 2); // "No Author" comes first alphabetically - assert!(posts[0].author().is_none()); - assert!(posts[1].author().is_some()); - assert_eq!(posts[1].author().as_ref().unwrap().name, "Alice"); + assert!(posts[0].author.is_none()); + assert!(posts[1].author.is_some()); + assert_eq!(posts[1].author.as_ref().unwrap().name, "Alice"); } // -- Combined root WHERE + relation WHERE (tests param ordering) -- @@ -531,8 +522,8 @@ fn query_root_and_relation_where_combined(db: &mut TestDb) { assert_eq!(posts.len(), 2); assert_eq!(posts[0].title, "Alice's Post"); - assert_eq!(posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(posts[0].author.as_ref().unwrap().name, "Alice"); assert_eq!(posts[1].title, "Bob's Post"); - assert_eq!(posts[1].author().as_ref().unwrap().name, "Bob"); + assert_eq!(posts[1].author.as_ref().unwrap().name, "Bob"); } // -- Combo: query regular tables and views in the same schema -- @@ -759,9 +750,9 @@ fn query_combo_tables_and_views(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 2); assert_eq!(users[0].name, "Alice"); - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); assert_eq!(users[1].name, "Bob"); - assert_eq!(users[1].posts().len(), 1); + assert_eq!(users[1].posts.len(), 1); // 2) Query view with relations from the same schema let view_posts = db @@ -771,9 +762,9 @@ fn query_combo_tables_and_views(db: &mut TestDb) { .find_many(); assert_eq!(view_posts.len(), 3); assert_eq!(view_posts[0].title, "Post A"); - assert_eq!(view_posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(view_posts[0].author.as_ref().unwrap().name, "Alice"); assert_eq!(view_posts[2].title, "Post C"); - assert_eq!(view_posts[2].author().as_ref().unwrap().name, "Bob"); + assert_eq!(view_posts[2].author.as_ref().unwrap().name, "Bob"); // 3) Query view standalone (no relations) let view_first = db @@ -911,8 +902,8 @@ fn query_deep_nested_complex(db: &mut TestDb) { assert_eq!(users[3].name, "Dave"); // -- Alice: no inviter, 4 posts but LIMIT 3 -- - assert!(users[0].invited_by().is_none()); - let alice_posts = users[0].posts(); + assert!(users[0].invited_by.is_none()); + let alice_posts = &users[0].posts; // LIMIT 3, ordered by title DESC: "Update", "Thoughts", "Draft" (Announcement excluded) assert_eq!(alice_posts.len(), 3); assert_eq!(alice_posts[0].title, "Alice Update"); @@ -920,75 +911,64 @@ fn query_deep_nested_complex(db: &mut TestDb) { assert_eq!(alice_posts[2].title, "Alice Draft"); // Alice Update: 0 comments - assert_eq!(alice_posts[0].comments().len(), 0); + assert_eq!(alice_posts[0].comments.len(), 0); // Alice Thoughts: 1 comment, no replies - assert_eq!(alice_posts[1].comments().len(), 1); - assert_eq!(alice_posts[1].comments()[0].body, "Interesting thoughts"); - assert_eq!(alice_posts[1].comments()[0].replies().len(), 0); + assert_eq!(alice_posts[1].comments.len(), 1); + assert_eq!(alice_posts[1].comments[0].body, "Interesting thoughts"); + assert_eq!(alice_posts[1].comments[0].replies.len(), 0); // Alice Draft: 3 comments ordered by body ASC - let draft_comments = alice_posts[2].comments(); + let draft_comments = &alice_posts[2].comments; assert_eq!(draft_comments.len(), 3); assert_eq!(draft_comments[0].body, "Great draft!"); assert_eq!(draft_comments[1].body, "Love this"); assert_eq!(draft_comments[2].body, "Needs work"); // "Great draft!" has 1 reply - assert_eq!(draft_comments[0].replies().len(), 1); - assert_eq!(draft_comments[0].replies()[0].text, "Thanks!"); + assert_eq!(draft_comments[0].replies.len(), 1); + assert_eq!(draft_comments[0].replies[0].text, "Thanks!"); // "Love this" has 0 replies - assert_eq!(draft_comments[1].replies().len(), 0); + assert_eq!(draft_comments[1].replies.len(), 0); // "Needs work" has 1 reply - assert_eq!(draft_comments[2].replies().len(), 1); - assert_eq!(draft_comments[2].replies()[0].text, "Will revise"); + assert_eq!(draft_comments[2].replies.len(), 1); + assert_eq!(draft_comments[2].replies[0].text, "Will revise"); // -- Bob: invited by Alice, 1 post with 1 comment with 1 reply -- - assert!(users[1].invited_by().is_some()); - assert_eq!(users[1].invited_by().as_ref().unwrap().name, "Alice"); - assert_eq!(users[1].posts().len(), 1); - assert_eq!(users[1].posts()[0].title, "Bob First Post"); - assert_eq!(users[1].posts()[0].comments().len(), 1); - assert_eq!(users[1].posts()[0].comments()[0].body, "Welcome Bob!"); - assert_eq!(users[1].posts()[0].comments()[0].replies().len(), 1); + assert!(users[1].invited_by.is_some()); + assert_eq!(users[1].invited_by.as_ref().unwrap().name, "Alice"); + assert_eq!(users[1].posts.len(), 1); + assert_eq!(users[1].posts[0].title, "Bob First Post"); + assert_eq!(users[1].posts[0].comments.len(), 1); + assert_eq!(users[1].posts[0].comments[0].body, "Welcome Bob!"); + assert_eq!(users[1].posts[0].comments[0].replies.len(), 1); assert_eq!( - users[1].posts()[0].comments()[0].replies()[0].text, + users[1].posts[0].comments[0].replies[0].text, "Glad to be here" ); // -- Charlie: invited by Alice, no posts -- - assert!(users[2].invited_by().is_some()); - assert_eq!(users[2].invited_by().as_ref().unwrap().name, "Alice"); - assert_eq!(users[2].posts().len(), 0); + assert!(users[2].invited_by.is_some()); + assert_eq!(users[2].invited_by.as_ref().unwrap().name, "Alice"); + assert_eq!(users[2].posts.len(), 0); // -- Dave: no inviter, no posts -- - assert!(users[3].invited_by().is_none()); - assert_eq!(users[3].posts().len(), 0); + assert!(users[3].invited_by.is_none()); + assert_eq!(users[3].posts.len(), 0); } // ============================================================================= // Type alias ergonomics // ============================================================================= -// Verify generated type aliases work in function signatures. -// The query API generates aliases like `ComplexWithPosts` so users -// can write clean function signatures instead of spelling out RelEntry<__Rel_...>. -use drizzle::core::query::QueryRow; - -// Single relation: Complex with posts loaded -type ComplexWithPostsRow = QueryRow; - -// Composed relations: Complex with invited_by AND posts loaded. -// The Rest parameter chains them: `ComplexWithInvitedBy` -// means "store has invited_by first, then posts". -// Note: order must match the .with() call order (last .with() is outermost). -type ComplexWithPostsAndInviter = QueryRow>; +type ComplexWithPostsRow = ComplexWithPosts; +type ComplexWithPostsAndInviter = ComplexWithInvitedBy; fn count_posts(user: &ComplexWithPostsRow) -> usize { - user.posts().len() + user.posts.len() } fn get_inviter_name(user: &ComplexWithPostsAndInviter) -> Option<&str> { - user.invited_by().as_ref().map(|u| u.name.as_str()) + user.invited_by.as_ref().map(|u| u.name.as_str()) } #[drizzle::test] @@ -1017,7 +997,6 @@ fn query_type_alias_in_fn_signature(db: &mut TestDb) { ]) .execute(); - // Use type alias with single relation let users: Vec = db.query(complex).with(complex.posts()).find_many(); let alice = users.iter().find(|u| u.name == "Alice").unwrap(); @@ -1026,9 +1005,6 @@ fn query_type_alias_in_fn_signature(db: &mut TestDb) { let bob = users.iter().find(|u| u.name == "Bob").unwrap(); assert_eq!(count_posts(bob), 1); - // Use type alias with composed relations - // .with() order: posts first, then invited_by - // Type order: InvitedBy (last .with() is outermost in the store) let users: Vec = db .query(complex) .with(complex.posts()) @@ -1101,9 +1077,9 @@ fn query_relation_limit_offset(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "BBB"); - assert_eq!(users[0].posts()[1].title, "CCC"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "BBB"); + assert_eq!(users[0].posts[1].title, "CCC"); } #[drizzle::test] @@ -1150,9 +1126,9 @@ fn query_prepare_with_placeholders(db: &mut TestDb) { assert_eq!(users.len(), 1); assert_eq!(users[0].name, "Alice"); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "AAA"); - assert_eq!(users[0].posts()[1].title, "BBB"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "AAA"); + assert_eq!(users[0].posts[1].title, "BBB"); } // ============================================================================= @@ -1239,9 +1215,9 @@ fn query_columns_with_relations(db: &mut TestDb) { assert_eq!(users.len(), 1); assert_eq!(users[0].name.as_deref(), Some("Alice")); assert!(users[0].invited_by.is_none()); // not selected - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); // Relations are full SelectModel (not partial) - assert_eq!(users[0].posts()[0].title, "Post 1"); + assert_eq!(users[0].posts[0].title, "Post 1"); } // -- Partial columns on a relation -- @@ -1273,11 +1249,11 @@ fn query_relation_columns(db: &mut TestDb) { // Base is full SelectModel assert_eq!(users[0].name, "Alice"); // Relation is PartialSelectModel - assert_eq!(users[0].posts().len(), 2); - assert!(users[0].posts()[0].id.is_some()); - assert_eq!(users[0].posts()[0].title.as_deref(), Some("Post 1")); + assert_eq!(users[0].posts.len(), 2); + assert!(users[0].posts[0].id.is_some()); + assert_eq!(users[0].posts[0].title.as_deref(), Some("Post 1")); // author_id not selected - assert!(users[0].posts()[0].author_id.is_none()); + assert!(users[0].posts[0].author_id.is_none()); } // -- find_first with partial columns -- @@ -1327,7 +1303,7 @@ fn query_first_limits_to_one(db: &mut TestDb) { let users = db.query(complex).with(complex.posts().first()).find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 1); + assert_eq!(users[0].posts.len(), 1); } // ============================================================================= @@ -1343,7 +1319,7 @@ struct M2MQuerySchema { post_category: PostCategory, } -// -- basic m2m: post.categories() returns categories through junction -- +// -- basic m2m: post.categories returns categories through junction -- #[drizzle::test] fn query_many_to_many_basic(db: &mut TestDb) { let M2MQuerySchema { @@ -1391,9 +1367,9 @@ fn query_many_to_many_basic(db: &mut TestDb) { assert_eq!(posts.len(), 1); assert_eq!(posts[0].title, "My Post"); - assert_eq!(posts[0].categories().len(), 2); + assert_eq!(posts[0].categories.len(), 2); let cat_names: Vec<&str> = posts[0] - .categories() + .categories .iter() .map(|c| c.name.as_str()) .collect(); @@ -1452,8 +1428,8 @@ fn query_many_to_many_reverse(db: &mut TestDb) { assert_eq!(cats.len(), 1); assert_eq!(cats[0].name, "Tech"); - assert_eq!(cats[0].posts().len(), 2); - let post_titles: Vec<&str> = cats[0].posts().iter().map(|p| p.title.as_str()).collect(); + assert_eq!(cats[0].posts.len(), 2); + let post_titles: Vec<&str> = cats[0].posts.iter().map(|p| p.title.as_str()).collect(); assert!(post_titles.contains(&"Post A")); assert!(post_titles.contains(&"Post B")); } @@ -1478,7 +1454,7 @@ fn query_many_to_many_empty(db: &mut TestDb) { let posts = db.query(post).with(post.categories()).find_many(); assert_eq!(posts.len(), 1); - assert_eq!(posts[0].categories().len(), 0); + assert_eq!(posts[0].categories.len(), 0); } // -- m2m with limit -- @@ -1530,5 +1506,104 @@ fn query_many_to_many_with_limit(db: &mut TestDb) { let posts = db.query(post).with(post.categories().limit(2)).find_many(); assert_eq!(posts.len(), 1); - assert_eq!(posts[0].categories().len(), 2); + assert_eq!(posts[0].categories.len(), 2); +} + +// ============================================================================= +// Multi-FK reverse relations + `relation = "..."` override +// ============================================================================= + +#[PostgresTable(NAME = "articles")] +struct Article { + #[column(PRIMARY, DEFAULT_FN = Uuid::new_v4)] + id: Uuid, + title: String, + /// Reverse on Complex: `.authored()` via explicit relation name. + #[column(REFERENCES = Complex::id, RELATION = "authored")] + author_id: Uuid, + /// Reverse on Complex: `.editor_articles()` via auto-disambiguation. + #[column(REFERENCES = Complex::id)] + editor_id: Option, +} + +#[derive(PostgresSchema)] +struct MultiFkQuerySchema { + role: Role, + complex: Complex, + article: Article, +} + +// -- Multi-FK: relation override + auto-disambiguated reverse -- +#[drizzle::test] +fn query_multi_fk_reverse_disambiguated(db: &mut TestDb) { + let MultiFkQuerySchema { + complex, article, .. + } = schema; + + db.insert(complex) + .values([ + InsertComplex::new("Alice", true, Role::User), + InsertComplex::new("Bob", true, Role::User), + ]) + .execute(); + + let all_users: Vec = db.select(()).from(complex).all(); + let alice_id = all_users.iter().find(|u| u.name == "Alice").unwrap().id; + let bob_id = all_users.iter().find(|u| u.name == "Bob").unwrap().id; + + db.insert(article) + .values([InsertArticle::new("Draft A", alice_id).with_editor_id(bob_id)]) + .execute(); + db.insert(article) + .values([InsertArticle::new("Draft B", bob_id)]) + .execute(); + + // `relation = "authored"` → Complex.authored() + let authors = db.query(complex).with(complex.authored()).find_many(); + let alice = authors.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice.authored.len(), 1); + assert_eq!(alice.authored[0].title, "Draft A"); + let bob = authors.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob.authored.len(), 1); + assert_eq!(bob.authored[0].title, "Draft B"); + + // Auto-disambiguated: Complex.editor_articles() + let editors = db + .query(complex) + .with(complex.editor_articles()) + .find_many(); + let bob_ed = editors.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob_ed.editor_articles.len(), 1); + assert_eq!(bob_ed.editor_articles[0].title, "Draft A"); + let alice_ed = editors.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice_ed.editor_articles.len(), 0); +} + +// -- Self-referential reverse: Complex.invited_by_complexes() -- +#[drizzle::test] +fn query_self_ref_reverse_disambiguated(db: &mut TestDb) { + let ComplexPostQuerySchema { complex, .. } = schema; + + db.insert(complex) + .values([InsertComplex::new("Alice", true, Role::User)]) + .execute(); + let all_users: Vec = db.select(()).from(complex).all(); + let alice_id = all_users[0].id; + + db.insert(complex) + .values([ + InsertComplex::new("Bob", true, Role::User).with_invited_by(alice_id), + InsertComplex::new("Charlie", true, Role::User).with_invited_by(alice_id), + ]) + .execute(); + + let users = db + .query(complex) + .with(complex.invited_by_complexes()) + .find_many(); + + let alice = users.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice.invited_by_complexes.len(), 2); + let bob = users.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob.invited_by_complexes.len(), 0); } diff --git a/tests/sqlite/query.rs b/tests/sqlite/query.rs index 35207c3f3..f1442a7be 100644 --- a/tests/sqlite/query.rs +++ b/tests/sqlite/query.rs @@ -15,16 +15,7 @@ use crate::common::schema::sqlite::{ SelectComment, SelectComplex, SelectPost, }; -// Import generated query result accessor traits from the common schema. -// Relation accessors (e.g., users.posts()) and column selectors are now -// inherent methods — no import needed. Only QueryAccess traits for result -// field access still require imports. -#[allow(unused_imports)] -use crate::common::schema::sqlite::{ - ComplexId, ComplexWithInvitedBy, ComplexWithPosts, QueryCategoryViaPostCategoryPosts, - QueryCommentReplies, QueryComplexInvitedBy, QueryComplexPosts, QueryPostAuthor, - QueryPostComments, QueryPostViaPostCategoryCategories, -}; +use crate::common::schema::sqlite::{ComplexId, ComplexWithInvitedBy, ComplexWithPosts}; // ============================================================================= // Schemas for different test scenarios @@ -153,14 +144,14 @@ fn query_reverse_relation_many(db: &mut TestDb) { // Alice has 2 posts let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert_eq!(alice.posts().len(), 2); - assert_eq!(alice.posts()[0].title, "Alice Post 1"); - assert_eq!(alice.posts()[1].title, "Alice Post 2"); + assert_eq!(alice.posts.len(), 2); + assert_eq!(alice.posts[0].title, "Alice Post 1"); + assert_eq!(alice.posts[1].title, "Alice Post 2"); // Bob has 1 post let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert_eq!(bob.posts().len(), 1); - assert_eq!(bob.posts()[0].title, "Bob Post 1"); + assert_eq!(bob.posts.len(), 1); + assert_eq!(bob.posts[0].title, "Bob Post 1"); } // -- Forward relation: Post -> Author (OptionalOne) -- @@ -184,7 +175,7 @@ fn query_forward_relation_one(db: &mut TestDb) { assert_eq!(posts.len(), 1); assert_eq!(posts[0].title, "Hello World"); - assert_eq!(posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(posts[0].author.as_ref().unwrap().name, "Alice"); } // -- Forward relation: OptionalOne (nullable FK, self-referential) -- @@ -212,12 +203,12 @@ fn query_forward_optional_one(db: &mut TestDb) { // Alice has no inviter let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert!(alice.invited_by().is_none()); + assert!(alice.invited_by.is_none()); // Bob was invited by Alice let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert!(bob.invited_by().is_some()); - assert_eq!(bob.invited_by().as_ref().unwrap().name, "Alice"); + assert!(bob.invited_by.is_some()); + assert_eq!(bob.invited_by.as_ref().unwrap().name, "Alice"); } // -- Nested relations: Complex -> Posts -> Comments -- @@ -270,15 +261,15 @@ fn query_nested_relations(db: &mut TestDb) { assert_eq!(users.len(), 1); let alice = &users[0]; assert_eq!(alice.name, "Alice"); - assert_eq!(alice.posts().len(), 2); + assert_eq!(alice.posts.len(), 2); // Find post 1 and check its comments - let p1 = alice.posts().iter().find(|p| p.title == "Post 1").unwrap(); - assert_eq!(p1.comments().len(), 2); + let p1 = alice.posts.iter().find(|p| p.title == "Post 1").unwrap(); + assert_eq!(p1.comments.len(), 2); - let p2 = alice.posts().iter().find(|p| p.title == "Post 2").unwrap(); - assert_eq!(p2.comments().len(), 1); - assert_eq!(p2.comments()[0].body, "Comment on P2"); + let p2 = alice.posts.iter().find(|p| p.title == "Post 2").unwrap(); + assert_eq!(p2.comments.len(), 1); + assert_eq!(p2.comments[0].body, "Comment on P2"); } // -- Multiple relations: Complex with posts AND invited_by -- @@ -321,15 +312,15 @@ fn query_multiple_relations(db: &mut TestDb) { // Bob has 1 post and was invited by Alice let bob = users.iter().find(|u| u.name == "Bob").unwrap(); - assert_eq!(bob.posts().len(), 1); - assert_eq!(bob.posts()[0].title, "Bob's Post"); - assert!(bob.invited_by().is_some()); - assert_eq!(bob.invited_by().as_ref().unwrap().name, "Alice"); + assert_eq!(bob.posts.len(), 1); + assert_eq!(bob.posts[0].title, "Bob's Post"); + assert!(bob.invited_by.is_some()); + assert_eq!(bob.invited_by.as_ref().unwrap().name, "Alice"); // Alice has no posts and no inviter let alice = users.iter().find(|u| u.name == "Alice").unwrap(); - assert_eq!(alice.posts().len(), 0); - assert!(alice.invited_by().is_none()); + assert_eq!(alice.posts.len(), 0); + assert!(alice.invited_by.is_none()); } // -- Empty relation (Many with no rows) -- @@ -344,7 +335,7 @@ fn query_empty_many_relation(db: &mut TestDb) { let users = db.query(complex).with(complex.posts()).find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 0); + assert_eq!(users[0].posts.len(), 0); } // -- Typed WHERE on root query -- @@ -425,7 +416,7 @@ fn query_relation_where_typed(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); } // -- Typed ORDER BY + LIMIT on relation subquery -- @@ -455,9 +446,9 @@ fn query_relation_order_limit_typed(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "Post C"); - assert_eq!(users[0].posts()[1].title, "Post B"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "Post C"); + assert_eq!(users[0].posts[1].title, "Post B"); } // -- Forward relation with NULL FK -- @@ -491,9 +482,9 @@ fn query_forward_relation_null_fk(db: &mut TestDb) { assert_eq!(posts.len(), 2); // "No Author" comes first alphabetically - assert!(posts[0].author().is_none()); - assert!(posts[1].author().is_some()); - assert_eq!(posts[1].author().as_ref().unwrap().name, "Alice"); + assert!(posts[0].author.is_none()); + assert!(posts[1].author.is_some()); + assert_eq!(posts[1].author.as_ref().unwrap().name, "Alice"); } // -- Combined root WHERE + relation WHERE (tests param ordering) -- @@ -529,8 +520,8 @@ fn query_root_and_relation_where_combined(db: &mut TestDb) { assert_eq!(posts.len(), 2); assert_eq!(posts[0].title, "Alice's Post"); - assert_eq!(posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(posts[0].author.as_ref().unwrap().name, "Alice"); assert_eq!(posts[1].title, "Bob's Post"); - assert_eq!(posts[1].author().as_ref().unwrap().name, "Bob"); + assert_eq!(posts[1].author.as_ref().unwrap().name, "Bob"); } // -- Combo: query regular tables and views in the same schema -- @@ -750,9 +741,9 @@ fn query_combo_tables_and_views(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 2); assert_eq!(users[0].name, "Alice"); - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); assert_eq!(users[1].name, "Bob"); - assert_eq!(users[1].posts().len(), 1); + assert_eq!(users[1].posts.len(), 1); // 2) Query view with relations from the same schema let view_posts = db @@ -762,9 +753,9 @@ fn query_combo_tables_and_views(db: &mut TestDb) { .find_many(); assert_eq!(view_posts.len(), 3); assert_eq!(view_posts[0].title, "Post A"); - assert_eq!(view_posts[0].author().as_ref().unwrap().name, "Alice"); + assert_eq!(view_posts[0].author.as_ref().unwrap().name, "Alice"); assert_eq!(view_posts[2].title, "Post C"); - assert_eq!(view_posts[2].author().as_ref().unwrap().name, "Bob"); + assert_eq!(view_posts[2].author.as_ref().unwrap().name, "Bob"); // 3) Query view standalone (no relations) let view_first = db @@ -901,8 +892,8 @@ fn query_deep_nested_complex(db: &mut TestDb) { assert_eq!(users[3].name, "Dave"); // -- Alice: no inviter, 4 posts but LIMIT 3 -- - assert!(users[0].invited_by().is_none()); - let alice_posts = users[0].posts(); + assert!(users[0].invited_by.is_none()); + let alice_posts = &users[0].posts; // LIMIT 3, ordered by title DESC: "Update", "Thoughts", "Draft" (Announcement excluded) assert_eq!(alice_posts.len(), 3); assert_eq!(alice_posts[0].title, "Alice Update"); @@ -910,75 +901,64 @@ fn query_deep_nested_complex(db: &mut TestDb) { assert_eq!(alice_posts[2].title, "Alice Draft"); // Alice Update: 0 comments - assert_eq!(alice_posts[0].comments().len(), 0); + assert_eq!(alice_posts[0].comments.len(), 0); // Alice Thoughts: 1 comment, no replies - assert_eq!(alice_posts[1].comments().len(), 1); - assert_eq!(alice_posts[1].comments()[0].body, "Interesting thoughts"); - assert_eq!(alice_posts[1].comments()[0].replies().len(), 0); + assert_eq!(alice_posts[1].comments.len(), 1); + assert_eq!(alice_posts[1].comments[0].body, "Interesting thoughts"); + assert_eq!(alice_posts[1].comments[0].replies.len(), 0); // Alice Draft: 3 comments ordered by body ASC - let draft_comments = alice_posts[2].comments(); + let draft_comments = &alice_posts[2].comments; assert_eq!(draft_comments.len(), 3); assert_eq!(draft_comments[0].body, "Great draft!"); assert_eq!(draft_comments[1].body, "Love this"); assert_eq!(draft_comments[2].body, "Needs work"); // "Great draft!" has 1 reply - assert_eq!(draft_comments[0].replies().len(), 1); - assert_eq!(draft_comments[0].replies()[0].text, "Thanks!"); + assert_eq!(draft_comments[0].replies.len(), 1); + assert_eq!(draft_comments[0].replies[0].text, "Thanks!"); // "Love this" has 0 replies - assert_eq!(draft_comments[1].replies().len(), 0); + assert_eq!(draft_comments[1].replies.len(), 0); // "Needs work" has 1 reply - assert_eq!(draft_comments[2].replies().len(), 1); - assert_eq!(draft_comments[2].replies()[0].text, "Will revise"); + assert_eq!(draft_comments[2].replies.len(), 1); + assert_eq!(draft_comments[2].replies[0].text, "Will revise"); // -- Bob: invited by Alice, 1 post with 1 comment with 1 reply -- - assert!(users[1].invited_by().is_some()); - assert_eq!(users[1].invited_by().as_ref().unwrap().name, "Alice"); - assert_eq!(users[1].posts().len(), 1); - assert_eq!(users[1].posts()[0].title, "Bob First Post"); - assert_eq!(users[1].posts()[0].comments().len(), 1); - assert_eq!(users[1].posts()[0].comments()[0].body, "Welcome Bob!"); - assert_eq!(users[1].posts()[0].comments()[0].replies().len(), 1); + assert!(users[1].invited_by.is_some()); + assert_eq!(users[1].invited_by.as_ref().unwrap().name, "Alice"); + assert_eq!(users[1].posts.len(), 1); + assert_eq!(users[1].posts[0].title, "Bob First Post"); + assert_eq!(users[1].posts[0].comments.len(), 1); + assert_eq!(users[1].posts[0].comments[0].body, "Welcome Bob!"); + assert_eq!(users[1].posts[0].comments[0].replies.len(), 1); assert_eq!( - users[1].posts()[0].comments()[0].replies()[0].text, + users[1].posts[0].comments[0].replies[0].text, "Glad to be here" ); // -- Charlie: invited by Alice, no posts -- - assert!(users[2].invited_by().is_some()); - assert_eq!(users[2].invited_by().as_ref().unwrap().name, "Alice"); - assert_eq!(users[2].posts().len(), 0); + assert!(users[2].invited_by.is_some()); + assert_eq!(users[2].invited_by.as_ref().unwrap().name, "Alice"); + assert_eq!(users[2].posts.len(), 0); // -- Dave: no inviter, no posts -- - assert!(users[3].invited_by().is_none()); - assert_eq!(users[3].posts().len(), 0); + assert!(users[3].invited_by.is_none()); + assert_eq!(users[3].posts.len(), 0); } // ============================================================================= // Type alias ergonomics // ============================================================================= -// Verify generated type aliases work in function signatures. -// The query API generates aliases like `ComplexWithPosts` so users -// can write clean function signatures instead of spelling out RelEntry<__Rel_...>. -use drizzle::core::query::QueryRow; - -// Single relation: Complex with posts loaded -type ComplexWithPostsRow = QueryRow; - -// Composed relations: Complex with invited_by AND posts loaded. -// The Rest parameter chains them: `ComplexWithInvitedBy` -// means "store has invited_by first, then posts". -// Note: order must match the .with() call order (last .with() is outermost). -type ComplexWithPostsAndInviter = QueryRow>; +type ComplexWithPostsRow = ComplexWithPosts; +type ComplexWithPostsAndInviter = ComplexWithInvitedBy; fn count_posts(user: &ComplexWithPostsRow) -> usize { - user.posts().len() + user.posts.len() } fn get_inviter_name(user: &ComplexWithPostsAndInviter) -> Option<&str> { - user.invited_by().as_ref().map(|u| u.name.as_str()) + user.invited_by.as_ref().map(|u| u.name.as_str()) } #[drizzle::test] @@ -1007,7 +987,6 @@ fn query_type_alias_in_fn_signature(db: &mut TestDb) { ]) .execute(); - // Use type alias with single relation let users: Vec = db.query(complex).with(complex.posts()).find_many(); let alice = users.iter().find(|u| u.name == "Alice").unwrap(); @@ -1016,9 +995,6 @@ fn query_type_alias_in_fn_signature(db: &mut TestDb) { let bob = users.iter().find(|u| u.name == "Bob").unwrap(); assert_eq!(count_posts(bob), 1); - // Use type alias with composed relations - // .with() order: posts first, then invited_by - // Type order: InvitedBy (last .with() is outermost in the store) let users: Vec = db .query(complex) .with(complex.posts()) @@ -1091,9 +1067,9 @@ fn query_relation_limit_offset(db: &mut TestDb) { .find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "BBB"); - assert_eq!(users[0].posts()[1].title, "CCC"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "BBB"); + assert_eq!(users[0].posts[1].title, "CCC"); } #[drizzle::test] @@ -1140,9 +1116,9 @@ fn query_prepare_with_placeholders(db: &mut TestDb) { assert_eq!(users.len(), 1); assert_eq!(users[0].name, "Alice"); - assert_eq!(users[0].posts().len(), 2); - assert_eq!(users[0].posts()[0].title, "AAA"); - assert_eq!(users[0].posts()[1].title, "BBB"); + assert_eq!(users[0].posts.len(), 2); + assert_eq!(users[0].posts[0].title, "AAA"); + assert_eq!(users[0].posts[1].title, "BBB"); } // ============================================================================= @@ -1229,9 +1205,9 @@ fn query_columns_with_relations(db: &mut TestDb) { assert_eq!(users.len(), 1); assert_eq!(users[0].name.as_deref(), Some("Alice")); assert!(users[0].invited_by.is_none()); // not selected - assert_eq!(users[0].posts().len(), 2); + assert_eq!(users[0].posts.len(), 2); // Relations are full SelectModel (not partial) - assert_eq!(users[0].posts()[0].title, "Post 1"); + assert_eq!(users[0].posts[0].title, "Post 1"); } // -- Partial columns on a relation -- @@ -1263,11 +1239,11 @@ fn query_relation_columns(db: &mut TestDb) { // Base is full SelectModel assert_eq!(users[0].name, "Alice"); // Relation is PartialSelectModel - assert_eq!(users[0].posts().len(), 2); - assert!(users[0].posts()[0].id.is_some()); - assert_eq!(users[0].posts()[0].title.as_deref(), Some("Post 1")); + assert_eq!(users[0].posts.len(), 2); + assert!(users[0].posts[0].id.is_some()); + assert_eq!(users[0].posts[0].title.as_deref(), Some("Post 1")); // author_id not selected - assert!(users[0].posts()[0].author_id.is_none()); + assert!(users[0].posts[0].author_id.is_none()); } // -- find_first with partial columns -- @@ -1317,7 +1293,7 @@ fn query_first_limits_to_one(db: &mut TestDb) { let users = db.query(complex).with(complex.posts().first()).find_many(); assert_eq!(users.len(), 1); - assert_eq!(users[0].posts().len(), 1); + assert_eq!(users[0].posts.len(), 1); } // ============================================================================= @@ -1332,7 +1308,7 @@ struct M2MQuerySchema { post_category: PostCategory, } -// -- basic m2m: post.categories() returns categories through junction -- +// -- basic m2m: post.categories returns categories through junction -- #[drizzle::test] fn query_many_to_many_basic(db: &mut TestDb) { let M2MQuerySchema { @@ -1379,9 +1355,9 @@ fn query_many_to_many_basic(db: &mut TestDb) { assert_eq!(posts.len(), 1); assert_eq!(posts[0].title, "My Post"); - assert_eq!(posts[0].categories().len(), 2); + assert_eq!(posts[0].categories.len(), 2); let cat_names: Vec<&str> = posts[0] - .categories() + .categories .iter() .map(|c| c.name.as_str()) .collect(); @@ -1439,8 +1415,8 @@ fn query_many_to_many_reverse(db: &mut TestDb) { assert_eq!(cats.len(), 1); assert_eq!(cats[0].name, "Tech"); - assert_eq!(cats[0].posts().len(), 2); - let post_titles: Vec<&str> = cats[0].posts().iter().map(|p| p.title.as_str()).collect(); + assert_eq!(cats[0].posts.len(), 2); + let post_titles: Vec<&str> = cats[0].posts.iter().map(|p| p.title.as_str()).collect(); assert!(post_titles.contains(&"Post A")); assert!(post_titles.contains(&"Post B")); } @@ -1470,7 +1446,7 @@ fn query_many_to_many_empty(db: &mut TestDb) { let posts = db.query(post).with(post.categories()).find_many(); assert_eq!(posts.len(), 1); - assert_eq!(posts[0].categories().len(), 0); + assert_eq!(posts[0].categories.len(), 0); } // -- m2m with limit -- @@ -1521,5 +1497,101 @@ fn query_many_to_many_with_limit(db: &mut TestDb) { let posts = db.query(post).with(post.categories().limit(2)).find_many(); assert_eq!(posts.len(), 1); - assert_eq!(posts[0].categories().len(), 2); + assert_eq!(posts[0].categories.len(), 2); +} + +// ============================================================================= +// Multi-FK reverse relations + `relation = "..."` override +// ============================================================================= + +#[SQLiteTable(NAME = "articles")] +struct Article { + #[column(PRIMARY, DEFAULT_FN = Uuid::new_v4)] + id: Uuid, + title: String, + /// Reverse on Complex: `.authored()` via explicit relation name. + #[column(REFERENCES = Complex::id, RELATION = "authored")] + author_id: Uuid, + /// Reverse on Complex: `.editor_articles()` via auto-disambiguation. + #[column(REFERENCES = Complex::id)] + editor_id: Option, +} + +#[derive(SQLiteSchema)] +struct MultiFkQuerySchema { + complex: Complex, + article: Article, +} + +// -- Multi-FK: relation override + auto-disambiguated reverse -- +#[drizzle::test] +fn query_multi_fk_reverse_disambiguated(db: &mut TestDb) { + let MultiFkQuerySchema { complex, article } = schema; + + db.insert(complex) + .values([ + InsertComplex::new("Alice", true, Role::User), + InsertComplex::new("Bob", true, Role::User), + ]) + .execute(); + + let all_users: Vec = db.select(()).from(complex).all(); + let alice_id = all_users.iter().find(|u| u.name == "Alice").unwrap().id; + let bob_id = all_users.iter().find(|u| u.name == "Bob").unwrap().id; + + db.insert(article) + .values([InsertArticle::new("Draft A", alice_id).with_editor_id(bob_id)]) + .execute(); + db.insert(article) + .values([InsertArticle::new("Draft B", bob_id)]) + .execute(); + + // `relation = "authored"` → Complex.authored() + let authors = db.query(complex).with(complex.authored()).find_many(); + let alice = authors.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice.authored.len(), 1); + assert_eq!(alice.authored[0].title, "Draft A"); + let bob = authors.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob.authored.len(), 1); + assert_eq!(bob.authored[0].title, "Draft B"); + + // Auto-disambiguated: Complex.editor_articles() + let editors = db + .query(complex) + .with(complex.editor_articles()) + .find_many(); + let bob_ed = editors.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob_ed.editor_articles.len(), 1); + assert_eq!(bob_ed.editor_articles[0].title, "Draft A"); + let alice_ed = editors.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice_ed.editor_articles.len(), 0); +} + +// -- Self-referential reverse: Complex.invited_by_complexes() -- +#[drizzle::test] +fn query_self_ref_reverse_disambiguated(db: &mut TestDb) { + let ComplexPostQuerySchema { complex, post: _ } = schema; + + db.insert(complex) + .values([InsertComplex::new("Alice", true, Role::User)]) + .execute(); + let all_users: Vec = db.select(()).from(complex).all(); + let alice_id = all_users[0].id; + + db.insert(complex) + .values([ + InsertComplex::new("Bob", true, Role::User).with_invited_by(alice_id), + InsertComplex::new("Charlie", true, Role::User).with_invited_by(alice_id), + ]) + .execute(); + + let users = db + .query(complex) + .with(complex.invited_by_complexes()) + .find_many(); + + let alice = users.iter().find(|u| u.name == "Alice").unwrap(); + assert_eq!(alice.invited_by_complexes.len(), 2); + let bob = users.iter().find(|u| u.name == "Bob").unwrap(); + assert_eq!(bob.invited_by_complexes.len(), 0); }