Skip to main content

sql_orm/
dbset_query.rs

1use crate::context::{ActiveTenant, SharedConnection};
2use crate::page_request::PageRequest;
3use crate::query_alias::AliasedEntityColumn;
4use crate::query_projection::SelectProjections;
5use crate::{
6    IncludeCollection, IncludeNavigation, SoftDeleteEntity, TenantScopedEntity,
7    TrackingRegistryHandle,
8};
9use sql_orm_core::{
10    ColumnMetadata, Entity, EntityColumn, EntityMetadata, FromRow, NavigationKind, OrmError, Row,
11    SqlServerType, SqlTypeMapping, SqlValue,
12};
13use sql_orm_query::{
14    AggregateExpr, AggregateOrderBy, AggregatePredicate, AggregateProjection, AggregateQuery,
15    ColumnRef, ExistsQuery, Expr, Join, JoinType, OrderBy, Pagination, Predicate, SelectProjection,
16    SelectQuery, TableRef,
17};
18use sql_orm_sqlserver::SqlServerCompiler;
19use std::collections::BTreeSet;
20
21#[derive(Clone)]
22/// Fluent query builder bound to one `DbSet<E>`.
23///
24/// `DbSetQuery` stores query intent as AST until execution. SQL text is
25/// generated only by the SQL Server compiler. Mandatory runtime policies such
26/// as tenant filtering and root-entity soft-delete visibility are applied when
27/// the query is compiled or executed.
28pub struct DbSetQuery<E: Entity> {
29    connection: Option<SharedConnection>,
30    active_tenant: Option<ActiveTenant>,
31    tracking_registry: Option<TrackingRegistryHandle>,
32    select_query: SelectQuery,
33    visibility: SoftDeleteVisibility,
34    _entity: core::marker::PhantomData<fn() -> E>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum SoftDeleteVisibility {
39    Default,
40    WithDeleted,
41    OnlyDeleted,
42}
43
44/// Loading strategy for a `has_many` collection include.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CollectionIncludeStrategy {
47    /// Load roots and related rows through one `LEFT JOIN`, then group joined
48    /// rows by the root primary key.
49    Join,
50    /// Planned split-query strategy for large collections.
51    ///
52    /// The strategy is explicit in the public API, but execution returns a
53    /// clear error until the split-query implementation lands.
54    SplitQuery,
55}
56
57const DEFAULT_INCLUDE_MANY_JOIN_ROW_LIMIT: usize = 10_000;
58
59impl<E: Entity> DbSetQuery<E> {
60    pub(crate) fn new(connection: Option<SharedConnection>, select_query: SelectQuery) -> Self {
61        let active_tenant = connection
62            .as_ref()
63            .and_then(SharedConnection::active_tenant);
64        Self {
65            connection,
66            active_tenant,
67            tracking_registry: None,
68            select_query,
69            visibility: SoftDeleteVisibility::Default,
70            _entity: core::marker::PhantomData,
71        }
72    }
73
74    pub(crate) fn with_tracking_registry(
75        mut self,
76        tracking_registry: TrackingRegistryHandle,
77    ) -> Self {
78        self.tracking_registry = Some(tracking_registry);
79        self
80    }
81
82    #[cfg(test)]
83    pub(crate) fn with_active_tenant_for_test(mut self, active_tenant: ActiveTenant) -> Self {
84        self.active_tenant = Some(active_tenant);
85        self
86    }
87
88    /// Replaces the underlying `SelectQuery` AST while keeping this query bound
89    /// to the same connection and runtime policies.
90    pub fn with_select_query(mut self, select_query: SelectQuery) -> Self {
91        self.select_query = select_query;
92        self
93    }
94
95    /// Adds a predicate to the query.
96    pub fn filter(mut self, predicate: Predicate) -> Self {
97        self.select_query = self.select_query.filter(predicate);
98        self
99    }
100
101    /// Adds an explicit join described by the query AST.
102    pub fn join(mut self, join: Join) -> Self {
103        self.select_query = self.select_query.join(join);
104        self
105    }
106
107    /// Adds an explicit `INNER JOIN` to another entity.
108    pub fn inner_join<J: Entity>(mut self, on: Predicate) -> Self {
109        self.select_query = self.select_query.inner_join::<J>(on);
110        self
111    }
112
113    /// Adds an explicit `LEFT JOIN` to another entity.
114    pub fn left_join<J: Entity>(mut self, on: Predicate) -> Self {
115        self.select_query = self.select_query.left_join::<J>(on);
116        self
117    }
118
119    /// Adds an `INNER JOIN` inferred from navigation metadata.
120    ///
121    /// The navigation must be declared on the root entity `E`, and its target
122    /// table must match `J`. This only builds the SQL join; it does not load or
123    /// materialize the related entity.
124    pub fn try_inner_join_navigation<J: Entity>(
125        self,
126        navigation: &'static str,
127    ) -> Result<Self, OrmError> {
128        self.try_join_navigation::<J>(navigation, JoinType::Inner, None)
129    }
130
131    /// Adds a `LEFT JOIN` inferred from navigation metadata.
132    ///
133    /// The navigation must be declared on the root entity `E`, and its target
134    /// table must match `J`. This only builds the SQL join; it does not load or
135    /// materialize the related entity.
136    pub fn try_left_join_navigation<J: Entity>(
137        self,
138        navigation: &'static str,
139    ) -> Result<Self, OrmError> {
140        self.try_join_navigation::<J>(navigation, JoinType::Left, None)
141    }
142
143    /// Adds an aliased `INNER JOIN` inferred from navigation metadata.
144    pub fn try_inner_join_navigation_as<J: Entity>(
145        self,
146        navigation: &'static str,
147        alias: &'static str,
148    ) -> Result<Self, OrmError> {
149        self.try_join_navigation::<J>(navigation, JoinType::Inner, Some(alias))
150    }
151
152    /// Adds an aliased `LEFT JOIN` inferred from navigation metadata.
153    pub fn try_left_join_navigation_as<J: Entity>(
154        self,
155        navigation: &'static str,
156        alias: &'static str,
157    ) -> Result<Self, OrmError> {
158        self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))
159    }
160
161    /// Includes a single related entity through a `belongs_to` or `has_one`
162    /// navigation.
163    ///
164    /// This first eager-loading cut uses a left join and materializes the
165    /// related row into `Navigation<J>`. Collection navigations (`has_many`)
166    /// are intentionally rejected because they need grouping or split-query
167    /// semantics.
168    pub fn include<J: Entity>(
169        self,
170        navigation: &'static str,
171    ) -> Result<DbSetQueryIncludeOne<E, J>, OrmError> {
172        self.include_as::<J>(navigation, navigation)
173    }
174
175    /// Includes a single related entity using an explicit table alias.
176    pub fn include_as<J: Entity>(
177        self,
178        navigation: &'static str,
179        alias: &'static str,
180    ) -> Result<DbSetQueryIncludeOne<E, J>, OrmError> {
181        let metadata = E::metadata();
182        let navigation_metadata = metadata.navigation(navigation).ok_or_else(|| {
183            OrmError::compile(format!(
184                "entity `{}` does not declare navigation `{}`",
185                metadata.rust_name, navigation
186            ))
187        })?;
188
189        if !matches!(
190            navigation_metadata.kind,
191            NavigationKind::BelongsTo | NavigationKind::HasOne
192        ) {
193            return Err(OrmError::compile(format!(
194                "include only supports belongs_to and has_one navigations; `{}` is {:?}",
195                navigation_metadata.rust_field, navigation_metadata.kind
196            )));
197        }
198
199        Ok(DbSetQueryIncludeOne {
200            query: self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))?,
201            navigation,
202            alias,
203            _target: core::marker::PhantomData,
204        })
205    }
206
207    /// Includes a collection navigation through a `has_many` relationship.
208    ///
209    /// This first collection include cut uses a left join, materializes joined
210    /// rows, then groups them by the root entity primary key before assigning
211    /// `Collection<J>`. Pagination is rejected for this join-based path
212    /// because limiting joined rows is not equivalent to limiting root
213    /// entities.
214    pub fn include_many<J: Entity>(
215        self,
216        navigation: &'static str,
217    ) -> Result<DbSetQueryIncludeMany<E, J>, OrmError> {
218        self.include_many_as::<J>(navigation, navigation)
219    }
220
221    /// Includes a collection navigation using an explicit table alias.
222    pub fn include_many_as<J: Entity>(
223        self,
224        navigation: &'static str,
225        alias: &'static str,
226    ) -> Result<DbSetQueryIncludeMany<E, J>, OrmError> {
227        let metadata = E::metadata();
228        let navigation_metadata = metadata.navigation(navigation).ok_or_else(|| {
229            OrmError::compile(format!(
230                "entity `{}` does not declare navigation `{}`",
231                metadata.rust_name, navigation
232            ))
233        })?;
234
235        if !matches!(navigation_metadata.kind, NavigationKind::HasMany) {
236            return Err(OrmError::compile(format!(
237                "include_many only supports has_many navigations; `{}` is {:?}",
238                navigation_metadata.rust_field, navigation_metadata.kind
239            )));
240        }
241
242        Ok(DbSetQueryIncludeMany {
243            query: self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))?,
244            navigation,
245            alias,
246            strategy: CollectionIncludeStrategy::Join,
247            join_row_limit: Some(DEFAULT_INCLUDE_MANY_JOIN_ROW_LIMIT),
248            _target: core::marker::PhantomData,
249        })
250    }
251
252    /// Adds an ordering expression.
253    pub fn order_by(mut self, order: OrderBy) -> Self {
254        self.select_query = self.select_query.order_by(order);
255        self
256    }
257
258    /// Limits the number of returned rows with zero offset.
259    pub fn limit(mut self, limit: u64) -> Self {
260        self.select_query = self.select_query.paginate(Pagination::new(0, limit));
261        self
262    }
263
264    /// Alias for `limit(...)`.
265    pub fn take(self, limit: u64) -> Self {
266        self.limit(limit)
267    }
268
269    /// Applies page-based pagination.
270    pub fn paginate(mut self, request: PageRequest) -> Self {
271        self.select_query = self.select_query.paginate(request.to_pagination());
272        self
273    }
274
275    /// Selects an explicit projection instead of materializing full entities.
276    ///
277    /// Use `all_as::<T>()` or `first_as::<T>()` to materialize the projection
278    /// into a DTO implementing `FromRow`.
279    pub fn select<P>(mut self, projection: P) -> Self
280    where
281        P: SelectProjections,
282    {
283        self.select_query = self
284            .select_query
285            .select(projection.into_select_projections());
286        self
287    }
288
289    /// Starts a grouped aggregate query over one or more group key
290    /// expressions.
291    ///
292    /// The returned builder materializes DTOs through `all_as::<T>()` and
293    /// `first_as::<T>()`; it does not expose full-entity materialization.
294    pub fn group_by<G>(self, group_by: G) -> Result<DbSetGroupedQuery<E>, OrmError>
295    where
296        E: SoftDeleteEntity + TenantScopedEntity,
297        G: GroupByExpressions,
298    {
299        let group_by = group_by.into_group_by_expressions();
300        if group_by.is_empty() {
301            return Err(OrmError::compile(
302                "group_by requires at least one group key expression",
303            ));
304        }
305
306        let connection = self.connection.clone();
307        let effective = self.effective_select_query()?;
308        Ok(DbSetGroupedQuery {
309            connection,
310            aggregate_query: AggregateQuery {
311                from: effective.from,
312                joins: effective.joins,
313                projection: Vec::new(),
314                predicate: effective.predicate,
315                group_by,
316                having: None,
317                order_by: Vec::new(),
318                pagination: None,
319            },
320            _entity: core::marker::PhantomData,
321        })
322    }
323
324    #[cfg(test)]
325    pub(crate) fn select_query(&self) -> &SelectQuery {
326        &self.select_query
327    }
328
329    /// Includes logically deleted rows for entities with `soft_delete`.
330    ///
331    /// This affects only the root entity `E`, not every manually joined entity.
332    pub fn with_deleted(mut self) -> Self {
333        self.visibility = SoftDeleteVisibility::WithDeleted;
334        self
335    }
336
337    /// Returns only logically deleted rows for entities with `soft_delete`.
338    ///
339    /// This affects only the root entity `E`, not every manually joined entity.
340    pub fn only_deleted(mut self) -> Self {
341        self.visibility = SoftDeleteVisibility::OnlyDeleted;
342        self
343    }
344
345    #[cfg(test)]
346    pub(crate) fn into_select_query(self) -> SelectQuery {
347        self.select_query
348    }
349
350    /// Executes the query and materializes full entities.
351    pub async fn all(self) -> Result<Vec<E>, OrmError>
352    where
353        E: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
354    {
355        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
356        let shared_connection = self.require_connection()?;
357        let mut connection = shared_connection.lock().await?;
358        connection.fetch_all(compiled).await
359    }
360
361    /// Executes the query and materializes the first full entity, if any.
362    pub async fn first(self) -> Result<Option<E>, OrmError>
363    where
364        E: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
365    {
366        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
367        let shared_connection = self.require_connection()?;
368        let mut connection = shared_connection.lock().await?;
369        connection.fetch_one(compiled).await
370    }
371
372    /// Executes the query and materializes projected rows as DTOs.
373    pub async fn all_as<T>(self) -> Result<Vec<T>, OrmError>
374    where
375        E: SoftDeleteEntity + TenantScopedEntity,
376        T: FromRow + Send,
377    {
378        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
379        let shared_connection = self.require_connection()?;
380        let mut connection = shared_connection.lock().await?;
381        connection.fetch_all(compiled).await
382    }
383
384    /// Executes the query and materializes the first projected DTO, if any.
385    pub async fn first_as<T>(self) -> Result<Option<T>, OrmError>
386    where
387        E: SoftDeleteEntity + TenantScopedEntity,
388        T: FromRow + Send,
389    {
390        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
391        let shared_connection = self.require_connection()?;
392        let mut connection = shared_connection.lock().await?;
393        connection.fetch_one(compiled).await
394    }
395
396    /// Executes the query as a `COUNT(*)` over the effective filters.
397    pub async fn count(self) -> Result<i64, OrmError>
398    where
399        E: SoftDeleteEntity + TenantScopedEntity,
400    {
401        let compiled = SqlServerCompiler::compile_aggregate(
402            &self.scalar_aggregate_query(AggregateProjection::count_as("count"))?,
403        )?;
404        let shared_connection = self.require_connection()?;
405        let mut connection = shared_connection.lock().await?;
406        let row = connection.fetch_one::<CountRow>(compiled).await?;
407
408        row.map(|row| row.value)
409            .ok_or_else(|| OrmError::mapping("count query did not return a row"))
410    }
411
412    /// Executes the query as an `EXISTS` predicate over the effective filters.
413    pub async fn exists(self) -> Result<bool, OrmError>
414    where
415        E: SoftDeleteEntity + TenantScopedEntity,
416    {
417        let compiled = SqlServerCompiler::compile_exists(&self.exists_query()?)?;
418        let shared_connection = self.require_connection()?;
419        let mut connection = shared_connection.lock().await?;
420        let row = connection.fetch_one::<ExistsRow>(compiled).await?;
421
422        row.map(|row| row.value)
423            .ok_or_else(|| OrmError::mapping("exists query did not return a row"))
424    }
425
426    /// Alias for `exists()`.
427    pub async fn any(self) -> Result<bool, OrmError>
428    where
429        E: SoftDeleteEntity + TenantScopedEntity,
430    {
431        self.exists().await
432    }
433
434    /// Executes the query as a scalar `SUM(...)` aggregate.
435    pub async fn sum<T>(self, column: impl Into<Expr>) -> Result<Option<T>, OrmError>
436    where
437        E: SoftDeleteEntity + TenantScopedEntity,
438        T: SqlTypeMapping + Send,
439    {
440        self.scalar_aggregate(AggregateExpr::sum(column)).await
441    }
442
443    /// Executes the query as a scalar `AVG(...)` aggregate.
444    pub async fn avg<T>(self, column: impl Into<Expr>) -> Result<Option<T>, OrmError>
445    where
446        E: SoftDeleteEntity + TenantScopedEntity,
447        T: SqlTypeMapping + Send,
448    {
449        self.scalar_aggregate(AggregateExpr::avg(column)).await
450    }
451
452    /// Executes the query as a scalar `MIN(...)` aggregate.
453    pub async fn min<T>(self, column: impl Into<Expr>) -> Result<Option<T>, OrmError>
454    where
455        E: SoftDeleteEntity + TenantScopedEntity,
456        T: SqlTypeMapping + Send,
457    {
458        self.scalar_aggregate(AggregateExpr::min(column)).await
459    }
460
461    /// Executes the query as a scalar `MAX(...)` aggregate.
462    pub async fn max<T>(self, column: impl Into<Expr>) -> Result<Option<T>, OrmError>
463    where
464        E: SoftDeleteEntity + TenantScopedEntity,
465        T: SqlTypeMapping + Send,
466    {
467        self.scalar_aggregate(AggregateExpr::max(column)).await
468    }
469
470    async fn scalar_aggregate<T>(self, expr: AggregateExpr) -> Result<Option<T>, OrmError>
471    where
472        E: SoftDeleteEntity + TenantScopedEntity,
473        T: SqlTypeMapping + Send,
474    {
475        let compiled = SqlServerCompiler::compile_aggregate(
476            &self.scalar_aggregate_query(AggregateProjection::expr_as(expr, "value"))?,
477        )?;
478        let shared_connection = self.require_connection()?;
479        let mut connection = shared_connection.lock().await?;
480        let row = connection
481            .fetch_one::<ScalarAggregateRow<T>>(compiled)
482            .await?;
483
484        row.map(|row| row.value)
485            .ok_or_else(|| OrmError::mapping("scalar aggregate query did not return a row"))
486    }
487
488    fn exists_query(&self) -> Result<ExistsQuery, OrmError>
489    where
490        E: SoftDeleteEntity + TenantScopedEntity,
491    {
492        let effective = self.effective_select_query()?;
493        Ok(ExistsQuery {
494            from: effective.from,
495            joins: effective.joins,
496            predicate: effective.predicate,
497        })
498    }
499
500    fn scalar_aggregate_query(
501        &self,
502        projection: AggregateProjection,
503    ) -> Result<AggregateQuery, OrmError>
504    where
505        E: SoftDeleteEntity + TenantScopedEntity,
506    {
507        let effective = self.effective_select_query()?;
508        Ok(AggregateQuery {
509            from: effective.from,
510            joins: effective.joins,
511            projection: vec![projection],
512            predicate: effective.predicate,
513            group_by: Vec::new(),
514            having: None,
515            order_by: Vec::new(),
516            pagination: None,
517        })
518    }
519
520    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
521    where
522        E: SoftDeleteEntity + TenantScopedEntity,
523    {
524        let mut query = self.select_query.clone();
525
526        if let Some(predicate) =
527            tenant_predicate_for::<E>(self.active_tenant.as_ref(), TableRef::for_entity::<E>())?
528        {
529            query = query.filter(predicate);
530        }
531
532        if let Some(predicate) =
533            soft_delete_visibility_predicate_for::<E>(TableRef::for_entity::<E>(), self.visibility)?
534        {
535            query = query.filter(predicate);
536        }
537
538        Ok(query)
539    }
540
541    fn require_connection(&self) -> Result<SharedConnection, OrmError> {
542        self.connection.as_ref().cloned().ok_or_else(|| {
543            OrmError::execution("DbSetQuery requires an initialized shared connection")
544        })
545    }
546
547    fn try_join_navigation<J: Entity>(
548        mut self,
549        navigation: &'static str,
550        join_type: JoinType,
551        alias: Option<&'static str>,
552    ) -> Result<Self, OrmError> {
553        let join = self.navigation_join::<J>(navigation, join_type, alias)?;
554        self.select_query = self.select_query.join(join);
555        Ok(self)
556    }
557
558    fn navigation_join<J: Entity>(
559        &self,
560        navigation: &'static str,
561        join_type: JoinType,
562        alias: Option<&'static str>,
563    ) -> Result<Join, OrmError> {
564        let root_metadata = E::metadata();
565        let target_metadata = J::metadata();
566        let navigation = root_metadata.navigation(navigation).ok_or_else(|| {
567            OrmError::compile(format!(
568                "entity `{}` does not declare navigation `{}`",
569                root_metadata.rust_name, navigation
570            ))
571        })?;
572
573        if navigation.target_schema != target_metadata.schema
574            || navigation.target_table != target_metadata.table
575        {
576            return Err(OrmError::compile(format!(
577                "navigation `{}` on `{}` targets `{}.{}`, not entity `{}` (`{}.{}`)",
578                navigation.rust_field,
579                root_metadata.rust_name,
580                navigation.target_schema,
581                navigation.target_table,
582                target_metadata.rust_name,
583                target_metadata.schema,
584                target_metadata.table
585            )));
586        }
587
588        if navigation.local_columns.is_empty()
589            || navigation.local_columns.len() != navigation.target_columns.len()
590        {
591            return Err(OrmError::compile(format!(
592                "navigation `{}` on `{}` has invalid join column metadata",
593                navigation.rust_field, root_metadata.rust_name
594            )));
595        }
596
597        let target_table = match alias {
598            Some(alias) => TableRef::for_entity_as::<J>(alias),
599            None => TableRef::for_entity::<J>(),
600        };
601
602        let predicates = navigation
603            .local_columns
604            .iter()
605            .zip(navigation.target_columns.iter())
606            .map(|(local_column, target_column)| {
607                Ok(Predicate::eq(
608                    metadata_column_expr(root_metadata, self.select_query.from, local_column)?,
609                    metadata_column_expr(target_metadata, target_table, target_column)?,
610                ))
611            })
612            .collect::<Result<Vec<_>, OrmError>>()?;
613
614        let on = if predicates.len() == 1 {
615            predicates[0].clone()
616        } else {
617            Predicate::and(predicates)
618        };
619
620        Ok(Join::new(join_type, target_table, on))
621    }
622}
623
624/// Converts public `group_by(...)` arguments into neutral query expressions.
625pub trait GroupByExpressions {
626    fn into_group_by_expressions(self) -> Vec<Expr>;
627}
628
629impl GroupByExpressions for Expr {
630    fn into_group_by_expressions(self) -> Vec<Expr> {
631        vec![self]
632    }
633}
634
635impl<E> GroupByExpressions for EntityColumn<E>
636where
637    E: Entity,
638{
639    fn into_group_by_expressions(self) -> Vec<Expr> {
640        vec![Expr::from(self)]
641    }
642}
643
644impl<E> GroupByExpressions for AliasedEntityColumn<E>
645where
646    E: Entity,
647{
648    fn into_group_by_expressions(self) -> Vec<Expr> {
649        vec![Expr::from(self)]
650    }
651}
652
653impl<P> GroupByExpressions for Vec<P>
654where
655    P: Into<Expr>,
656{
657    fn into_group_by_expressions(self) -> Vec<Expr> {
658        self.into_iter().map(Into::into).collect()
659    }
660}
661
662impl<P, const N: usize> GroupByExpressions for [P; N]
663where
664    P: Into<Expr>,
665{
666    fn into_group_by_expressions(self) -> Vec<Expr> {
667        self.into_iter().map(Into::into).collect()
668    }
669}
670
671macro_rules! impl_group_by_expressions_tuple {
672    ($($name:ident),+ $(,)?) => {
673        impl<$($name),+> GroupByExpressions for ($($name,)+)
674        where
675            $($name: Into<Expr>),+
676        {
677            #[allow(non_snake_case)]
678            fn into_group_by_expressions(self) -> Vec<Expr> {
679                let ($($name,)+) = self;
680                vec![$($name.into()),+]
681            }
682        }
683    };
684}
685
686impl_group_by_expressions_tuple!(A);
687impl_group_by_expressions_tuple!(A, B);
688impl_group_by_expressions_tuple!(A, B, C);
689impl_group_by_expressions_tuple!(A, B, C, D);
690impl_group_by_expressions_tuple!(A, B, C, D, E);
691impl_group_by_expressions_tuple!(A, B, C, D, E, F);
692impl_group_by_expressions_tuple!(A, B, C, D, E, F, G);
693impl_group_by_expressions_tuple!(A, B, C, D, E, F, G, H);
694impl_group_by_expressions_tuple!(A, B, C, D, E, F, G, H, I);
695impl_group_by_expressions_tuple!(A, B, C, D, E, F, G, H, I, J);
696impl_group_by_expressions_tuple!(A, B, C, D, E, F, G, H, I, J, K);
697impl_group_by_expressions_tuple!(A, B, C, D, E, F, G, H, I, J, K, L);
698
699/// Converts public `select_aggregate(...)` arguments into aggregate
700/// projections.
701pub trait AggregateProjections {
702    fn into_aggregate_projections(self) -> Vec<AggregateProjection>;
703}
704
705impl AggregateProjections for AggregateProjection {
706    fn into_aggregate_projections(self) -> Vec<AggregateProjection> {
707        vec![self]
708    }
709}
710
711impl<P> AggregateProjections for Vec<P>
712where
713    P: Into<AggregateProjection>,
714{
715    fn into_aggregate_projections(self) -> Vec<AggregateProjection> {
716        self.into_iter().map(Into::into).collect()
717    }
718}
719
720impl<P, const N: usize> AggregateProjections for [P; N]
721where
722    P: Into<AggregateProjection>,
723{
724    fn into_aggregate_projections(self) -> Vec<AggregateProjection> {
725        self.into_iter().map(Into::into).collect()
726    }
727}
728
729macro_rules! impl_aggregate_projections_tuple {
730    ($($name:ident),+ $(,)?) => {
731        impl<$($name),+> AggregateProjections for ($($name,)+)
732        where
733            $($name: Into<AggregateProjection>),+
734        {
735            #[allow(non_snake_case)]
736            fn into_aggregate_projections(self) -> Vec<AggregateProjection> {
737                let ($($name,)+) = self;
738                vec![$($name.into()),+]
739            }
740        }
741    };
742}
743
744impl_aggregate_projections_tuple!(A);
745impl_aggregate_projections_tuple!(A, B);
746impl_aggregate_projections_tuple!(A, B, C);
747impl_aggregate_projections_tuple!(A, B, C, D);
748impl_aggregate_projections_tuple!(A, B, C, D, E);
749impl_aggregate_projections_tuple!(A, B, C, D, E, F);
750impl_aggregate_projections_tuple!(A, B, C, D, E, F, G);
751impl_aggregate_projections_tuple!(A, B, C, D, E, F, G, H);
752impl_aggregate_projections_tuple!(A, B, C, D, E, F, G, H, I);
753impl_aggregate_projections_tuple!(A, B, C, D, E, F, G, H, I, J);
754impl_aggregate_projections_tuple!(A, B, C, D, E, F, G, H, I, J, K);
755impl_aggregate_projections_tuple!(A, B, C, D, E, F, G, H, I, J, K, L);
756
757/// Query builder returned by `DbSetQuery::group_by(...)`.
758pub struct DbSetGroupedQuery<E: Entity> {
759    connection: Option<SharedConnection>,
760    aggregate_query: AggregateQuery,
761    _entity: core::marker::PhantomData<fn() -> E>,
762}
763
764impl<E: Entity> DbSetGroupedQuery<E> {
765    /// Selects aggregate projections materialized by alias into DTOs.
766    pub fn select_aggregate<P>(mut self, projection: P) -> Self
767    where
768        P: AggregateProjections,
769    {
770        self.aggregate_query.projection = projection.into_aggregate_projections();
771        self
772    }
773
774    /// Selects aggregate projections and validates aliases before execution.
775    pub fn try_select_aggregate<P>(mut self, projection: P) -> Result<Self, OrmError>
776    where
777        P: AggregateProjections,
778    {
779        let projection = projection.into_aggregate_projections();
780        validate_aggregate_projection_aliases(&projection)?;
781        self.aggregate_query.projection = projection;
782        Ok(self)
783    }
784
785    /// Adds a `HAVING` predicate over aggregate expressions or group keys.
786    pub fn having(mut self, predicate: AggregatePredicate) -> Self {
787        self.aggregate_query = self.aggregate_query.having(predicate);
788        self
789    }
790
791    /// Adds aggregate ordering.
792    pub fn order_by(mut self, order: AggregateOrderBy) -> Self {
793        self.aggregate_query = self.aggregate_query.order_by(order);
794        self
795    }
796
797    /// Limits grouped aggregate rows with zero offset.
798    pub fn limit(mut self, limit: u64) -> Self {
799        self.aggregate_query = self.aggregate_query.paginate(Pagination::new(0, limit));
800        self
801    }
802
803    /// Alias for `limit(...)`.
804    pub fn take(self, limit: u64) -> Self {
805        self.limit(limit)
806    }
807
808    /// Applies page-based pagination to grouped aggregate rows.
809    pub fn paginate(mut self, request: PageRequest) -> Self {
810        self.aggregate_query = self.aggregate_query.paginate(request.to_pagination());
811        self
812    }
813
814    /// Executes the grouped query and materializes projected rows as DTOs.
815    pub async fn all_as<T>(self) -> Result<Vec<T>, OrmError>
816    where
817        T: FromRow + Send,
818    {
819        let compiled = SqlServerCompiler::compile_aggregate(&self.aggregate_query)?;
820        let shared_connection = self.require_connection()?;
821        let mut connection = shared_connection.lock().await?;
822        connection.fetch_all(compiled).await
823    }
824
825    /// Executes the grouped query and materializes the first DTO, if any.
826    pub async fn first_as<T>(self) -> Result<Option<T>, OrmError>
827    where
828        T: FromRow + Send,
829    {
830        let compiled = SqlServerCompiler::compile_aggregate(&self.aggregate_query)?;
831        let shared_connection = self.require_connection()?;
832        let mut connection = shared_connection.lock().await?;
833        connection.fetch_one(compiled).await
834    }
835
836    #[cfg(test)]
837    pub(crate) fn aggregate_query(&self) -> &AggregateQuery {
838        &self.aggregate_query
839    }
840
841    fn require_connection(&self) -> Result<SharedConnection, OrmError> {
842        self.connection.as_ref().cloned().ok_or_else(|| {
843            OrmError::execution("DbSetGroupedQuery requires an initialized shared connection")
844        })
845    }
846}
847
848fn validate_aggregate_projection_aliases(
849    projection: &[AggregateProjection],
850) -> Result<(), OrmError> {
851    if projection.is_empty() {
852        return Err(OrmError::compile(
853            "select_aggregate requires at least one aggregate projection",
854        ));
855    }
856
857    let mut aliases = BTreeSet::new();
858    for projection in projection {
859        if projection.alias.trim().is_empty() {
860            return Err(OrmError::compile(
861                "aggregate projection alias cannot be empty",
862            ));
863        }
864        if !aliases.insert(projection.alias) {
865            return Err(OrmError::compile(format!(
866                "aggregate projection alias `{}` is duplicated",
867                projection.alias
868            )));
869        }
870    }
871
872    Ok(())
873}
874
875impl<E: Entity> core::fmt::Debug for DbSetGroupedQuery<E> {
876    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
877        f.debug_struct("DbSetGroupedQuery")
878            .field("entity", &E::metadata().rust_name)
879            .field("table", &E::metadata().table)
880            .field("aggregate_query", &self.aggregate_query)
881            .finish()
882    }
883}
884
885/// Query builder returned by `DbSetQuery::include::<T>(...)` for a single
886/// included navigation.
887pub struct DbSetQueryIncludeOne<E: Entity, J: Entity> {
888    query: DbSetQuery<E>,
889    navigation: &'static str,
890    alias: &'static str,
891    _target: core::marker::PhantomData<fn() -> J>,
892}
893
894impl<E: Entity, J: Entity> DbSetQueryIncludeOne<E, J> {
895    /// Adds a predicate after configuring the include.
896    pub fn filter(mut self, predicate: Predicate) -> Self {
897        self.query = self.query.filter(predicate);
898        self
899    }
900
901    /// Adds an explicit join after configuring the include.
902    pub fn join(mut self, join: Join) -> Self {
903        self.query = self.query.join(join);
904        self
905    }
906
907    /// Adds an explicit `INNER JOIN` after configuring the include.
908    pub fn inner_join<K: Entity>(mut self, on: Predicate) -> Self {
909        self.query = self.query.inner_join::<K>(on);
910        self
911    }
912
913    /// Adds an explicit `LEFT JOIN` after configuring the include.
914    pub fn left_join<K: Entity>(mut self, on: Predicate) -> Self {
915        self.query = self.query.left_join::<K>(on);
916        self
917    }
918
919    /// Adds an ordering expression after configuring the include.
920    pub fn order_by(mut self, order: OrderBy) -> Self {
921        self.query = self.query.order_by(order);
922        self
923    }
924
925    /// Limits the number of returned rows with zero offset.
926    pub fn limit(mut self, limit: u64) -> Self {
927        self.query = self.query.limit(limit);
928        self
929    }
930
931    /// Alias for `limit(...)`.
932    pub fn take(self, limit: u64) -> Self {
933        self.limit(limit)
934    }
935
936    /// Applies page-based pagination after configuring the include.
937    pub fn paginate(mut self, request: PageRequest) -> Self {
938        self.query = self.query.paginate(request);
939        self
940    }
941
942    /// Includes logically deleted root rows for entities with `soft_delete`.
943    ///
944    /// This affects only the root entity `E`; included entities still apply
945    /// their own default `soft_delete` visibility inside the include join.
946    pub fn with_deleted(mut self) -> Self {
947        self.query = self.query.with_deleted();
948        self
949    }
950
951    /// Returns only logically deleted root rows for entities with `soft_delete`.
952    ///
953    /// This affects only the root entity `E`; included entities still apply
954    /// their own default `soft_delete` visibility inside the include join.
955    pub fn only_deleted(mut self) -> Self {
956        self.query = self.query.only_deleted();
957        self
958    }
959
960    /// Executes the query and materializes root entities with one included
961    /// navigation attached.
962    pub async fn all(self) -> Result<Vec<E>, OrmError>
963    where
964        E: FromRow + IncludeNavigation<J> + Send + SoftDeleteEntity + TenantScopedEntity,
965        J: Clone + FromRow + Send + SoftDeleteEntity + Sync + TenantScopedEntity + 'static,
966    {
967        let navigation = self.navigation;
968        let alias = self.alias;
969        let tracking_registry = self.query.tracking_registry.clone();
970        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
971        let shared_connection = self.query.require_connection()?;
972        let mut connection = shared_connection.lock().await?;
973        connection
974            .fetch_all_with(compiled, move |row| {
975                materialize_include_one::<E, J>(&row, navigation, alias, tracking_registry.as_ref())
976            })
977            .await
978    }
979
980    /// Executes the query and materializes the first root entity with one
981    /// included navigation attached, if any.
982    pub async fn first(self) -> Result<Option<E>, OrmError>
983    where
984        E: FromRow + IncludeNavigation<J> + Send + SoftDeleteEntity + TenantScopedEntity,
985        J: Clone + FromRow + Send + SoftDeleteEntity + Sync + TenantScopedEntity + 'static,
986    {
987        let navigation = self.navigation;
988        let alias = self.alias;
989        let tracking_registry = self.query.tracking_registry.clone();
990        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
991        let shared_connection = self.query.require_connection()?;
992        let mut connection = shared_connection.lock().await?;
993        connection
994            .fetch_one_with(compiled, move |row| {
995                materialize_include_one::<E, J>(&row, navigation, alias, tracking_registry.as_ref())
996            })
997            .await
998    }
999
1000    #[cfg(test)]
1001    pub(crate) fn select_query(&self) -> Result<SelectQuery, OrmError>
1002    where
1003        E: SoftDeleteEntity + TenantScopedEntity,
1004        J: SoftDeleteEntity + TenantScopedEntity,
1005    {
1006        self.effective_select_query()
1007    }
1008
1009    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
1010    where
1011        E: SoftDeleteEntity + TenantScopedEntity,
1012        J: SoftDeleteEntity + TenantScopedEntity,
1013    {
1014        let query = self.query.effective_select_query()?;
1015        let query = apply_include_policy_filters::<J>(
1016            query,
1017            self.query.active_tenant.as_ref(),
1018            self.alias,
1019        )?;
1020        apply_include_projection::<E, J>(query, self.alias)
1021    }
1022}
1023
1024/// Query builder returned by `DbSetQuery::include_many::<T>(...)` for a
1025/// collection navigation.
1026pub struct DbSetQueryIncludeMany<E: Entity, J: Entity> {
1027    query: DbSetQuery<E>,
1028    navigation: &'static str,
1029    alias: &'static str,
1030    strategy: CollectionIncludeStrategy,
1031    join_row_limit: Option<usize>,
1032    _target: core::marker::PhantomData<fn() -> J>,
1033}
1034
1035impl<E: Entity, J: Entity> DbSetQueryIncludeMany<E, J> {
1036    /// Adds a predicate after configuring the collection include.
1037    pub fn filter(mut self, predicate: Predicate) -> Self {
1038        self.query = self.query.filter(predicate);
1039        self
1040    }
1041
1042    /// Adds an explicit join after configuring the collection include.
1043    pub fn join(mut self, join: Join) -> Self {
1044        self.query = self.query.join(join);
1045        self
1046    }
1047
1048    /// Adds an explicit `INNER JOIN` after configuring the collection include.
1049    pub fn inner_join<K: Entity>(mut self, on: Predicate) -> Self {
1050        self.query = self.query.inner_join::<K>(on);
1051        self
1052    }
1053
1054    /// Adds an explicit `LEFT JOIN` after configuring the collection include.
1055    pub fn left_join<K: Entity>(mut self, on: Predicate) -> Self {
1056        self.query = self.query.left_join::<K>(on);
1057        self
1058    }
1059
1060    /// Adds an ordering expression after configuring the collection include.
1061    pub fn order_by(mut self, order: OrderBy) -> Self {
1062        self.query = self.query.order_by(order);
1063        self
1064    }
1065
1066    /// Uses the join-based collection loading strategy.
1067    ///
1068    /// This is the default strategy. The row limit protects callers from
1069    /// accidentally loading an unbounded cartesian result through one join.
1070    pub fn join_strategy(mut self) -> Self {
1071        self.strategy = CollectionIncludeStrategy::Join;
1072        self
1073    }
1074
1075    /// Selects the planned split-query loading strategy.
1076    ///
1077    /// Execution currently returns a clear error because split queries need a
1078    /// separate implementation that loads roots first and related rows second.
1079    pub fn split_query(mut self) -> Self {
1080        self.strategy = CollectionIncludeStrategy::SplitQuery;
1081        self
1082    }
1083
1084    /// Overrides the maximum number of joined rows accepted before grouping.
1085    ///
1086    /// Use this only when the expected root/collection cardinality is known.
1087    pub fn max_joined_rows(mut self, limit: usize) -> Self {
1088        self.join_row_limit = Some(limit);
1089        self
1090    }
1091
1092    /// Removes the join row safety limit.
1093    ///
1094    /// This keeps the API explicit for callers that intentionally accept a
1095    /// large join result.
1096    pub fn unbounded_join(mut self) -> Self {
1097        self.join_row_limit = None;
1098        self
1099    }
1100
1101    /// Includes logically deleted root rows for entities with `soft_delete`.
1102    ///
1103    /// This affects only the root entity `E`; included collection entities
1104    /// still apply their own default `soft_delete` visibility inside the join.
1105    pub fn with_deleted(mut self) -> Self {
1106        self.query = self.query.with_deleted();
1107        self
1108    }
1109
1110    /// Returns only logically deleted root rows for entities with `soft_delete`.
1111    ///
1112    /// This affects only the root entity `E`; included collection entities
1113    /// still apply their own default `soft_delete` visibility inside the join.
1114    pub fn only_deleted(mut self) -> Self {
1115        self.query = self.query.only_deleted();
1116        self
1117    }
1118
1119    /// Executes the query and materializes root entities with one collection
1120    /// navigation attached.
1121    pub async fn all(self) -> Result<Vec<E>, OrmError>
1122    where
1123        E: FromRow + IncludeCollection<J> + Send + SoftDeleteEntity + TenantScopedEntity,
1124        J: Clone + FromRow + Send + SoftDeleteEntity + Sync + TenantScopedEntity + 'static,
1125    {
1126        if self.strategy == CollectionIncludeStrategy::SplitQuery {
1127            return Err(OrmError::compile(
1128                "include_many split-query loading is not implemented yet; use join_strategy() with an explicit max_joined_rows(...) limit",
1129            ));
1130        }
1131
1132        let navigation = self.navigation;
1133        let alias = self.alias;
1134        let tracking_registry = self.query.tracking_registry.clone();
1135        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
1136        let shared_connection = self.query.require_connection()?;
1137        let mut connection = shared_connection.lock().await?;
1138        let rows = connection
1139            .fetch_all_with(compiled, move |row| {
1140                materialize_include_many_row::<E, J>(&row, alias)
1141            })
1142            .await?;
1143
1144        enforce_include_many_join_row_limit(rows.len(), self.join_row_limit)?;
1145        group_include_many_rows::<E, J>(rows, navigation, tracking_registry.as_ref())
1146    }
1147
1148    #[cfg(test)]
1149    pub(crate) fn select_query(&self) -> Result<SelectQuery, OrmError>
1150    where
1151        E: SoftDeleteEntity + TenantScopedEntity,
1152        J: SoftDeleteEntity + TenantScopedEntity,
1153    {
1154        self.effective_select_query()
1155    }
1156
1157    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
1158    where
1159        E: SoftDeleteEntity + TenantScopedEntity,
1160        J: SoftDeleteEntity + TenantScopedEntity,
1161    {
1162        let query = self.query.effective_select_query()?;
1163        if query.pagination.is_some() {
1164            return Err(OrmError::compile(
1165                "include_many does not support pagination in the join-based collection loading cut",
1166            ));
1167        }
1168
1169        let query = apply_include_policy_filters::<J>(
1170            query,
1171            self.query.active_tenant.as_ref(),
1172            self.alias,
1173        )?;
1174        apply_include_projection::<E, J>(query, self.alias)
1175    }
1176}
1177
1178fn tenant_predicate_for<E: TenantScopedEntity>(
1179    active_tenant: Option<&ActiveTenant>,
1180    table: TableRef,
1181) -> Result<Option<Predicate>, OrmError> {
1182    let Some(policy) = E::tenant_policy() else {
1183        return Ok(None);
1184    };
1185
1186    if policy.columns.len() != 1 {
1187        return Err(OrmError::compile(
1188            "tenant query filter requires exactly one tenant policy column",
1189        ));
1190    }
1191
1192    let tenant_column = &policy.columns[0];
1193    let active_tenant = active_tenant.ok_or_else(|| {
1194        OrmError::execution("tenant-scoped query requires an active tenant in the DbContext")
1195    })?;
1196
1197    if active_tenant.column_name != tenant_column.column_name {
1198        return Err(OrmError::compile(format!(
1199            "active tenant column `{}` does not match entity tenant column `{}`",
1200            active_tenant.column_name, tenant_column.column_name
1201        )));
1202    }
1203
1204    if !tenant_value_matches_column_type(&active_tenant.value, tenant_column) {
1205        return Err(OrmError::compile(format!(
1206            "active tenant value is not compatible with entity tenant column `{}`",
1207            tenant_column.column_name
1208        )));
1209    }
1210
1211    Ok(Some(Predicate::eq(
1212        Expr::Column(ColumnRef::new(
1213            table,
1214            tenant_column.rust_field,
1215            tenant_column.column_name,
1216        )),
1217        Expr::Value(active_tenant.value.clone()),
1218    )))
1219}
1220
1221fn soft_delete_visibility_predicate_for<E: SoftDeleteEntity>(
1222    table: TableRef,
1223    visibility: SoftDeleteVisibility,
1224) -> Result<Option<Predicate>, OrmError> {
1225    let Some(policy) = E::soft_delete_policy() else {
1226        return Ok(None);
1227    };
1228
1229    let visibility = match visibility {
1230        SoftDeleteVisibility::Default => SoftDeleteVisibility::Default,
1231        SoftDeleteVisibility::WithDeleted => return Ok(None),
1232        SoftDeleteVisibility::OnlyDeleted => SoftDeleteVisibility::OnlyDeleted,
1233    };
1234
1235    let indicator = policy.columns.first().ok_or_else(|| {
1236        OrmError::compile("soft_delete query visibility requires at least one policy column")
1237    })?;
1238    let column = Expr::Column(ColumnRef::new(
1239        table,
1240        indicator.rust_field,
1241        indicator.column_name,
1242    ));
1243
1244    if indicator.sql_type == SqlServerType::Bit {
1245        return Ok(Some(match visibility {
1246            SoftDeleteVisibility::Default => {
1247                Predicate::eq(column, Expr::Value(SqlValue::Bool(false)))
1248            }
1249            SoftDeleteVisibility::OnlyDeleted => {
1250                Predicate::eq(column, Expr::Value(SqlValue::Bool(true)))
1251            }
1252            SoftDeleteVisibility::WithDeleted => unreachable!(),
1253        }));
1254    }
1255
1256    if indicator.nullable {
1257        return Ok(Some(match visibility {
1258            SoftDeleteVisibility::Default => Predicate::is_null(column),
1259            SoftDeleteVisibility::OnlyDeleted => Predicate::is_not_null(column),
1260            SoftDeleteVisibility::WithDeleted => unreachable!(),
1261        }));
1262    }
1263
1264    Err(OrmError::compile(
1265        "soft_delete query visibility requires the first policy column to be nullable or bit",
1266    ))
1267}
1268
1269fn apply_include_policy_filters<J: Entity + SoftDeleteEntity + TenantScopedEntity>(
1270    mut query: SelectQuery,
1271    active_tenant: Option<&ActiveTenant>,
1272    alias: &'static str,
1273) -> Result<SelectQuery, OrmError> {
1274    let target_table = TableRef::for_entity_as::<J>(alias);
1275    let mut predicates = Vec::new();
1276
1277    if let Some(predicate) = tenant_predicate_for::<J>(active_tenant, target_table)? {
1278        predicates.push(predicate);
1279    }
1280
1281    if let Some(predicate) =
1282        soft_delete_visibility_predicate_for::<J>(target_table, SoftDeleteVisibility::Default)?
1283    {
1284        predicates.push(predicate);
1285    }
1286
1287    if predicates.is_empty() {
1288        return Ok(query);
1289    }
1290
1291    let include_join = query
1292        .joins
1293        .iter_mut()
1294        .find(|join| join.table == target_table)
1295        .ok_or_else(|| {
1296            OrmError::compile(format!(
1297                "include join for entity `{}` with alias `{}` was not found",
1298                J::metadata().rust_name,
1299                alias
1300            ))
1301        })?;
1302
1303    let policy_predicate = if predicates.len() == 1 {
1304        predicates.remove(0)
1305    } else {
1306        Predicate::and(predicates)
1307    };
1308    include_join.on = Predicate::and(vec![include_join.on.clone(), policy_predicate]);
1309
1310    Ok(query)
1311}
1312
1313fn apply_include_projection<E: Entity, J: Entity>(
1314    mut query: SelectQuery,
1315    alias: &'static str,
1316) -> Result<SelectQuery, OrmError> {
1317    let mut projection = Vec::new();
1318
1319    projection.extend(E::metadata().columns.iter().map(|column| {
1320        SelectProjection::expr_as(
1321            Expr::Column(ColumnRef::new(
1322                query.from,
1323                column.rust_field,
1324                column.column_name,
1325            )),
1326            column.column_name,
1327        )
1328    }));
1329
1330    let target_table = TableRef::for_entity_as::<J>(alias);
1331    for column in J::metadata().columns {
1332        projection.push(SelectProjection::expr_as(
1333            Expr::Column(ColumnRef::new(
1334                target_table,
1335                column.rust_field,
1336                column.column_name,
1337            )),
1338            include_column_alias(alias, column.column_name),
1339        ));
1340    }
1341
1342    query.projection = projection;
1343    Ok(query)
1344}
1345
1346fn materialize_include_one<E, J>(
1347    row: &impl Row,
1348    navigation: &'static str,
1349    alias: &'static str,
1350    tracking_registry: Option<&TrackingRegistryHandle>,
1351) -> Result<E, OrmError>
1352where
1353    E: FromRow + IncludeNavigation<J>,
1354    J: Clone + Entity + FromRow + Send + Sync + 'static,
1355{
1356    let mut entity = E::from_row(row)?;
1357    let related = materialize_prefixed_entity::<J>(row, alias)?;
1358    let related_key = prefixed_primary_key_value::<J>(row, alias)?;
1359    let related =
1360        identity_mapped_optional_navigation_value(tracking_registry, related_key, related);
1361    entity.set_included_navigation(navigation, related)?;
1362    Ok(entity)
1363}
1364
1365fn materialize_prefixed_entity<J: Entity + FromRow>(
1366    row: &impl Row,
1367    alias: &'static str,
1368) -> Result<Option<J>, OrmError> {
1369    let prefix = include_prefix(alias);
1370    let mut saw_value = false;
1371
1372    for column in J::metadata().columns {
1373        let projected = prefixed_column_name(&prefix, column.column_name);
1374        if let Some(value) = row.try_get(&projected)? {
1375            if !value.is_null() {
1376                saw_value = true;
1377                break;
1378            }
1379        }
1380    }
1381
1382    if !saw_value {
1383        return Ok(None);
1384    }
1385
1386    Ok(Some(J::from_row(&PrefixedRow { row, prefix })?))
1387}
1388
1389struct IncludeManyRow<E, J> {
1390    root_key: Vec<SqlValue>,
1391    root: E,
1392    related_key: Option<SqlValue>,
1393    related: Option<J>,
1394}
1395
1396fn materialize_include_many_row<E, J>(
1397    row: &impl Row,
1398    alias: &'static str,
1399) -> Result<IncludeManyRow<E, J>, OrmError>
1400where
1401    E: Entity + FromRow,
1402    J: Entity + FromRow,
1403{
1404    Ok(IncludeManyRow {
1405        root_key: root_primary_key_values::<E>(row)?,
1406        root: E::from_row(row)?,
1407        related_key: prefixed_primary_key_value::<J>(row, alias)?,
1408        related: materialize_prefixed_entity::<J>(row, alias)?,
1409    })
1410}
1411
1412fn prefixed_primary_key_value<J: Entity>(
1413    row: &impl Row,
1414    alias: &'static str,
1415) -> Result<Option<SqlValue>, OrmError> {
1416    let metadata = J::metadata();
1417    if metadata.primary_key.columns.len() != 1 {
1418        return Ok(None);
1419    }
1420
1421    let prefix = include_prefix(alias);
1422    let column = prefixed_column_name(&prefix, metadata.primary_key.columns[0]);
1423    row.try_get(&column)
1424}
1425
1426fn root_primary_key_values<E: Entity>(row: &impl Row) -> Result<Vec<SqlValue>, OrmError> {
1427    let metadata = E::metadata();
1428    if metadata.primary_key.columns.is_empty() {
1429        return Err(OrmError::compile(format!(
1430            "include_many requires entity `{}` to declare a primary key for row grouping",
1431            metadata.rust_name
1432        )));
1433    }
1434
1435    metadata
1436        .primary_key
1437        .columns
1438        .iter()
1439        .map(|column_name| row.get_required(column_name))
1440        .collect()
1441}
1442
1443fn group_include_many_rows<E, J>(
1444    rows: Vec<IncludeManyRow<E, J>>,
1445    navigation: &'static str,
1446    tracking_registry: Option<&TrackingRegistryHandle>,
1447) -> Result<Vec<E>, OrmError>
1448where
1449    E: IncludeCollection<J>,
1450    J: Clone + Entity + Send + Sync + 'static,
1451{
1452    let mut grouped: Vec<(Vec<SqlValue>, E, Vec<J>)> = Vec::new();
1453
1454    for row in rows {
1455        let related = identity_mapped_optional_navigation_value(
1456            tracking_registry,
1457            row.related_key,
1458            row.related,
1459        );
1460        if let Some((_, _, related_values)) = grouped
1461            .iter_mut()
1462            .find(|(root_key, _, _)| *root_key == row.root_key)
1463        {
1464            if let Some(related) = related {
1465                related_values.push(related);
1466            }
1467            continue;
1468        }
1469
1470        let related_values = related.into_iter().collect();
1471        grouped.push((row.root_key, row.root, related_values));
1472    }
1473
1474    grouped
1475        .into_iter()
1476        .map(|(_, mut root, related_values)| {
1477            root.set_included_collection(navigation, related_values)?;
1478            Ok(root)
1479        })
1480        .collect()
1481}
1482
1483fn identity_mapped_optional_navigation_value<J>(
1484    tracking_registry: Option<&TrackingRegistryHandle>,
1485    key: Option<SqlValue>,
1486    value: Option<J>,
1487) -> Option<J>
1488where
1489    J: Clone + Entity + Send + Sync + 'static,
1490{
1491    value.map(|value| identity_mapped_navigation_value(tracking_registry, key, value))
1492}
1493
1494fn identity_mapped_navigation_value<J>(
1495    tracking_registry: Option<&TrackingRegistryHandle>,
1496    key: Option<SqlValue>,
1497    value: J,
1498) -> J
1499where
1500    J: Clone + Entity + Send + Sync + 'static,
1501{
1502    let Some(registry) = tracking_registry else {
1503        return value;
1504    };
1505    let Some(key) = key else {
1506        return value;
1507    };
1508
1509    registry.current_snapshot_for_key::<J>(key).unwrap_or(value)
1510}
1511
1512fn enforce_include_many_join_row_limit(
1513    row_count: usize,
1514    limit: Option<usize>,
1515) -> Result<(), OrmError> {
1516    let Some(limit) = limit else {
1517        return Ok(());
1518    };
1519
1520    if row_count > limit {
1521        return Err(OrmError::compile(format!(
1522            "include_many join produced {row_count} rows, exceeding the configured limit of {limit}; use max_joined_rows(...), unbounded_join(), or wait for split-query collection loading"
1523        )));
1524    }
1525
1526    Ok(())
1527}
1528
1529struct PrefixedRow<'a, R: Row + ?Sized> {
1530    row: &'a R,
1531    prefix: String,
1532}
1533
1534impl<R: Row + ?Sized> Row for PrefixedRow<'_, R> {
1535    fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
1536        self.row
1537            .try_get(&prefixed_column_name(&self.prefix, column))
1538    }
1539}
1540
1541fn include_prefix(alias: &'static str) -> String {
1542    format!("{alias}__")
1543}
1544
1545fn include_column_alias(alias: &'static str, column_name: &'static str) -> String {
1546    format!("{alias}__{column_name}")
1547}
1548
1549fn prefixed_column_name(prefix: &str, column_name: &str) -> String {
1550    format!("{prefix}{column_name}")
1551}
1552
1553fn metadata_column_expr(
1554    metadata: &'static EntityMetadata,
1555    table: TableRef,
1556    column_name: &str,
1557) -> Result<Expr, OrmError> {
1558    let column = metadata.column(column_name).ok_or_else(|| {
1559        OrmError::compile(format!(
1560            "entity `{}` metadata does not contain column `{}` required by navigation join",
1561            metadata.rust_name, column_name
1562        ))
1563    })?;
1564
1565    Ok(Expr::Column(ColumnRef::new(
1566        table,
1567        column.rust_field,
1568        column.column_name,
1569    )))
1570}
1571
1572pub(crate) fn tenant_value_matches_column_type(value: &SqlValue, column: &ColumnMetadata) -> bool {
1573    if value.is_null() {
1574        return false;
1575    }
1576
1577    match column.sql_type {
1578        SqlServerType::BigInt => matches!(value, SqlValue::I64(_)),
1579        SqlServerType::Int | SqlServerType::SmallInt | SqlServerType::TinyInt => {
1580            matches!(value, SqlValue::I32(_))
1581        }
1582        SqlServerType::Bit => matches!(value, SqlValue::Bool(_)),
1583        SqlServerType::UniqueIdentifier => matches!(value, SqlValue::Uuid(_)),
1584        SqlServerType::Date => matches!(value, SqlValue::Date(_)),
1585        SqlServerType::Time => matches!(value, SqlValue::Time(_)),
1586        SqlServerType::DateTime2 => matches!(value, SqlValue::DateTime(_)),
1587        SqlServerType::DateTimeOffset => matches!(value, SqlValue::DateTimeOffset(_)),
1588        SqlServerType::Decimal | SqlServerType::Money => matches!(value, SqlValue::Decimal(_)),
1589        SqlServerType::Float => matches!(value, SqlValue::F64(_)),
1590        SqlServerType::NVarChar | SqlServerType::Custom(_) => {
1591            matches!(value, SqlValue::String(_))
1592        }
1593        SqlServerType::VarBinary | SqlServerType::RowVersion => matches!(value, SqlValue::Bytes(_)),
1594    }
1595}
1596
1597impl<E: Entity> core::fmt::Debug for DbSetQuery<E> {
1598    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1599        f.debug_struct("DbSetQuery")
1600            .field("entity", &E::metadata().rust_name)
1601            .field("table", &E::metadata().table)
1602            .field("select_query", &self.select_query)
1603            .finish()
1604    }
1605}
1606
1607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1608struct CountRow {
1609    value: i64,
1610}
1611
1612impl FromRow for CountRow {
1613    fn from_row<R: Row>(row: &R) -> Result<Self, OrmError> {
1614        match row.get_required("count")? {
1615            SqlValue::I32(value) => Ok(Self {
1616                value: i64::from(value),
1617            }),
1618            SqlValue::I64(value) => Ok(Self { value }),
1619            _ => Err(OrmError::mapping(
1620                "expected SQL Server COUNT result as i32 or i64",
1621            )),
1622        }
1623    }
1624}
1625
1626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1627struct ExistsRow {
1628    value: bool,
1629}
1630
1631impl FromRow for ExistsRow {
1632    fn from_row<R: Row>(row: &R) -> Result<Self, OrmError> {
1633        Ok(Self {
1634            value: row.get_required_typed::<bool>("exists")?,
1635        })
1636    }
1637}
1638
1639#[derive(Debug, Clone, PartialEq)]
1640struct ScalarAggregateRow<T> {
1641    value: Option<T>,
1642}
1643
1644impl<T> FromRow for ScalarAggregateRow<T>
1645where
1646    T: SqlTypeMapping,
1647{
1648    fn from_row<R: Row>(row: &R) -> Result<Self, OrmError> {
1649        let value = row
1650            .try_get("value")?
1651            .ok_or_else(|| OrmError::mapping("scalar aggregate result column was not present"))?;
1652
1653        if value.is_null() {
1654            return Ok(Self { value: None });
1655        }
1656
1657        Ok(Self {
1658            value: Some(T::from_sql_value(value)?),
1659        })
1660    }
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665    use super::{
1666        DbSetQuery, enforce_include_many_join_row_limit, identity_mapped_navigation_value,
1667        tenant_value_matches_column_type, validate_aggregate_projection_aliases,
1668    };
1669    use crate::context::{ActiveTenant, DbSet};
1670    use crate::page_request::PageRequest;
1671    use crate::{
1672        EntityColumnAliasExt, IncludeCollection, SoftDeleteEntity, TenantScopedEntity, Tracked,
1673        TrackingRegistry,
1674    };
1675    use chrono::{NaiveDate, NaiveDateTime};
1676    use insta::assert_snapshot;
1677    use rust_decimal::Decimal;
1678    use sql_orm_core::{
1679        ColumnMetadata, Entity, EntityColumn, EntityMetadata, EntityPolicyMetadata, FromRow,
1680        NavigationKind, NavigationMetadata, OrmError, OrmErrorKind, PrimaryKeyMetadata, Row,
1681        SqlServerType, SqlValue,
1682    };
1683    use sql_orm_query::{
1684        AggregateExpr, AggregateOrderBy, AggregatePredicate, AggregateProjection, ColumnRef,
1685        CompiledQuery, Expr, Join, JoinType, OrderBy, Pagination, Predicate, SelectProjection,
1686        SelectQuery, SortDirection, TableRef,
1687    };
1688    use sql_orm_sqlserver::SqlServerCompiler;
1689    use std::borrow::Cow;
1690
1691    struct TestEntity;
1692    struct JoinedEntity;
1693    #[derive(Debug)]
1694    struct NavigationRoot;
1695    #[derive(Debug, Clone, PartialEq, Eq)]
1696    struct NavigationTarget {
1697        id: i64,
1698        owner_id: i64,
1699    }
1700    struct TenantNavigationRoot;
1701    struct TenantNavigationTarget;
1702    struct SoftDeleteEntityUnderTest;
1703    struct BoolSoftDeleteEntity;
1704    struct TenantEntity;
1705
1706    static TEST_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1707        rust_name: "TestEntity",
1708        schema: "dbo",
1709        table: "test_entities",
1710        renamed_from: None,
1711        columns: &[],
1712        primary_key: PrimaryKeyMetadata {
1713            name: None,
1714            columns: &[],
1715        },
1716        indexes: &[],
1717        foreign_keys: &[],
1718        navigations: &[],
1719    };
1720
1721    impl Entity for TestEntity {
1722        fn metadata() -> &'static EntityMetadata {
1723            &TEST_ENTITY_METADATA
1724        }
1725    }
1726
1727    #[allow(non_upper_case_globals)]
1728    impl TestEntity {
1729        const id: EntityColumn<TestEntity> = EntityColumn::new("id", "id");
1730        const name: EntityColumn<TestEntity> = EntityColumn::new("name", "name");
1731    }
1732
1733    static JOINED_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1734        rust_name: "JoinedEntity",
1735        schema: "dbo",
1736        table: "joined_entities",
1737        renamed_from: None,
1738        columns: &[],
1739        primary_key: PrimaryKeyMetadata {
1740            name: None,
1741            columns: &[],
1742        },
1743        indexes: &[],
1744        foreign_keys: &[],
1745        navigations: &[],
1746    };
1747
1748    impl Entity for JoinedEntity {
1749        fn metadata() -> &'static EntityMetadata {
1750            &JOINED_ENTITY_METADATA
1751        }
1752    }
1753
1754    static NAVIGATION_ROOT_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1755        rust_field: "id",
1756        column_name: "id",
1757        renamed_from: None,
1758        sql_type: SqlServerType::BigInt,
1759        nullable: false,
1760        primary_key: true,
1761        identity: None,
1762        default_sql: None,
1763        computed_sql: None,
1764        rowversion: false,
1765        insertable: false,
1766        updatable: false,
1767        max_length: None,
1768        precision: None,
1769        scale: None,
1770    }];
1771
1772    static NAVIGATION_TARGET_COLUMNS: [ColumnMetadata; 2] = [
1773        ColumnMetadata {
1774            rust_field: "id",
1775            column_name: "id",
1776            renamed_from: None,
1777            sql_type: SqlServerType::BigInt,
1778            nullable: false,
1779            primary_key: true,
1780            identity: None,
1781            default_sql: None,
1782            computed_sql: None,
1783            rowversion: false,
1784            insertable: false,
1785            updatable: false,
1786            max_length: None,
1787            precision: None,
1788            scale: None,
1789        },
1790        ColumnMetadata {
1791            rust_field: "owner_id",
1792            column_name: "owner_id",
1793            renamed_from: None,
1794            sql_type: SqlServerType::BigInt,
1795            nullable: false,
1796            primary_key: false,
1797            identity: None,
1798            default_sql: None,
1799            computed_sql: None,
1800            rowversion: false,
1801            insertable: true,
1802            updatable: true,
1803            max_length: None,
1804            precision: None,
1805            scale: None,
1806        },
1807    ];
1808
1809    static NAVIGATION_ROOT_NAVIGATIONS: [NavigationMetadata; 1] = [NavigationMetadata::new(
1810        "orders",
1811        NavigationKind::HasMany,
1812        "NavigationTarget",
1813        "sales",
1814        "navigation_targets",
1815        &["id"],
1816        &["owner_id"],
1817        Some("fk_navigation_targets_owner"),
1818    )];
1819
1820    static NAVIGATION_ROOT_METADATA: EntityMetadata = EntityMetadata {
1821        rust_name: "NavigationRoot",
1822        schema: "dbo",
1823        table: "navigation_roots",
1824        renamed_from: None,
1825        columns: &NAVIGATION_ROOT_COLUMNS,
1826        primary_key: PrimaryKeyMetadata {
1827            name: None,
1828            columns: &["id"],
1829        },
1830        indexes: &[],
1831        foreign_keys: &[],
1832        navigations: &NAVIGATION_ROOT_NAVIGATIONS,
1833    };
1834
1835    static NAVIGATION_TARGET_NAVIGATIONS: [NavigationMetadata; 1] = [NavigationMetadata::new(
1836        "owner",
1837        NavigationKind::BelongsTo,
1838        "NavigationRoot",
1839        "dbo",
1840        "navigation_roots",
1841        &["owner_id"],
1842        &["id"],
1843        Some("fk_navigation_targets_owner"),
1844    )];
1845
1846    static NAVIGATION_TARGET_METADATA: EntityMetadata = EntityMetadata {
1847        rust_name: "NavigationTarget",
1848        schema: "sales",
1849        table: "navigation_targets",
1850        renamed_from: None,
1851        columns: &NAVIGATION_TARGET_COLUMNS,
1852        primary_key: PrimaryKeyMetadata {
1853            name: None,
1854            columns: &["id"],
1855        },
1856        indexes: &[],
1857        foreign_keys: &[],
1858        navigations: &NAVIGATION_TARGET_NAVIGATIONS,
1859    };
1860
1861    impl Entity for NavigationRoot {
1862        fn metadata() -> &'static EntityMetadata {
1863            &NAVIGATION_ROOT_METADATA
1864        }
1865    }
1866
1867    impl Entity for NavigationTarget {
1868        fn metadata() -> &'static EntityMetadata {
1869            &NAVIGATION_TARGET_METADATA
1870        }
1871    }
1872
1873    #[allow(non_upper_case_globals)]
1874    impl NavigationTarget {
1875        const id: EntityColumn<NavigationTarget> = EntityColumn::new("id", "id");
1876        const owner_id: EntityColumn<NavigationTarget> = EntityColumn::new("owner_id", "owner_id");
1877    }
1878
1879    impl FromRow for NavigationRoot {
1880        fn from_row<R: Row>(_row: &R) -> Result<Self, OrmError> {
1881            Ok(Self)
1882        }
1883    }
1884
1885    impl FromRow for NavigationTarget {
1886        fn from_row<R: Row>(row: &R) -> Result<Self, OrmError> {
1887            Ok(Self {
1888                id: required_i64(row, "id")?,
1889                owner_id: required_i64(row, "owner_id")?,
1890            })
1891        }
1892    }
1893
1894    fn required_i64<R: Row>(row: &R, column: &str) -> Result<i64, OrmError> {
1895        match row.get_required(column)? {
1896            SqlValue::I64(value) => Ok(value),
1897            value => Err(OrmError::mapping(format!(
1898                "expected `{column}` as i64, got {value:?}"
1899            ))),
1900        }
1901    }
1902
1903    static SOFT_DELETE_POLICY_COLUMNS: [ColumnMetadata; 2] = [
1904        ColumnMetadata {
1905            rust_field: "deleted_at",
1906            column_name: "deleted_at",
1907            renamed_from: None,
1908            sql_type: SqlServerType::DateTime2,
1909            nullable: true,
1910            primary_key: false,
1911            identity: None,
1912            default_sql: None,
1913            computed_sql: None,
1914            rowversion: false,
1915            insertable: false,
1916            updatable: true,
1917            max_length: None,
1918            precision: None,
1919            scale: None,
1920        },
1921        ColumnMetadata {
1922            rust_field: "deleted_by",
1923            column_name: "deleted_by",
1924            renamed_from: None,
1925            sql_type: SqlServerType::NVarChar,
1926            nullable: true,
1927            primary_key: false,
1928            identity: None,
1929            default_sql: None,
1930            computed_sql: None,
1931            rowversion: false,
1932            insertable: false,
1933            updatable: true,
1934            max_length: Some(120),
1935            precision: None,
1936            scale: None,
1937        },
1938    ];
1939
1940    static BOOL_SOFT_DELETE_POLICY_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1941        rust_field: "is_deleted",
1942        column_name: "is_deleted",
1943        renamed_from: None,
1944        sql_type: SqlServerType::Bit,
1945        nullable: false,
1946        primary_key: false,
1947        identity: None,
1948        default_sql: Some("0"),
1949        computed_sql: None,
1950        rowversion: false,
1951        insertable: false,
1952        updatable: true,
1953        max_length: None,
1954        precision: None,
1955        scale: None,
1956    }];
1957
1958    static SOFT_DELETE_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1959        rust_name: "SoftDeleteEntityUnderTest",
1960        schema: "dbo",
1961        table: "soft_delete_entities",
1962        renamed_from: None,
1963        columns: &[],
1964        primary_key: PrimaryKeyMetadata {
1965            name: None,
1966            columns: &[],
1967        },
1968        indexes: &[],
1969        foreign_keys: &[],
1970        navigations: &[],
1971    };
1972
1973    static BOOL_SOFT_DELETE_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1974        rust_name: "BoolSoftDeleteEntity",
1975        schema: "dbo",
1976        table: "bool_soft_delete_entities",
1977        renamed_from: None,
1978        columns: &[],
1979        primary_key: PrimaryKeyMetadata {
1980            name: None,
1981            columns: &[],
1982        },
1983        indexes: &[],
1984        foreign_keys: &[],
1985        navigations: &[],
1986    };
1987
1988    static TENANT_POLICY_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1989        rust_field: "tenant_id",
1990        column_name: "tenant_id",
1991        renamed_from: None,
1992        sql_type: SqlServerType::BigInt,
1993        nullable: false,
1994        primary_key: false,
1995        identity: None,
1996        default_sql: None,
1997        computed_sql: None,
1998        rowversion: false,
1999        insertable: true,
2000        updatable: false,
2001        max_length: None,
2002        precision: None,
2003        scale: None,
2004    }];
2005
2006    static TENANT_ENTITY_METADATA: EntityMetadata = EntityMetadata {
2007        rust_name: "TenantEntity",
2008        schema: "sales",
2009        table: "tenant_entities",
2010        renamed_from: None,
2011        columns: &TENANT_POLICY_COLUMNS,
2012        primary_key: PrimaryKeyMetadata {
2013            name: None,
2014            columns: &[],
2015        },
2016        indexes: &[],
2017        foreign_keys: &[],
2018        navigations: &[],
2019    };
2020
2021    static TENANT_NAVIGATION_ROOT_COLUMNS: [ColumnMetadata; 2] = [
2022        ColumnMetadata {
2023            rust_field: "id",
2024            column_name: "id",
2025            renamed_from: None,
2026            sql_type: SqlServerType::BigInt,
2027            nullable: false,
2028            primary_key: true,
2029            identity: None,
2030            default_sql: None,
2031            computed_sql: None,
2032            rowversion: false,
2033            insertable: false,
2034            updatable: false,
2035            max_length: None,
2036            precision: None,
2037            scale: None,
2038        },
2039        TENANT_POLICY_COLUMNS[0],
2040    ];
2041
2042    static TENANT_NAVIGATION_TARGET_COLUMNS: [ColumnMetadata; 2] = [
2043        ColumnMetadata {
2044            rust_field: "id",
2045            column_name: "id",
2046            renamed_from: None,
2047            sql_type: SqlServerType::BigInt,
2048            nullable: false,
2049            primary_key: true,
2050            identity: None,
2051            default_sql: None,
2052            computed_sql: None,
2053            rowversion: false,
2054            insertable: false,
2055            updatable: false,
2056            max_length: None,
2057            precision: None,
2058            scale: None,
2059        },
2060        ColumnMetadata {
2061            rust_field: "owner_id",
2062            column_name: "owner_id",
2063            renamed_from: None,
2064            sql_type: SqlServerType::BigInt,
2065            nullable: false,
2066            primary_key: false,
2067            identity: None,
2068            default_sql: None,
2069            computed_sql: None,
2070            rowversion: false,
2071            insertable: true,
2072            updatable: true,
2073            max_length: None,
2074            precision: None,
2075            scale: None,
2076        },
2077    ];
2078
2079    static TENANT_NAVIGATION_TARGET_NAVIGATIONS: [NavigationMetadata; 1] =
2080        [NavigationMetadata::new(
2081            "owner",
2082            NavigationKind::BelongsTo,
2083            "TenantNavigationRoot",
2084            "sales",
2085            "tenant_navigation_roots",
2086            &["owner_id"],
2087            &["id"],
2088            Some("fk_tenant_navigation_targets_owner"),
2089        )];
2090
2091    static TENANT_NAVIGATION_ROOT_METADATA: EntityMetadata = EntityMetadata {
2092        rust_name: "TenantNavigationRoot",
2093        schema: "sales",
2094        table: "tenant_navigation_roots",
2095        renamed_from: None,
2096        columns: &TENANT_NAVIGATION_ROOT_COLUMNS,
2097        primary_key: PrimaryKeyMetadata {
2098            name: None,
2099            columns: &["id"],
2100        },
2101        indexes: &[],
2102        foreign_keys: &[],
2103        navigations: &[],
2104    };
2105
2106    static TENANT_NAVIGATION_TARGET_METADATA: EntityMetadata = EntityMetadata {
2107        rust_name: "TenantNavigationTarget",
2108        schema: "sales",
2109        table: "tenant_navigation_targets",
2110        renamed_from: None,
2111        columns: &TENANT_NAVIGATION_TARGET_COLUMNS,
2112        primary_key: PrimaryKeyMetadata {
2113            name: None,
2114            columns: &["id"],
2115        },
2116        indexes: &[],
2117        foreign_keys: &[],
2118        navigations: &TENANT_NAVIGATION_TARGET_NAVIGATIONS,
2119    };
2120
2121    impl Entity for SoftDeleteEntityUnderTest {
2122        fn metadata() -> &'static EntityMetadata {
2123            &SOFT_DELETE_ENTITY_METADATA
2124        }
2125    }
2126
2127    impl Entity for BoolSoftDeleteEntity {
2128        fn metadata() -> &'static EntityMetadata {
2129            &BOOL_SOFT_DELETE_ENTITY_METADATA
2130        }
2131    }
2132
2133    impl Entity for TenantEntity {
2134        fn metadata() -> &'static EntityMetadata {
2135            &TENANT_ENTITY_METADATA
2136        }
2137    }
2138
2139    #[allow(non_upper_case_globals)]
2140    impl TenantEntity {
2141        const tenant_id: EntityColumn<TenantEntity> = EntityColumn::new("tenant_id", "tenant_id");
2142    }
2143
2144    impl Entity for TenantNavigationRoot {
2145        fn metadata() -> &'static EntityMetadata {
2146            &TENANT_NAVIGATION_ROOT_METADATA
2147        }
2148    }
2149
2150    impl Entity for TenantNavigationTarget {
2151        fn metadata() -> &'static EntityMetadata {
2152            &TENANT_NAVIGATION_TARGET_METADATA
2153        }
2154    }
2155
2156    impl SoftDeleteEntity for TestEntity {
2157        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2158            None
2159        }
2160    }
2161
2162    impl SoftDeleteEntity for JoinedEntity {
2163        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2164            None
2165        }
2166    }
2167
2168    impl SoftDeleteEntity for SoftDeleteEntityUnderTest {
2169        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2170            Some(EntityPolicyMetadata::new(
2171                "soft_delete",
2172                &SOFT_DELETE_POLICY_COLUMNS,
2173            ))
2174        }
2175    }
2176
2177    impl SoftDeleteEntity for BoolSoftDeleteEntity {
2178        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2179            Some(EntityPolicyMetadata::new(
2180                "soft_delete",
2181                &BOOL_SOFT_DELETE_POLICY_COLUMNS,
2182            ))
2183        }
2184    }
2185
2186    impl SoftDeleteEntity for TenantEntity {
2187        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2188            None
2189        }
2190    }
2191
2192    impl TenantScopedEntity for TestEntity {
2193        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2194            None
2195        }
2196    }
2197
2198    impl TenantScopedEntity for JoinedEntity {
2199        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2200            None
2201        }
2202    }
2203
2204    impl TenantScopedEntity for SoftDeleteEntityUnderTest {
2205        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2206            None
2207        }
2208    }
2209
2210    impl TenantScopedEntity for BoolSoftDeleteEntity {
2211        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2212            None
2213        }
2214    }
2215
2216    impl TenantScopedEntity for TenantEntity {
2217        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2218            Some(EntityPolicyMetadata::new("tenant", &TENANT_POLICY_COLUMNS))
2219        }
2220    }
2221
2222    impl SoftDeleteEntity for NavigationRoot {
2223        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2224            Some(EntityPolicyMetadata::new(
2225                "soft_delete",
2226                &SOFT_DELETE_POLICY_COLUMNS,
2227            ))
2228        }
2229    }
2230
2231    impl SoftDeleteEntity for NavigationTarget {
2232        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2233            None
2234        }
2235    }
2236
2237    impl SoftDeleteEntity for TenantNavigationRoot {
2238        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2239            None
2240        }
2241    }
2242
2243    impl SoftDeleteEntity for TenantNavigationTarget {
2244        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
2245            None
2246        }
2247    }
2248
2249    impl TenantScopedEntity for NavigationRoot {
2250        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2251            None
2252        }
2253    }
2254
2255    impl TenantScopedEntity for NavigationTarget {
2256        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2257            None
2258        }
2259    }
2260
2261    impl TenantScopedEntity for TenantNavigationRoot {
2262        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2263            Some(EntityPolicyMetadata::new("tenant", &TENANT_POLICY_COLUMNS))
2264        }
2265    }
2266
2267    impl TenantScopedEntity for TenantNavigationTarget {
2268        fn tenant_policy() -> Option<EntityPolicyMetadata> {
2269            None
2270        }
2271    }
2272
2273    impl IncludeCollection<NavigationTarget> for NavigationRoot {
2274        fn set_included_collection(
2275            &mut self,
2276            _navigation: &str,
2277            _values: Vec<NavigationTarget>,
2278        ) -> Result<(), OrmError> {
2279            Ok(())
2280        }
2281    }
2282
2283    #[derive(Debug)]
2284    struct TestProjectionRow;
2285
2286    impl FromRow for TestProjectionRow {
2287        fn from_row<R: Row>(_row: &R) -> Result<Self, OrmError> {
2288            Ok(Self)
2289        }
2290    }
2291
2292    #[test]
2293    fn dbset_query_starts_from_entity_select_query() {
2294        let dbset = DbSet::<TestEntity>::disconnected();
2295        let query = dbset.query();
2296
2297        assert_eq!(
2298            query.select_query(),
2299            &SelectQuery::from_entity::<TestEntity>()
2300        );
2301    }
2302
2303    #[test]
2304    fn dbset_query_accepts_replacement_select_query() {
2305        let dbset = DbSet::<TestEntity>::disconnected();
2306        let custom = SelectQuery::from_entity::<TestEntity>().filter(Predicate::eq(
2307            Expr::value(SqlValue::Bool(true)),
2308            Expr::value(SqlValue::Bool(true)),
2309        ));
2310
2311        let query = dbset.query().with_select_query(custom.clone());
2312
2313        assert_eq!(query.select_query(), &custom);
2314        assert_eq!(query.into_select_query(), custom);
2315    }
2316
2317    #[test]
2318    fn dbset_query_filter_builds_on_internal_select_query() {
2319        let dbset = DbSet::<TestEntity>::disconnected();
2320
2321        let query = dbset.query().filter(Predicate::eq(
2322            Expr::value(SqlValue::Bool(true)),
2323            Expr::value(SqlValue::Bool(true)),
2324        ));
2325
2326        assert_eq!(
2327            query.into_select_query(),
2328            SelectQuery::from_entity::<TestEntity>().filter(Predicate::eq(
2329                Expr::value(SqlValue::Bool(true)),
2330                Expr::value(SqlValue::Bool(true)),
2331            ))
2332        );
2333    }
2334
2335    #[test]
2336    fn dbset_query_select_builds_projection_with_aliases() {
2337        let dbset = DbSet::<TestEntity>::disconnected();
2338
2339        let query = dbset
2340            .query()
2341            .select((TestEntity::id, TestEntity::name))
2342            .into_select_query();
2343
2344        assert_eq!(
2345            query.projection,
2346            vec![
2347                SelectProjection::column(TestEntity::id),
2348                SelectProjection::column(TestEntity::name),
2349            ]
2350        );
2351    }
2352
2353    #[tokio::test]
2354    async fn dbset_query_all_as_reuses_projection_compilation_before_connection() {
2355        let dbset = DbSet::<TestEntity>::disconnected();
2356
2357        let error = dbset
2358            .query()
2359            .select(TestEntity::id)
2360            .all_as::<TestProjectionRow>()
2361            .await
2362            .unwrap_err();
2363
2364        assert_eq!(
2365            error.message(),
2366            "DbSetQuery requires an initialized shared connection"
2367        );
2368        assert_eq!(error.kind(), OrmErrorKind::Execution);
2369    }
2370
2371    #[tokio::test]
2372    async fn dbset_query_first_as_rejects_unaliased_expression_projection() {
2373        let dbset = DbSet::<TestEntity>::disconnected();
2374
2375        let error = dbset
2376            .query()
2377            .select(Expr::function(
2378                sql_orm_query::SqlFunction::Lower,
2379                vec![Expr::from(TestEntity::name)],
2380            ))
2381            .first_as::<TestProjectionRow>()
2382            .await
2383            .unwrap_err();
2384
2385        assert_eq!(
2386            error.message(),
2387            "SQL Server projection expressions require an explicit alias"
2388        );
2389    }
2390
2391    #[test]
2392    fn dbset_query_applies_active_only_visibility_for_nullable_indicator() {
2393        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
2394
2395        let query = dbset.query().effective_select_query().unwrap();
2396
2397        assert_eq!(
2398            query,
2399            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>().filter(Predicate::is_null(
2400                Expr::Column(sql_orm_query::ColumnRef::new(
2401                    TableRef::new("dbo", "soft_delete_entities"),
2402                    "deleted_at",
2403                    "deleted_at",
2404                )),
2405            ))
2406        );
2407    }
2408
2409    #[test]
2410    fn dbset_query_with_deleted_removes_soft_delete_filter() {
2411        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
2412
2413        let query = dbset
2414            .query()
2415            .with_deleted()
2416            .effective_select_query()
2417            .unwrap();
2418
2419        assert_eq!(
2420            query,
2421            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>()
2422        );
2423    }
2424
2425    #[test]
2426    fn dbset_query_only_deleted_filters_nullable_indicator() {
2427        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
2428
2429        let query = dbset
2430            .query()
2431            .only_deleted()
2432            .effective_select_query()
2433            .unwrap();
2434
2435        assert_eq!(
2436            query,
2437            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>().filter(Predicate::is_not_null(
2438                Expr::Column(sql_orm_query::ColumnRef::new(
2439                    TableRef::new("dbo", "soft_delete_entities"),
2440                    "deleted_at",
2441                    "deleted_at",
2442                ))
2443            ))
2444        );
2445    }
2446
2447    #[test]
2448    fn dbset_query_uses_bool_indicator_when_soft_delete_column_is_bit() {
2449        let dbset = DbSet::<BoolSoftDeleteEntity>::disconnected();
2450
2451        let active = dbset.query().effective_select_query().unwrap();
2452        let deleted = dbset
2453            .query()
2454            .only_deleted()
2455            .effective_select_query()
2456            .unwrap();
2457
2458        assert_eq!(
2459            active,
2460            SelectQuery::from_entity::<BoolSoftDeleteEntity>().filter(Predicate::eq(
2461                Expr::Column(sql_orm_query::ColumnRef::new(
2462                    TableRef::new("dbo", "bool_soft_delete_entities"),
2463                    "is_deleted",
2464                    "is_deleted",
2465                )),
2466                Expr::Value(SqlValue::Bool(false)),
2467            ))
2468        );
2469        assert_eq!(
2470            deleted,
2471            SelectQuery::from_entity::<BoolSoftDeleteEntity>().filter(Predicate::eq(
2472                Expr::Column(sql_orm_query::ColumnRef::new(
2473                    TableRef::new("dbo", "bool_soft_delete_entities"),
2474                    "is_deleted",
2475                    "is_deleted",
2476                )),
2477                Expr::Value(SqlValue::Bool(true)),
2478            ))
2479        );
2480    }
2481
2482    #[test]
2483    fn dbset_query_applies_active_tenant_filter_for_tenant_scoped_entities() {
2484        let query = DbSetQuery::<TenantEntity>::new(
2485            None,
2486            SelectQuery::from_entity::<TenantEntity>().filter(Predicate::eq(
2487                Expr::value(SqlValue::Bool(true)),
2488                Expr::value(SqlValue::Bool(true)),
2489            )),
2490        )
2491        .with_active_tenant_for_test(ActiveTenant {
2492            column_name: "tenant_id",
2493            value: SqlValue::I64(42),
2494        })
2495        .effective_select_query()
2496        .unwrap();
2497
2498        assert_eq!(
2499            query,
2500            SelectQuery::from_entity::<TenantEntity>()
2501                .filter(Predicate::eq(
2502                    Expr::value(SqlValue::Bool(true)),
2503                    Expr::value(SqlValue::Bool(true)),
2504                ))
2505                .filter(Predicate::eq(
2506                    Expr::Column(sql_orm_query::ColumnRef::new(
2507                        TableRef::new("sales", "tenant_entities"),
2508                        "tenant_id",
2509                        "tenant_id",
2510                    )),
2511                    Expr::Value(SqlValue::I64(42)),
2512                ))
2513        );
2514    }
2515
2516    #[test]
2517    fn tenant_security_guardrail_keeps_joined_read_sql_tenant_scoped() {
2518        let query = DbSetQuery::<TenantEntity>::new(
2519            None,
2520            SelectQuery::from_entity::<TenantEntity>().inner_join::<JoinedEntity>(Predicate::eq(
2521                Expr::value(SqlValue::Bool(true)),
2522                Expr::value(SqlValue::Bool(true)),
2523            )),
2524        )
2525        .with_active_tenant_for_test(ActiveTenant {
2526            column_name: "tenant_id",
2527            value: SqlValue::I64(42),
2528        })
2529        .effective_select_query()
2530        .unwrap();
2531
2532        let compiled = SqlServerCompiler::compile_select(&query).unwrap();
2533
2534        assert!(
2535            compiled.sql.contains("INNER JOIN [dbo].[joined_entities]"),
2536            "joined tenant read should preserve explicit joins: {}",
2537            compiled.sql
2538        );
2539        assert!(
2540            compiled
2541                .sql
2542                .contains("[sales].[tenant_entities].[tenant_id] = @P"),
2543            "joined tenant read must include tenant predicate on the root entity: {}",
2544            compiled.sql
2545        );
2546        assert!(
2547            compiled.params.contains(&SqlValue::I64(42)),
2548            "joined tenant read params must include active tenant value: {:?}",
2549            compiled.params
2550        );
2551    }
2552
2553    #[test]
2554    fn dbset_query_fails_closed_without_active_tenant_for_tenant_scoped_entities() {
2555        let error =
2556            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2557                .effective_select_query()
2558                .unwrap_err();
2559
2560        assert!(
2561            error
2562                .message()
2563                .contains("requires an active tenant in the DbContext")
2564        );
2565    }
2566
2567    #[test]
2568    fn dbset_query_rejects_mismatched_active_tenant_column() {
2569        let error =
2570            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2571                .with_active_tenant_for_test(ActiveTenant {
2572                    column_name: "company_id",
2573                    value: SqlValue::I64(42),
2574                })
2575                .effective_select_query()
2576                .unwrap_err();
2577
2578        assert!(error.message().contains("does not match"));
2579    }
2580
2581    #[test]
2582    fn dbset_query_rejects_incompatible_active_tenant_value() {
2583        let error =
2584            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2585                .with_active_tenant_for_test(ActiveTenant {
2586                    column_name: "tenant_id",
2587                    value: SqlValue::String("not-a-bigint".to_string()),
2588                })
2589                .effective_select_query()
2590                .unwrap_err();
2591
2592        assert!(error.message().contains("not compatible"));
2593    }
2594
2595    #[test]
2596    fn tenant_value_type_matching_rejects_null_even_for_nullable_columns() {
2597        assert!(!tenant_value_matches_column_type(
2598            &SqlValue::Null,
2599            &TENANT_POLICY_COLUMNS[0],
2600        ));
2601    }
2602
2603    #[test]
2604    fn dbset_query_order_by_builds_on_internal_select_query() {
2605        let dbset = DbSet::<TestEntity>::disconnected();
2606
2607        let query = dbset.query().order_by(OrderBy::new(
2608            TableRef::new("dbo", "test_entities"),
2609            "created_at",
2610            SortDirection::Desc,
2611        ));
2612
2613        assert_eq!(
2614            query.into_select_query(),
2615            SelectQuery::from_entity::<TestEntity>().order_by(OrderBy::new(
2616                TableRef::new("dbo", "test_entities"),
2617                "created_at",
2618                SortDirection::Desc,
2619            ))
2620        );
2621    }
2622
2623    #[test]
2624    fn dbset_query_join_builds_on_internal_select_query() {
2625        let dbset = DbSet::<TestEntity>::disconnected();
2626        let join = Join::left(
2627            TableRef::new("dbo", "joined_entities"),
2628            Predicate::eq(
2629                Expr::value(SqlValue::Bool(true)),
2630                Expr::value(SqlValue::Bool(true)),
2631            ),
2632        );
2633
2634        let query = dbset.query().join(join.clone());
2635
2636        assert_eq!(
2637            query.into_select_query(),
2638            SelectQuery::from_entity::<TestEntity>().join(join)
2639        );
2640    }
2641
2642    #[test]
2643    fn dbset_query_exposes_entity_targeted_join_helpers() {
2644        let dbset = DbSet::<TestEntity>::disconnected();
2645
2646        let query = dbset
2647            .query()
2648            .inner_join::<JoinedEntity>(Predicate::eq(
2649                Expr::value(SqlValue::Bool(true)),
2650                Expr::value(SqlValue::Bool(true)),
2651            ))
2652            .left_join::<JoinedEntity>(Predicate::eq(
2653                Expr::value(SqlValue::Bool(false)),
2654                Expr::value(SqlValue::Bool(false)),
2655            ));
2656
2657        let select = query.into_select_query();
2658
2659        assert_eq!(select.joins.len(), 2);
2660        assert_eq!(select.joins[0].join_type, JoinType::Inner);
2661        assert_eq!(
2662            select.joins[0].table,
2663            TableRef::new("dbo", "joined_entities")
2664        );
2665        assert_eq!(select.joins[1].join_type, JoinType::Left);
2666        assert_eq!(
2667            select.joins[1].table,
2668            TableRef::new("dbo", "joined_entities")
2669        );
2670    }
2671
2672    #[test]
2673    fn dbset_query_infers_navigation_join_from_metadata() {
2674        let dbset = DbSet::<NavigationRoot>::disconnected();
2675
2676        let select = dbset
2677            .query()
2678            .try_inner_join_navigation::<NavigationTarget>("orders")
2679            .unwrap()
2680            .into_select_query();
2681
2682        assert_eq!(select.joins.len(), 1);
2683        assert_eq!(select.joins[0].join_type, JoinType::Inner);
2684        assert_eq!(
2685            select.joins[0].table,
2686            TableRef::new("sales", "navigation_targets")
2687        );
2688        assert_eq!(
2689            select.joins[0].on,
2690            Predicate::eq(
2691                Expr::Column(ColumnRef::new(
2692                    TableRef::new("dbo", "navigation_roots"),
2693                    "id",
2694                    "id",
2695                )),
2696                Expr::Column(ColumnRef::new(
2697                    TableRef::new("sales", "navigation_targets"),
2698                    "owner_id",
2699                    "owner_id",
2700                )),
2701            )
2702        );
2703    }
2704
2705    #[test]
2706    fn dbset_query_infers_aliased_navigation_join_from_metadata() {
2707        let dbset = DbSet::<NavigationRoot>::disconnected();
2708
2709        let select = dbset
2710            .query()
2711            .try_left_join_navigation_as::<NavigationTarget>("orders", "orders")
2712            .unwrap()
2713            .into_select_query();
2714
2715        assert_eq!(select.joins.len(), 1);
2716        assert_eq!(select.joins[0].join_type, JoinType::Left);
2717        assert_eq!(
2718            select.joins[0].table,
2719            TableRef::with_alias("sales", "navigation_targets", "orders")
2720        );
2721        assert_eq!(
2722            select.joins[0].on,
2723            Predicate::eq(
2724                Expr::Column(ColumnRef::new(
2725                    TableRef::new("dbo", "navigation_roots"),
2726                    "id",
2727                    "id",
2728                )),
2729                Expr::Column(ColumnRef::new(
2730                    TableRef::with_alias("sales", "navigation_targets", "orders"),
2731                    "owner_id",
2732                    "owner_id",
2733                )),
2734            )
2735        );
2736    }
2737
2738    #[test]
2739    fn dbset_query_rejects_unknown_navigation_join() {
2740        let error = DbSet::<NavigationRoot>::disconnected()
2741            .query()
2742            .try_inner_join_navigation::<NavigationTarget>("missing")
2743            .unwrap_err();
2744
2745        assert!(
2746            error
2747                .message()
2748                .contains("does not declare navigation `missing`")
2749        );
2750    }
2751
2752    #[test]
2753    fn dbset_query_rejects_navigation_join_target_mismatch() {
2754        let error = DbSet::<NavigationRoot>::disconnected()
2755            .query()
2756            .try_inner_join_navigation::<JoinedEntity>("orders")
2757            .unwrap_err();
2758
2759        assert!(
2760            error
2761                .message()
2762                .contains("targets `sales.navigation_targets`")
2763        );
2764    }
2765
2766    #[test]
2767    fn dbset_query_include_projects_root_and_prefixed_related_columns() {
2768        let include = DbSet::<NavigationTarget>::disconnected()
2769            .query()
2770            .include_as::<NavigationRoot>("owner", "owner")
2771            .unwrap();
2772
2773        let select = include.select_query().unwrap();
2774
2775        assert_eq!(select.joins.len(), 1);
2776        assert_eq!(select.joins[0].join_type, JoinType::Left);
2777        assert_eq!(
2778            select.joins[0].table,
2779            TableRef::with_alias("dbo", "navigation_roots", "owner")
2780        );
2781        assert_eq!(select.projection.len(), 3);
2782        assert_eq!(select.projection[0].alias.as_deref(), Some("id"));
2783        assert_eq!(select.projection[1].alias.as_deref(), Some("owner_id"));
2784        assert_eq!(select.projection[2].alias.as_deref(), Some("owner__id"));
2785        assert!(matches!(
2786            select.projection[2].alias,
2787            Some(Cow::Owned(ref alias)) if alias == "owner__id"
2788        ));
2789    }
2790
2791    #[test]
2792    fn compiled_include_sql_preserves_projection_aliases_soft_delete_and_params() {
2793        let include = DbSet::<NavigationTarget>::disconnected()
2794            .query()
2795            .include_as::<NavigationRoot>("owner", "owner")
2796            .unwrap()
2797            .filter(Predicate::gt(
2798                Expr::Column(ColumnRef::new(
2799                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2800                    "id",
2801                    "id",
2802                )),
2803                Expr::value(SqlValue::I64(7)),
2804            ))
2805            .order_by(OrderBy::new(
2806                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2807                "id",
2808                SortDirection::Desc,
2809            ))
2810            .paginate(PageRequest::new(2, 10));
2811
2812        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
2813
2814        assert_snapshot!(
2815            "compiled_include_one_with_soft_delete_and_parameters",
2816            render_compiled_query(&compiled)
2817        );
2818    }
2819
2820    #[test]
2821    fn dbset_query_include_applies_included_soft_delete_filter_to_join_on() {
2822        let include = DbSet::<NavigationTarget>::disconnected()
2823            .query()
2824            .include_as::<NavigationRoot>("owner", "owner")
2825            .unwrap();
2826
2827        let select = include.select_query().unwrap();
2828
2829        assert_eq!(
2830            select.joins[0].on,
2831            Predicate::and(vec![
2832                Predicate::eq(
2833                    Expr::Column(ColumnRef::new(
2834                        TableRef::new("sales", "navigation_targets"),
2835                        "owner_id",
2836                        "owner_id",
2837                    )),
2838                    Expr::Column(ColumnRef::new(
2839                        TableRef::with_alias("dbo", "navigation_roots", "owner"),
2840                        "id",
2841                        "id",
2842                    )),
2843                ),
2844                Predicate::is_null(Expr::Column(ColumnRef::new(
2845                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2846                    "deleted_at",
2847                    "deleted_at",
2848                ))),
2849            ])
2850        );
2851    }
2852
2853    #[test]
2854    fn dbset_query_include_applies_included_tenant_filter_to_join_on() {
2855        let include = DbSet::<TenantNavigationTarget>::disconnected()
2856            .query()
2857            .with_active_tenant_for_test(ActiveTenant {
2858                column_name: "tenant_id",
2859                value: SqlValue::I64(42),
2860            })
2861            .include_as::<TenantNavigationRoot>("owner", "owner")
2862            .unwrap();
2863
2864        let select = include.select_query().unwrap();
2865
2866        assert_eq!(
2867            select.joins[0].on,
2868            Predicate::and(vec![
2869                Predicate::eq(
2870                    Expr::Column(ColumnRef::new(
2871                        TableRef::new("sales", "tenant_navigation_targets"),
2872                        "owner_id",
2873                        "owner_id",
2874                    )),
2875                    Expr::Column(ColumnRef::new(
2876                        TableRef::with_alias("sales", "tenant_navigation_roots", "owner"),
2877                        "id",
2878                        "id",
2879                    )),
2880                ),
2881                Predicate::eq(
2882                    Expr::Column(ColumnRef::new(
2883                        TableRef::with_alias("sales", "tenant_navigation_roots", "owner"),
2884                        "tenant_id",
2885                        "tenant_id",
2886                    )),
2887                    Expr::Value(SqlValue::I64(42)),
2888                ),
2889            ])
2890        );
2891    }
2892
2893    #[test]
2894    fn compiled_include_sql_preserves_included_tenant_parameter_order() {
2895        let include = DbSet::<TenantNavigationTarget>::disconnected()
2896            .query()
2897            .filter(Predicate::gt(
2898                Expr::Column(ColumnRef::new(
2899                    TableRef::new("sales", "tenant_navigation_targets"),
2900                    "id",
2901                    "id",
2902                )),
2903                Expr::value(SqlValue::I64(100)),
2904            ))
2905            .with_active_tenant_for_test(ActiveTenant {
2906                column_name: "tenant_id",
2907                value: SqlValue::I64(42),
2908            })
2909            .include_as::<TenantNavigationRoot>("owner", "owner")
2910            .unwrap();
2911
2912        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
2913
2914        assert_snapshot!(
2915            "compiled_include_one_with_included_tenant_parameter_order",
2916            render_compiled_query(&compiled)
2917        );
2918        assert_eq!(compiled.params, vec![SqlValue::I64(42), SqlValue::I64(100)]);
2919    }
2920
2921    #[test]
2922    fn dbset_query_include_fails_closed_for_included_tenant_without_active_tenant() {
2923        let include = DbSet::<TenantNavigationTarget>::disconnected()
2924            .query()
2925            .include_as::<TenantNavigationRoot>("owner", "owner")
2926            .unwrap();
2927
2928        let error = include.select_query().unwrap_err();
2929
2930        assert!(
2931            error
2932                .message()
2933                .contains("requires an active tenant in the DbContext")
2934        );
2935    }
2936
2937    #[test]
2938    fn dbset_query_include_supports_chained_filter_order_and_pagination() {
2939        let include = DbSet::<NavigationTarget>::disconnected()
2940            .query()
2941            .include_as::<NavigationRoot>("owner", "owner")
2942            .unwrap()
2943            .filter(Predicate::gt(
2944                Expr::Column(ColumnRef::new(
2945                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2946                    "id",
2947                    "id",
2948                )),
2949                Expr::value(SqlValue::I64(0)),
2950            ))
2951            .order_by(OrderBy::new(
2952                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2953                "id",
2954                SortDirection::Desc,
2955            ))
2956            .paginate(PageRequest::new(2, 10));
2957
2958        let select = include.select_query().unwrap();
2959
2960        assert_eq!(
2961            select.predicate,
2962            Some(Predicate::gt(
2963                Expr::Column(ColumnRef::new(
2964                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2965                    "id",
2966                    "id",
2967                )),
2968                Expr::value(SqlValue::I64(0)),
2969            ))
2970        );
2971        assert_eq!(
2972            select.order_by,
2973            vec![OrderBy::new(
2974                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2975                "id",
2976                SortDirection::Desc,
2977            )]
2978        );
2979        assert_eq!(select.pagination, Some(Pagination::new(10, 10)));
2980    }
2981
2982    #[test]
2983    fn dbset_query_include_rejects_collection_navigation() {
2984        let result = DbSet::<NavigationRoot>::disconnected()
2985            .query()
2986            .include::<NavigationTarget>("orders");
2987        let error = match result {
2988            Ok(_) => panic!("expected collection include to be rejected"),
2989            Err(error) => error,
2990        };
2991
2992        assert!(error.message().contains("belongs_to and has_one"));
2993    }
2994
2995    #[test]
2996    fn dbset_query_include_many_projects_root_and_prefixed_related_columns() {
2997        let include = DbSet::<NavigationRoot>::disconnected()
2998            .query()
2999            .include_many_as::<NavigationTarget>("orders", "orders")
3000            .unwrap();
3001
3002        let select = include.select_query().unwrap();
3003
3004        assert_eq!(select.joins.len(), 1);
3005        assert_eq!(select.joins[0].join_type, JoinType::Left);
3006        assert_eq!(
3007            select.joins[0].table,
3008            TableRef::with_alias("sales", "navigation_targets", "orders")
3009        );
3010        assert_eq!(select.projection.len(), 3);
3011        assert_eq!(select.projection[0].alias.as_deref(), Some("id"));
3012        assert_eq!(select.projection[1].alias.as_deref(), Some("orders__id"));
3013        assert_eq!(
3014            select.projection[2].alias.as_deref(),
3015            Some("orders__owner_id")
3016        );
3017        assert!(matches!(
3018            select.projection[2].alias,
3019            Some(Cow::Owned(ref alias)) if alias == "orders__owner_id"
3020        ));
3021    }
3022
3023    #[test]
3024    fn compiled_include_many_sql_preserves_grouping_projection_and_root_soft_delete() {
3025        let include = DbSet::<NavigationRoot>::disconnected()
3026            .query()
3027            .include_many_as::<NavigationTarget>("orders", "orders")
3028            .unwrap()
3029            .filter(Predicate::gte(
3030                Expr::Column(ColumnRef::new(
3031                    TableRef::with_alias("sales", "navigation_targets", "orders"),
3032                    "owner_id",
3033                    "owner_id",
3034                )),
3035                Expr::value(SqlValue::I64(7)),
3036            ))
3037            .order_by(OrderBy::new(
3038                TableRef::with_alias("sales", "navigation_targets", "orders"),
3039                "id",
3040                SortDirection::Asc,
3041            ));
3042
3043        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
3044
3045        assert_snapshot!(
3046            "compiled_include_many_with_root_soft_delete_and_parameters",
3047            render_compiled_query(&compiled)
3048        );
3049        assert_eq!(compiled.params, vec![SqlValue::I64(7)]);
3050    }
3051
3052    #[test]
3053    fn dbset_query_include_many_rejects_non_collection_navigation() {
3054        let result = DbSet::<NavigationTarget>::disconnected()
3055            .query()
3056            .include_many::<NavigationRoot>("owner");
3057        let error = match result {
3058            Ok(_) => panic!("expected non-collection include_many to be rejected"),
3059            Err(error) => error,
3060        };
3061
3062        assert!(error.message().contains("has_many"));
3063    }
3064
3065    #[test]
3066    fn dbset_query_include_many_rejects_pagination_for_join_grouping() {
3067        let include = DbSet::<NavigationRoot>::disconnected()
3068            .query()
3069            .take(10)
3070            .include_many_as::<NavigationTarget>("orders", "orders")
3071            .unwrap();
3072
3073        let error = include.select_query().unwrap_err();
3074
3075        assert!(error.message().contains("does not support pagination"));
3076    }
3077
3078    #[tokio::test]
3079    async fn dbset_query_include_many_split_query_reports_explicit_error() {
3080        let error = DbSet::<NavigationRoot>::disconnected()
3081            .query()
3082            .include_many_as::<NavigationTarget>("orders", "orders")
3083            .unwrap()
3084            .split_query()
3085            .all()
3086            .await
3087            .unwrap_err();
3088
3089        assert!(
3090            error
3091                .message()
3092                .contains("split-query loading is not implemented yet")
3093        );
3094    }
3095
3096    #[test]
3097    fn include_many_join_row_limit_reports_clear_error() {
3098        let error = enforce_include_many_join_row_limit(11, Some(10)).unwrap_err();
3099
3100        assert!(error.message().contains("produced 11 rows"));
3101        assert!(error.message().contains("configured limit of 10"));
3102    }
3103
3104    #[test]
3105    fn include_many_join_row_limit_allows_explicit_unbounded_join() {
3106        enforce_include_many_join_row_limit(usize::MAX, None).unwrap();
3107    }
3108
3109    #[test]
3110    fn include_navigation_identity_map_helper_reuses_tracked_snapshot_without_registering() {
3111        let registry = std::sync::Arc::new(TrackingRegistry::default());
3112        let mut tracked = Tracked::from_loaded(NavigationTarget { id: 7, owner_id: 1 });
3113        tracked
3114            .attach_registry_loaded(std::sync::Arc::clone(&registry), SqlValue::I64(7))
3115            .unwrap();
3116        tracked.current_mut().owner_id = 99;
3117
3118        let materialized = NavigationTarget { id: 7, owner_id: 1 };
3119        let mapped =
3120            identity_mapped_navigation_value(Some(&registry), Some(SqlValue::I64(7)), materialized);
3121
3122        assert_eq!(mapped.owner_id, 99);
3123        assert_eq!(registry.tracked_for::<NavigationTarget>().len(), 1);
3124
3125        let ordinary = identity_mapped_navigation_value(
3126            Some(&registry),
3127            Some(SqlValue::I64(8)),
3128            NavigationTarget { id: 8, owner_id: 2 },
3129        );
3130        assert_eq!(ordinary.owner_id, 2);
3131        assert_eq!(registry.tracked_for::<NavigationTarget>().len(), 1);
3132    }
3133
3134    #[test]
3135    fn compiled_self_join_sql_preserves_repeated_aliases_and_parameter_order() {
3136        let query = SelectQuery::from_entity_as::<TestEntity>("root")
3137            .select(vec![
3138                SelectProjection::expr_as(Expr::column_as(TestEntity::id, "root"), "root_id"),
3139                SelectProjection::expr_as(
3140                    Expr::column_as(TestEntity::name, "parent"),
3141                    "parent_name",
3142                ),
3143                SelectProjection::expr_as(Expr::column_as(TestEntity::name, "child"), "child_name"),
3144            ])
3145            .inner_join_as::<TestEntity>(
3146                "parent",
3147                Predicate::eq(
3148                    Expr::column_as(TestEntity::id, "root"),
3149                    Expr::column_as(TestEntity::id, "parent"),
3150                ),
3151            )
3152            .left_join_as::<TestEntity>(
3153                "child",
3154                Predicate::eq(
3155                    Expr::column_as(TestEntity::id, "root"),
3156                    Expr::column_as(TestEntity::id, "child"),
3157                ),
3158            )
3159            .filter(Predicate::like(
3160                Expr::column_as(TestEntity::name, "parent"),
3161                Expr::value(SqlValue::String("%admin%".to_string())),
3162            ))
3163            .filter(Predicate::gte(
3164                Expr::column_as(TestEntity::id, "child"),
3165                Expr::value(SqlValue::I64(10)),
3166            ))
3167            .order_by(OrderBy::new(
3168                TableRef::with_alias("dbo", "test_entities", "child"),
3169                "name",
3170                SortDirection::Asc,
3171            ))
3172            .paginate(Pagination::new(20, 10));
3173
3174        let compiled = SqlServerCompiler::compile_select(&query).unwrap();
3175
3176        assert_snapshot!(
3177            "compiled_self_join_repeated_aliases_and_parameter_order",
3178            render_compiled_query(&compiled)
3179        );
3180        assert_eq!(
3181            compiled.params,
3182            vec![
3183                SqlValue::String("%admin%".to_string()),
3184                SqlValue::I64(10),
3185                SqlValue::I64(20),
3186                SqlValue::I64(10),
3187            ]
3188        );
3189    }
3190
3191    #[test]
3192    fn dbset_query_supports_chaining_filter_and_order_by() {
3193        let dbset = DbSet::<TestEntity>::disconnected();
3194
3195        let query = dbset
3196            .query()
3197            .filter(Predicate::eq(
3198                Expr::value(SqlValue::Bool(true)),
3199                Expr::value(SqlValue::Bool(true)),
3200            ))
3201            .order_by(OrderBy::new(
3202                TableRef::new("dbo", "test_entities"),
3203                "created_at",
3204                SortDirection::Asc,
3205            ));
3206
3207        assert_eq!(
3208            query.into_select_query(),
3209            SelectQuery::from_entity::<TestEntity>()
3210                .filter(Predicate::eq(
3211                    Expr::value(SqlValue::Bool(true)),
3212                    Expr::value(SqlValue::Bool(true)),
3213                ))
3214                .order_by(OrderBy::new(
3215                    TableRef::new("dbo", "test_entities"),
3216                    "created_at",
3217                    SortDirection::Asc,
3218                ))
3219        );
3220    }
3221
3222    #[test]
3223    fn dbset_query_limit_builds_zero_offset_pagination() {
3224        let dbset = DbSet::<TestEntity>::disconnected();
3225
3226        let query = dbset.query().limit(25);
3227
3228        assert_eq!(
3229            query.into_select_query(),
3230            SelectQuery::from_entity::<TestEntity>().paginate(Pagination::new(0, 25))
3231        );
3232    }
3233
3234    #[test]
3235    fn dbset_query_take_is_alias_for_limit() {
3236        let dbset = DbSet::<TestEntity>::disconnected();
3237
3238        let limited = dbset.query().limit(10).into_select_query();
3239        let taken = dbset.query().take(10).into_select_query();
3240
3241        assert_eq!(limited, taken);
3242    }
3243
3244    #[test]
3245    fn dbset_query_paginate_uses_page_request_contract() {
3246        let dbset = DbSet::<TestEntity>::disconnected();
3247
3248        let query = dbset.query().paginate(PageRequest::new(3, 25));
3249
3250        assert_eq!(
3251            query.into_select_query(),
3252            SelectQuery::from_entity::<TestEntity>().paginate(Pagination::new(50, 25))
3253        );
3254    }
3255
3256    #[test]
3257    fn count_row_accepts_i32_and_i64_results() {
3258        struct CountTestRow {
3259            value: SqlValue,
3260        }
3261
3262        impl Row for CountTestRow {
3263            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
3264                Ok((column == "count").then(|| self.value.clone()))
3265            }
3266        }
3267
3268        let from_i32 = super::CountRow::from_row(&CountTestRow {
3269            value: SqlValue::I32(7),
3270        })
3271        .unwrap();
3272        let from_i64 = super::CountRow::from_row(&CountTestRow {
3273            value: SqlValue::I64(9),
3274        })
3275        .unwrap();
3276
3277        assert_eq!(from_i32.value, 7);
3278        assert_eq!(from_i64.value, 9);
3279    }
3280
3281    #[test]
3282    fn count_row_rejects_non_integer_results() {
3283        struct CountTestRow;
3284
3285        impl Row for CountTestRow {
3286            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
3287                Ok((column == "count").then(|| SqlValue::String("7".to_string())))
3288            }
3289        }
3290
3291        let error = super::CountRow::from_row(&CountTestRow).unwrap_err();
3292
3293        assert_eq!(
3294            error.message(),
3295            "expected SQL Server COUNT result as i32 or i64"
3296        );
3297        assert_eq!(error.kind(), OrmErrorKind::Mapping);
3298    }
3299
3300    #[test]
3301    fn exists_query_preserves_joins_and_effective_filters() {
3302        let active_tenant = ActiveTenant {
3303            column_name: "tenant_id",
3304            value: SqlValue::I64(42),
3305        };
3306        let dbset = DbSet::<TenantEntity>::disconnected();
3307        let query = dbset
3308            .query()
3309            .with_active_tenant_for_test(active_tenant)
3310            .inner_join::<JoinedEntity>(Predicate::eq(
3311                Expr::Column(ColumnRef::new(
3312                    TableRef::for_entity::<TenantEntity>(),
3313                    "tenant_id",
3314                    "tenant_id",
3315                )),
3316                Expr::Column(ColumnRef::new(
3317                    TableRef::for_entity::<JoinedEntity>(),
3318                    "tenant_id",
3319                    "tenant_id",
3320                )),
3321            ))
3322            .filter(Predicate::eq(
3323                Expr::Column(ColumnRef::new(
3324                    TableRef::for_entity::<TenantEntity>(),
3325                    "tenant_id",
3326                    "tenant_id",
3327                )),
3328                Expr::value(SqlValue::I64(7)),
3329            ));
3330
3331        let exists = query.exists_query().unwrap();
3332
3333        assert_eq!(exists.joins.len(), 1);
3334        let compiled = SqlServerCompiler::compile_exists(&exists).unwrap();
3335        assert!(compiled.sql.contains("INNER JOIN [dbo].[joined_entities]"));
3336        assert!(
3337            compiled
3338                .sql
3339                .contains("[sales].[tenant_entities].[tenant_id] = @P1")
3340        );
3341        assert!(
3342            compiled
3343                .sql
3344                .contains("[sales].[tenant_entities].[tenant_id] = @P2")
3345        );
3346        assert_eq!(compiled.params, vec![SqlValue::I64(7), SqlValue::I64(42)]);
3347    }
3348
3349    #[test]
3350    fn scalar_aggregate_query_preserves_soft_delete_filter_and_ignores_pagination() {
3351        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
3352        let query = dbset
3353            .query()
3354            .filter(Predicate::eq(
3355                Expr::value(SqlValue::Bool(true)),
3356                Expr::value(SqlValue::Bool(true)),
3357            ))
3358            .order_by(OrderBy::new(
3359                TableRef::for_entity::<SoftDeleteEntityUnderTest>(),
3360                "deleted_at",
3361                SortDirection::Desc,
3362            ))
3363            .limit(5);
3364
3365        let aggregate = query
3366            .scalar_aggregate_query(AggregateProjection::expr_as(
3367                AggregateExpr::max(Expr::Column(ColumnRef::new(
3368                    TableRef::for_entity::<SoftDeleteEntityUnderTest>(),
3369                    "deleted_at",
3370                    "deleted_at",
3371                ))),
3372                "value",
3373            ))
3374            .unwrap();
3375
3376        assert!(aggregate.order_by.is_empty());
3377        assert!(aggregate.pagination.is_none());
3378        let compiled = SqlServerCompiler::compile_aggregate(&aggregate).unwrap();
3379        assert!(
3380            compiled
3381                .sql
3382                .starts_with("SELECT MAX([dbo].[soft_delete_entities].[deleted_at]) AS [value]")
3383        );
3384        assert!(
3385            compiled
3386                .sql
3387                .contains("[dbo].[soft_delete_entities].[deleted_at] IS NULL")
3388        );
3389        assert!(!compiled.sql.contains("ORDER BY"));
3390        assert!(!compiled.sql.contains("OFFSET"));
3391        assert_eq!(
3392            compiled.params,
3393            vec![SqlValue::Bool(true), SqlValue::Bool(true)]
3394        );
3395    }
3396
3397    #[test]
3398    fn scalar_aggregate_query_preserves_explicit_join_and_aliased_column() {
3399        let query = DbSet::<NavigationRoot>::disconnected()
3400            .query()
3401            .try_left_join_navigation_as::<NavigationTarget>("orders", "orders")
3402            .unwrap()
3403            .filter(Predicate::gt(
3404                Expr::from(NavigationTarget::owner_id.aliased("orders")),
3405                Expr::value(SqlValue::I64(10)),
3406            ));
3407
3408        let aggregate = query
3409            .scalar_aggregate_query(AggregateProjection::expr_as(
3410                AggregateExpr::max(NavigationTarget::id.aliased("orders")),
3411                "value",
3412            ))
3413            .unwrap();
3414
3415        assert_eq!(aggregate.joins.len(), 1);
3416        assert_eq!(
3417            aggregate.joins[0].table,
3418            TableRef::with_alias("sales", "navigation_targets", "orders")
3419        );
3420
3421        let compiled = SqlServerCompiler::compile_aggregate(&aggregate).unwrap();
3422        assert!(compiled.sql.contains(
3423            "LEFT JOIN [sales].[navigation_targets] AS [orders] ON ([dbo].[navigation_roots].[id] = [orders].[owner_id])"
3424        ));
3425        assert!(
3426            compiled
3427                .sql
3428                .starts_with("SELECT MAX([orders].[id]) AS [value]")
3429        );
3430        assert!(compiled.sql.contains("WHERE ("));
3431        assert!(compiled.sql.contains("[orders].[owner_id] > @P1"));
3432        assert!(
3433            compiled
3434                .sql
3435                .contains("[dbo].[navigation_roots].[deleted_at] IS NULL")
3436        );
3437        assert_eq!(compiled.params, vec![SqlValue::I64(10)]);
3438    }
3439
3440    #[test]
3441    fn grouped_query_builds_aggregate_ast_with_projection_having_order_and_pagination() {
3442        let dbset = DbSet::<TestEntity>::disconnected();
3443        let grouped = dbset
3444            .query()
3445            .filter(Predicate::eq(
3446                Expr::value(SqlValue::Bool(true)),
3447                Expr::value(SqlValue::Bool(true)),
3448            ))
3449            .group_by(TestEntity::id)
3450            .unwrap()
3451            .select_aggregate((
3452                AggregateProjection::group_key(TestEntity::id),
3453                AggregateProjection::count_as("entity_count"),
3454            ))
3455            .having(AggregatePredicate::gt(
3456                AggregateExpr::count_all(),
3457                Expr::value(SqlValue::I64(1)),
3458            ))
3459            .order_by(AggregateOrderBy::desc(AggregateExpr::count_all()))
3460            .paginate(PageRequest::new(2, 10));
3461
3462        let aggregate = grouped.aggregate_query();
3463
3464        assert_eq!(aggregate.group_by, vec![Expr::from(TestEntity::id)]);
3465        assert_eq!(aggregate.projection.len(), 2);
3466        assert!(matches!(
3467            aggregate.having,
3468            Some(AggregatePredicate::Gt(_, _))
3469        ));
3470        assert_eq!(aggregate.order_by.len(), 1);
3471        assert_eq!(aggregate.pagination, Some(Pagination::new(10, 10)));
3472
3473        let compiled = SqlServerCompiler::compile_aggregate(aggregate).unwrap();
3474        assert!(compiled.sql.contains("COUNT(*) AS [entity_count]"));
3475        assert!(compiled.sql.contains("GROUP BY [dbo].[test_entities].[id]"));
3476        assert!(compiled.sql.contains("HAVING (COUNT(*) > @P3)"));
3477        assert!(compiled.sql.contains("ORDER BY COUNT(*) DESC"));
3478        assert!(
3479            compiled
3480                .sql
3481                .contains("OFFSET @P4 ROWS FETCH NEXT @P5 ROWS ONLY")
3482        );
3483        assert_eq!(
3484            compiled.params,
3485            vec![
3486                SqlValue::Bool(true),
3487                SqlValue::Bool(true),
3488                SqlValue::I64(1),
3489                SqlValue::I64(10),
3490                SqlValue::I64(10),
3491            ]
3492        );
3493    }
3494
3495    #[test]
3496    fn grouped_query_preserves_explicit_navigation_alias_join() {
3497        let grouped = DbSet::<NavigationRoot>::disconnected()
3498            .query()
3499            .try_left_join_navigation_as::<NavigationTarget>("orders", "orders")
3500            .unwrap()
3501            .filter(Predicate::gt(
3502                Expr::from(NavigationTarget::owner_id.aliased("orders")),
3503                Expr::value(SqlValue::I64(10)),
3504            ))
3505            .group_by(NavigationTarget::owner_id.aliased("orders"))
3506            .unwrap()
3507            .select_aggregate((
3508                NavigationTarget::owner_id.aliased("orders"),
3509                AggregateProjection::count_as("order_count"),
3510            ))
3511            .having(AggregatePredicate::gt(
3512                AggregateExpr::count_all(),
3513                Expr::value(SqlValue::I64(1)),
3514            ))
3515            .order_by(AggregateOrderBy::desc(AggregateExpr::count_all()));
3516
3517        let aggregate = grouped.aggregate_query();
3518
3519        assert_eq!(aggregate.joins.len(), 1);
3520        assert_eq!(
3521            aggregate.group_by,
3522            vec![Expr::from(NavigationTarget::owner_id.aliased("orders"))]
3523        );
3524        assert_eq!(aggregate.projection[0].alias, "owner_id");
3525
3526        let compiled = SqlServerCompiler::compile_aggregate(aggregate).unwrap();
3527        assert!(compiled.sql.contains(
3528            "LEFT JOIN [sales].[navigation_targets] AS [orders] ON ([dbo].[navigation_roots].[id] = [orders].[owner_id])"
3529        ));
3530        assert!(compiled.sql.contains("[orders].[owner_id] AS [owner_id]"));
3531        assert!(compiled.sql.contains("COUNT(*) AS [order_count]"));
3532        assert!(compiled.sql.contains("GROUP BY [orders].[owner_id]"));
3533        assert!(compiled.sql.contains("HAVING (COUNT(*) > @P2)"));
3534        assert!(compiled.sql.contains("ORDER BY COUNT(*) DESC"));
3535        assert!(
3536            compiled
3537                .sql
3538                .contains("[dbo].[navigation_roots].[deleted_at] IS NULL")
3539        );
3540        assert_eq!(compiled.params, vec![SqlValue::I64(10), SqlValue::I64(1)]);
3541    }
3542
3543    #[test]
3544    fn grouped_query_does_not_infer_hidden_join_from_aliased_group_key() {
3545        let grouped = DbSet::<NavigationRoot>::disconnected()
3546            .query()
3547            .group_by(NavigationTarget::owner_id.aliased("orders"))
3548            .unwrap()
3549            .select_aggregate((
3550                NavigationTarget::owner_id.aliased("orders"),
3551                AggregateProjection::count_as("order_count"),
3552            ));
3553
3554        let aggregate = grouped.aggregate_query();
3555
3556        assert!(aggregate.joins.is_empty());
3557        assert_eq!(
3558            aggregate.group_by,
3559            vec![Expr::from(NavigationTarget::owner_id.aliased("orders"))]
3560        );
3561
3562        let compiled = SqlServerCompiler::compile_aggregate(aggregate).unwrap();
3563        assert!(!compiled.sql.contains(" JOIN "));
3564        assert!(compiled.sql.contains("GROUP BY [orders].[owner_id]"));
3565    }
3566
3567    #[test]
3568    fn grouped_query_preserves_root_tenant_and_soft_delete_filters() {
3569        let active_tenant = ActiveTenant {
3570            column_name: "tenant_id",
3571            value: SqlValue::I64(42),
3572        };
3573        let dbset = DbSet::<TenantEntity>::disconnected();
3574        let grouped = dbset
3575            .query()
3576            .with_active_tenant_for_test(active_tenant)
3577            .group_by(TenantEntity::tenant_id)
3578            .unwrap()
3579            .select_aggregate((
3580                AggregateProjection::group_key(TenantEntity::tenant_id),
3581                AggregateProjection::count_as("tenant_count"),
3582            ));
3583
3584        let compiled = SqlServerCompiler::compile_aggregate(grouped.aggregate_query()).unwrap();
3585
3586        assert!(
3587            compiled
3588                .sql
3589                .contains("GROUP BY [sales].[tenant_entities].[tenant_id]")
3590        );
3591        assert!(
3592            compiled
3593                .sql
3594                .contains("[sales].[tenant_entities].[tenant_id] = @P1")
3595        );
3596        assert_eq!(compiled.params, vec![SqlValue::I64(42)]);
3597    }
3598
3599    #[test]
3600    fn grouped_query_rejects_empty_group_by_early() {
3601        let dbset = DbSet::<TestEntity>::disconnected();
3602        let error = dbset.query().group_by(Vec::<Expr>::new()).unwrap_err();
3603
3604        assert_eq!(
3605            error.message(),
3606            "group_by requires at least one group key expression"
3607        );
3608        assert_eq!(error.kind(), OrmErrorKind::Compile);
3609    }
3610
3611    #[test]
3612    fn grouped_query_try_select_aggregate_rejects_alias_ambiguity_early() {
3613        let grouped = DbSet::<TestEntity>::disconnected()
3614            .query()
3615            .group_by(TestEntity::id)
3616            .unwrap();
3617
3618        let empty_error = grouped
3619            .try_select_aggregate(AggregateProjection::count_as(" "))
3620            .unwrap_err();
3621        assert_eq!(
3622            empty_error.message(),
3623            "aggregate projection alias cannot be empty"
3624        );
3625        assert_eq!(empty_error.kind(), OrmErrorKind::Compile);
3626
3627        let grouped = DbSet::<TestEntity>::disconnected()
3628            .query()
3629            .group_by(TestEntity::id)
3630            .unwrap();
3631        let duplicate_error = grouped
3632            .try_select_aggregate((
3633                AggregateProjection::count_as("value"),
3634                AggregateProjection::sum_as(TestEntity::id, "value"),
3635            ))
3636            .unwrap_err();
3637        assert_eq!(
3638            duplicate_error.message(),
3639            "aggregate projection alias `value` is duplicated"
3640        );
3641
3642        let empty_projection_error = validate_aggregate_projection_aliases(&[]).unwrap_err();
3643        assert_eq!(
3644            empty_projection_error.message(),
3645            "select_aggregate requires at least one aggregate projection"
3646        );
3647    }
3648
3649    #[test]
3650    fn grouped_query_debug_mentions_entity_type() {
3651        let dbset = DbSet::<TestEntity>::disconnected();
3652        let grouped = dbset
3653            .query()
3654            .group_by(TestEntity::id)
3655            .unwrap()
3656            .select_aggregate(AggregateProjection::count_as("entity_count"));
3657
3658        let rendered = format!("{grouped:?}");
3659
3660        assert!(rendered.contains("DbSetGroupedQuery"));
3661        assert!(rendered.contains("test_entities"));
3662    }
3663
3664    #[test]
3665    fn scalar_aggregate_row_materializes_values_and_nulls() {
3666        struct AggregateTestRow {
3667            value: Option<SqlValue>,
3668        }
3669
3670        impl Row for AggregateTestRow {
3671            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
3672                Ok((column == "value").then(|| self.value.clone()).flatten())
3673            }
3674        }
3675
3676        let from_value = super::ScalarAggregateRow::<i64>::from_row(&AggregateTestRow {
3677            value: Some(SqlValue::I64(12)),
3678        })
3679        .unwrap();
3680        let from_null = super::ScalarAggregateRow::<i64>::from_row(&AggregateTestRow {
3681            value: Some(SqlValue::Null),
3682        })
3683        .unwrap();
3684        let missing = super::ScalarAggregateRow::<i64>::from_row(&AggregateTestRow { value: None })
3685            .unwrap_err();
3686
3687        assert_eq!(from_value.value, Some(12));
3688        assert_eq!(from_null.value, None);
3689        assert_eq!(
3690            missing.message(),
3691            "scalar aggregate result column was not present"
3692        );
3693    }
3694
3695    #[test]
3696    fn scalar_aggregate_row_validates_supported_return_types_strictly() {
3697        struct AggregateTestRow {
3698            value: SqlValue,
3699        }
3700
3701        impl Row for AggregateTestRow {
3702            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
3703                Ok((column == "value").then(|| self.value.clone()))
3704            }
3705        }
3706
3707        let date = NaiveDate::from_ymd_opt(2026, 5, 17).expect("valid date");
3708        let datetime: NaiveDateTime = date.and_hms_opt(9, 30, 0).expect("valid datetime");
3709        let decimal = Decimal::new(12345, 2);
3710
3711        let i32_row = super::ScalarAggregateRow::<i32>::from_row(&AggregateTestRow {
3712            value: SqlValue::I32(7),
3713        })
3714        .unwrap();
3715        let i64_row = super::ScalarAggregateRow::<i64>::from_row(&AggregateTestRow {
3716            value: SqlValue::I64(9),
3717        })
3718        .unwrap();
3719        let f64_row = super::ScalarAggregateRow::<f64>::from_row(&AggregateTestRow {
3720            value: SqlValue::F64(10.5),
3721        })
3722        .unwrap();
3723        let decimal_row = super::ScalarAggregateRow::<Decimal>::from_row(&AggregateTestRow {
3724            value: SqlValue::Decimal(decimal),
3725        })
3726        .unwrap();
3727        let string_row = super::ScalarAggregateRow::<String>::from_row(&AggregateTestRow {
3728            value: SqlValue::String("last".to_string()),
3729        })
3730        .unwrap();
3731        let date_row = super::ScalarAggregateRow::<NaiveDate>::from_row(&AggregateTestRow {
3732            value: SqlValue::Date(date),
3733        })
3734        .unwrap();
3735        let datetime_row =
3736            super::ScalarAggregateRow::<NaiveDateTime>::from_row(&AggregateTestRow {
3737                value: SqlValue::DateTime(datetime),
3738            })
3739            .unwrap();
3740        let mismatch = super::ScalarAggregateRow::<i64>::from_row(&AggregateTestRow {
3741            value: SqlValue::I32(7),
3742        })
3743        .unwrap_err();
3744
3745        assert_eq!(i32_row.value, Some(7));
3746        assert_eq!(i64_row.value, Some(9));
3747        assert_eq!(f64_row.value, Some(10.5));
3748        assert_eq!(decimal_row.value, Some(decimal));
3749        assert_eq!(string_row.value, Some("last".to_string()));
3750        assert_eq!(date_row.value, Some(date));
3751        assert_eq!(datetime_row.value, Some(datetime));
3752        assert_eq!(mismatch.message(), "expected i64 value");
3753    }
3754
3755    #[test]
3756    fn exists_row_materializes_bool_result() {
3757        struct ExistsTestRow;
3758
3759        impl Row for ExistsTestRow {
3760            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
3761                Ok((column == "exists").then_some(SqlValue::Bool(true)))
3762            }
3763        }
3764
3765        let row = super::ExistsRow::from_row(&ExistsTestRow).unwrap();
3766
3767        assert!(row.value);
3768    }
3769
3770    #[test]
3771    fn debug_mentions_entity_type() {
3772        let query = DbSetQuery::<TestEntity>::new(None, SelectQuery::from_entity::<TestEntity>());
3773
3774        let rendered = format!("{query:?}");
3775
3776        assert!(rendered.contains("DbSetQuery"));
3777        assert!(rendered.contains("test_entities"));
3778    }
3779
3780    fn render_compiled_query(compiled: &CompiledQuery) -> String {
3781        let params = compiled
3782            .params
3783            .iter()
3784            .enumerate()
3785            .map(|(index, value)| format!("{}: {:?}", index + 1, value))
3786            .collect::<Vec<_>>();
3787
3788        if params.is_empty() {
3789            format!("SQL: {}\nParams:\n<none>", compiled.sql)
3790        } else {
3791            format!("SQL: {}\nParams:\n{}", compiled.sql, params.join("\n"))
3792        }
3793    }
3794}