Skip to main content

sql_orm/
dbset_query.rs

1use crate::context::{ActiveTenant, SharedConnection};
2use crate::page_request::PageRequest;
3use crate::query_projection::SelectProjections;
4use crate::{IncludeCollection, IncludeNavigation, SoftDeleteEntity, TenantScopedEntity};
5use sql_orm_core::{
6    ColumnMetadata, Entity, EntityMetadata, FromRow, NavigationKind, OrmError, Row, SqlServerType,
7    SqlValue,
8};
9use sql_orm_query::{
10    ColumnRef, CountQuery, Expr, Join, JoinType, OrderBy, Pagination, Predicate, SelectProjection,
11    SelectQuery, TableRef,
12};
13use sql_orm_sqlserver::SqlServerCompiler;
14
15#[derive(Clone)]
16/// Fluent query builder bound to one `DbSet<E>`.
17///
18/// `DbSetQuery` stores query intent as AST until execution. SQL text is
19/// generated only by the SQL Server compiler. Mandatory runtime policies such
20/// as tenant filtering and root-entity soft-delete visibility are applied when
21/// the query is compiled or executed.
22pub struct DbSetQuery<E: Entity> {
23    connection: Option<SharedConnection>,
24    active_tenant: Option<ActiveTenant>,
25    select_query: SelectQuery,
26    visibility: SoftDeleteVisibility,
27    _entity: core::marker::PhantomData<fn() -> E>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum SoftDeleteVisibility {
32    Default,
33    WithDeleted,
34    OnlyDeleted,
35}
36
37/// Loading strategy for a `has_many` collection include.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CollectionIncludeStrategy {
40    /// Load roots and related rows through one `LEFT JOIN`, then group joined
41    /// rows by the root primary key.
42    Join,
43    /// Planned split-query strategy for large collections.
44    ///
45    /// The strategy is explicit in the public API, but execution returns a
46    /// clear error until the split-query implementation lands.
47    SplitQuery,
48}
49
50const DEFAULT_INCLUDE_MANY_JOIN_ROW_LIMIT: usize = 10_000;
51
52impl<E: Entity> DbSetQuery<E> {
53    pub(crate) fn new(connection: Option<SharedConnection>, select_query: SelectQuery) -> Self {
54        let active_tenant = connection
55            .as_ref()
56            .and_then(SharedConnection::active_tenant);
57        Self {
58            connection,
59            active_tenant,
60            select_query,
61            visibility: SoftDeleteVisibility::Default,
62            _entity: core::marker::PhantomData,
63        }
64    }
65
66    #[cfg(test)]
67    pub(crate) fn with_active_tenant_for_test(mut self, active_tenant: ActiveTenant) -> Self {
68        self.active_tenant = Some(active_tenant);
69        self
70    }
71
72    /// Replaces the underlying `SelectQuery` AST while keeping this query bound
73    /// to the same connection and runtime policies.
74    pub fn with_select_query(mut self, select_query: SelectQuery) -> Self {
75        self.select_query = select_query;
76        self
77    }
78
79    /// Adds a predicate to the query.
80    pub fn filter(mut self, predicate: Predicate) -> Self {
81        self.select_query = self.select_query.filter(predicate);
82        self
83    }
84
85    /// Adds an explicit join described by the query AST.
86    pub fn join(mut self, join: Join) -> Self {
87        self.select_query = self.select_query.join(join);
88        self
89    }
90
91    /// Adds an explicit `INNER JOIN` to another entity.
92    pub fn inner_join<J: Entity>(mut self, on: Predicate) -> Self {
93        self.select_query = self.select_query.inner_join::<J>(on);
94        self
95    }
96
97    /// Adds an explicit `LEFT JOIN` to another entity.
98    pub fn left_join<J: Entity>(mut self, on: Predicate) -> Self {
99        self.select_query = self.select_query.left_join::<J>(on);
100        self
101    }
102
103    /// Adds an `INNER JOIN` inferred from navigation metadata.
104    ///
105    /// The navigation must be declared on the root entity `E`, and its target
106    /// table must match `J`. This only builds the SQL join; it does not load or
107    /// materialize the related entity.
108    pub fn try_inner_join_navigation<J: Entity>(
109        self,
110        navigation: &'static str,
111    ) -> Result<Self, OrmError> {
112        self.try_join_navigation::<J>(navigation, JoinType::Inner, None)
113    }
114
115    /// Adds a `LEFT JOIN` inferred from navigation metadata.
116    ///
117    /// The navigation must be declared on the root entity `E`, and its target
118    /// table must match `J`. This only builds the SQL join; it does not load or
119    /// materialize the related entity.
120    pub fn try_left_join_navigation<J: Entity>(
121        self,
122        navigation: &'static str,
123    ) -> Result<Self, OrmError> {
124        self.try_join_navigation::<J>(navigation, JoinType::Left, None)
125    }
126
127    /// Adds an aliased `INNER JOIN` inferred from navigation metadata.
128    pub fn try_inner_join_navigation_as<J: Entity>(
129        self,
130        navigation: &'static str,
131        alias: &'static str,
132    ) -> Result<Self, OrmError> {
133        self.try_join_navigation::<J>(navigation, JoinType::Inner, Some(alias))
134    }
135
136    /// Adds an aliased `LEFT JOIN` inferred from navigation metadata.
137    pub fn try_left_join_navigation_as<J: Entity>(
138        self,
139        navigation: &'static str,
140        alias: &'static str,
141    ) -> Result<Self, OrmError> {
142        self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))
143    }
144
145    /// Includes a single related entity through a `belongs_to` or `has_one`
146    /// navigation.
147    ///
148    /// This first eager-loading cut uses a left join and materializes the
149    /// related row into `Navigation<J>`. Collection navigations (`has_many`)
150    /// are intentionally rejected because they need grouping or split-query
151    /// semantics.
152    pub fn include<J: Entity>(
153        self,
154        navigation: &'static str,
155    ) -> Result<DbSetQueryIncludeOne<E, J>, OrmError> {
156        self.include_as::<J>(navigation, navigation)
157    }
158
159    /// Includes a single related entity using an explicit table alias.
160    pub fn include_as<J: Entity>(
161        self,
162        navigation: &'static str,
163        alias: &'static str,
164    ) -> Result<DbSetQueryIncludeOne<E, J>, OrmError> {
165        let metadata = E::metadata();
166        let navigation_metadata = metadata.navigation(navigation).ok_or_else(|| {
167            OrmError::new(format!(
168                "entity `{}` does not declare navigation `{}`",
169                metadata.rust_name, navigation
170            ))
171        })?;
172
173        if !matches!(
174            navigation_metadata.kind,
175            NavigationKind::BelongsTo | NavigationKind::HasOne
176        ) {
177            return Err(OrmError::new(format!(
178                "include only supports belongs_to and has_one navigations; `{}` is {:?}",
179                navigation_metadata.rust_field, navigation_metadata.kind
180            )));
181        }
182
183        Ok(DbSetQueryIncludeOne {
184            query: self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))?,
185            navigation,
186            alias,
187            _target: core::marker::PhantomData,
188        })
189    }
190
191    /// Includes a collection navigation through a `has_many` relationship.
192    ///
193    /// This first collection include cut uses a left join, materializes joined
194    /// rows, then groups them by the root entity primary key before assigning
195    /// `Collection<J>`. Pagination is rejected for this join-based path
196    /// because limiting joined rows is not equivalent to limiting root
197    /// entities.
198    pub fn include_many<J: Entity>(
199        self,
200        navigation: &'static str,
201    ) -> Result<DbSetQueryIncludeMany<E, J>, OrmError> {
202        self.include_many_as::<J>(navigation, navigation)
203    }
204
205    /// Includes a collection navigation using an explicit table alias.
206    pub fn include_many_as<J: Entity>(
207        self,
208        navigation: &'static str,
209        alias: &'static str,
210    ) -> Result<DbSetQueryIncludeMany<E, J>, OrmError> {
211        let metadata = E::metadata();
212        let navigation_metadata = metadata.navigation(navigation).ok_or_else(|| {
213            OrmError::new(format!(
214                "entity `{}` does not declare navigation `{}`",
215                metadata.rust_name, navigation
216            ))
217        })?;
218
219        if !matches!(navigation_metadata.kind, NavigationKind::HasMany) {
220            return Err(OrmError::new(format!(
221                "include_many only supports has_many navigations; `{}` is {:?}",
222                navigation_metadata.rust_field, navigation_metadata.kind
223            )));
224        }
225
226        Ok(DbSetQueryIncludeMany {
227            query: self.try_join_navigation::<J>(navigation, JoinType::Left, Some(alias))?,
228            navigation,
229            alias,
230            strategy: CollectionIncludeStrategy::Join,
231            join_row_limit: Some(DEFAULT_INCLUDE_MANY_JOIN_ROW_LIMIT),
232            _target: core::marker::PhantomData,
233        })
234    }
235
236    /// Adds an ordering expression.
237    pub fn order_by(mut self, order: OrderBy) -> Self {
238        self.select_query = self.select_query.order_by(order);
239        self
240    }
241
242    /// Limits the number of returned rows with zero offset.
243    pub fn limit(mut self, limit: u64) -> Self {
244        self.select_query = self.select_query.paginate(Pagination::new(0, limit));
245        self
246    }
247
248    /// Alias for `limit(...)`.
249    pub fn take(self, limit: u64) -> Self {
250        self.limit(limit)
251    }
252
253    /// Applies page-based pagination.
254    pub fn paginate(mut self, request: PageRequest) -> Self {
255        self.select_query = self.select_query.paginate(request.to_pagination());
256        self
257    }
258
259    /// Selects an explicit projection instead of materializing full entities.
260    ///
261    /// Use `all_as::<T>()` or `first_as::<T>()` to materialize the projection
262    /// into a DTO implementing `FromRow`.
263    pub fn select<P>(mut self, projection: P) -> Self
264    where
265        P: SelectProjections,
266    {
267        self.select_query = self
268            .select_query
269            .select(projection.into_select_projections());
270        self
271    }
272
273    #[cfg(test)]
274    pub(crate) fn select_query(&self) -> &SelectQuery {
275        &self.select_query
276    }
277
278    /// Includes logically deleted rows for entities with `soft_delete`.
279    ///
280    /// This affects only the root entity `E`, not every manually joined entity.
281    pub fn with_deleted(mut self) -> Self {
282        self.visibility = SoftDeleteVisibility::WithDeleted;
283        self
284    }
285
286    /// Returns only logically deleted rows for entities with `soft_delete`.
287    ///
288    /// This affects only the root entity `E`, not every manually joined entity.
289    pub fn only_deleted(mut self) -> Self {
290        self.visibility = SoftDeleteVisibility::OnlyDeleted;
291        self
292    }
293
294    #[cfg(test)]
295    pub(crate) fn into_select_query(self) -> SelectQuery {
296        self.select_query
297    }
298
299    /// Executes the query and materializes full entities.
300    pub async fn all(self) -> Result<Vec<E>, OrmError>
301    where
302        E: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
303    {
304        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
305        let shared_connection = self.require_connection()?;
306        let mut connection = shared_connection.lock().await?;
307        connection.fetch_all(compiled).await
308    }
309
310    /// Executes the query and materializes the first full entity, if any.
311    pub async fn first(self) -> Result<Option<E>, OrmError>
312    where
313        E: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
314    {
315        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
316        let shared_connection = self.require_connection()?;
317        let mut connection = shared_connection.lock().await?;
318        connection.fetch_one(compiled).await
319    }
320
321    /// Executes the query and materializes projected rows as DTOs.
322    pub async fn all_as<T>(self) -> Result<Vec<T>, OrmError>
323    where
324        E: SoftDeleteEntity + TenantScopedEntity,
325        T: FromRow + Send,
326    {
327        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
328        let shared_connection = self.require_connection()?;
329        let mut connection = shared_connection.lock().await?;
330        connection.fetch_all(compiled).await
331    }
332
333    /// Executes the query and materializes the first projected DTO, if any.
334    pub async fn first_as<T>(self) -> Result<Option<T>, OrmError>
335    where
336        E: SoftDeleteEntity + TenantScopedEntity,
337        T: FromRow + Send,
338    {
339        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
340        let shared_connection = self.require_connection()?;
341        let mut connection = shared_connection.lock().await?;
342        connection.fetch_one(compiled).await
343    }
344
345    /// Executes the query as a `COUNT(*)` over the effective filters.
346    pub async fn count(self) -> Result<i64, OrmError>
347    where
348        E: SoftDeleteEntity + TenantScopedEntity,
349    {
350        let compiled = SqlServerCompiler::compile_count(&self.count_query())?;
351        let shared_connection = self.require_connection()?;
352        let mut connection = shared_connection.lock().await?;
353        let row = connection.fetch_one::<CountRow>(compiled).await?;
354
355        row.map(|row| row.value)
356            .ok_or_else(|| OrmError::new("count query did not return a row"))
357    }
358
359    fn count_query(&self) -> CountQuery
360    where
361        E: SoftDeleteEntity + TenantScopedEntity,
362    {
363        let effective = self
364            .effective_select_query()
365            .expect("count_query should materialize soft_delete visibility");
366        CountQuery {
367            from: effective.from,
368            predicate: effective.predicate.clone(),
369        }
370    }
371
372    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
373    where
374        E: SoftDeleteEntity + TenantScopedEntity,
375    {
376        let mut query = self.select_query.clone();
377
378        if let Some(predicate) =
379            tenant_predicate_for::<E>(self.active_tenant.as_ref(), TableRef::for_entity::<E>())?
380        {
381            query = query.filter(predicate);
382        }
383
384        if let Some(predicate) =
385            soft_delete_visibility_predicate_for::<E>(TableRef::for_entity::<E>(), self.visibility)?
386        {
387            query = query.filter(predicate);
388        }
389
390        Ok(query)
391    }
392
393    fn require_connection(&self) -> Result<SharedConnection, OrmError> {
394        self.connection
395            .as_ref()
396            .cloned()
397            .ok_or_else(|| OrmError::new("DbSetQuery requires an initialized shared connection"))
398    }
399
400    fn try_join_navigation<J: Entity>(
401        mut self,
402        navigation: &'static str,
403        join_type: JoinType,
404        alias: Option<&'static str>,
405    ) -> Result<Self, OrmError> {
406        let join = self.navigation_join::<J>(navigation, join_type, alias)?;
407        self.select_query = self.select_query.join(join);
408        Ok(self)
409    }
410
411    fn navigation_join<J: Entity>(
412        &self,
413        navigation: &'static str,
414        join_type: JoinType,
415        alias: Option<&'static str>,
416    ) -> Result<Join, OrmError> {
417        let root_metadata = E::metadata();
418        let target_metadata = J::metadata();
419        let navigation = root_metadata.navigation(navigation).ok_or_else(|| {
420            OrmError::new(format!(
421                "entity `{}` does not declare navigation `{}`",
422                root_metadata.rust_name, navigation
423            ))
424        })?;
425
426        if navigation.target_schema != target_metadata.schema
427            || navigation.target_table != target_metadata.table
428        {
429            return Err(OrmError::new(format!(
430                "navigation `{}` on `{}` targets `{}.{}`, not entity `{}` (`{}.{}`)",
431                navigation.rust_field,
432                root_metadata.rust_name,
433                navigation.target_schema,
434                navigation.target_table,
435                target_metadata.rust_name,
436                target_metadata.schema,
437                target_metadata.table
438            )));
439        }
440
441        if navigation.local_columns.is_empty()
442            || navigation.local_columns.len() != navigation.target_columns.len()
443        {
444            return Err(OrmError::new(format!(
445                "navigation `{}` on `{}` has invalid join column metadata",
446                navigation.rust_field, root_metadata.rust_name
447            )));
448        }
449
450        let target_table = match alias {
451            Some(alias) => TableRef::for_entity_as::<J>(alias),
452            None => TableRef::for_entity::<J>(),
453        };
454
455        let predicates = navigation
456            .local_columns
457            .iter()
458            .zip(navigation.target_columns.iter())
459            .map(|(local_column, target_column)| {
460                Ok(Predicate::eq(
461                    metadata_column_expr(root_metadata, self.select_query.from, local_column)?,
462                    metadata_column_expr(target_metadata, target_table, target_column)?,
463                ))
464            })
465            .collect::<Result<Vec<_>, OrmError>>()?;
466
467        let on = if predicates.len() == 1 {
468            predicates[0].clone()
469        } else {
470            Predicate::and(predicates)
471        };
472
473        Ok(Join::new(join_type, target_table, on))
474    }
475}
476
477/// Query builder returned by `DbSetQuery::include::<T>(...)` for a single
478/// included navigation.
479pub struct DbSetQueryIncludeOne<E: Entity, J: Entity> {
480    query: DbSetQuery<E>,
481    navigation: &'static str,
482    alias: &'static str,
483    _target: core::marker::PhantomData<fn() -> J>,
484}
485
486impl<E: Entity, J: Entity> DbSetQueryIncludeOne<E, J> {
487    /// Adds a predicate after configuring the include.
488    pub fn filter(mut self, predicate: Predicate) -> Self {
489        self.query = self.query.filter(predicate);
490        self
491    }
492
493    /// Adds an explicit join after configuring the include.
494    pub fn join(mut self, join: Join) -> Self {
495        self.query = self.query.join(join);
496        self
497    }
498
499    /// Adds an explicit `INNER JOIN` after configuring the include.
500    pub fn inner_join<K: Entity>(mut self, on: Predicate) -> Self {
501        self.query = self.query.inner_join::<K>(on);
502        self
503    }
504
505    /// Adds an explicit `LEFT JOIN` after configuring the include.
506    pub fn left_join<K: Entity>(mut self, on: Predicate) -> Self {
507        self.query = self.query.left_join::<K>(on);
508        self
509    }
510
511    /// Adds an ordering expression after configuring the include.
512    pub fn order_by(mut self, order: OrderBy) -> Self {
513        self.query = self.query.order_by(order);
514        self
515    }
516
517    /// Limits the number of returned rows with zero offset.
518    pub fn limit(mut self, limit: u64) -> Self {
519        self.query = self.query.limit(limit);
520        self
521    }
522
523    /// Alias for `limit(...)`.
524    pub fn take(self, limit: u64) -> Self {
525        self.limit(limit)
526    }
527
528    /// Applies page-based pagination after configuring the include.
529    pub fn paginate(mut self, request: PageRequest) -> Self {
530        self.query = self.query.paginate(request);
531        self
532    }
533
534    /// Includes logically deleted root rows for entities with `soft_delete`.
535    ///
536    /// This affects only the root entity `E`; included entities still apply
537    /// their own default `soft_delete` visibility inside the include join.
538    pub fn with_deleted(mut self) -> Self {
539        self.query = self.query.with_deleted();
540        self
541    }
542
543    /// Returns only logically deleted root rows for entities with `soft_delete`.
544    ///
545    /// This affects only the root entity `E`; included entities still apply
546    /// their own default `soft_delete` visibility inside the include join.
547    pub fn only_deleted(mut self) -> Self {
548        self.query = self.query.only_deleted();
549        self
550    }
551
552    /// Executes the query and materializes root entities with one included
553    /// navigation attached.
554    pub async fn all(self) -> Result<Vec<E>, OrmError>
555    where
556        E: FromRow + IncludeNavigation<J> + Send + SoftDeleteEntity + TenantScopedEntity,
557        J: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
558    {
559        let navigation = self.navigation;
560        let alias = self.alias;
561        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
562        let shared_connection = self.query.require_connection()?;
563        let mut connection = shared_connection.lock().await?;
564        connection
565            .fetch_all_with(compiled, move |row| {
566                materialize_include_one::<E, J>(&row, navigation, alias)
567            })
568            .await
569    }
570
571    /// Executes the query and materializes the first root entity with one
572    /// included navigation attached, if any.
573    pub async fn first(self) -> Result<Option<E>, OrmError>
574    where
575        E: FromRow + IncludeNavigation<J> + Send + SoftDeleteEntity + TenantScopedEntity,
576        J: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
577    {
578        let navigation = self.navigation;
579        let alias = self.alias;
580        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
581        let shared_connection = self.query.require_connection()?;
582        let mut connection = shared_connection.lock().await?;
583        connection
584            .fetch_one_with(compiled, move |row| {
585                materialize_include_one::<E, J>(&row, navigation, alias)
586            })
587            .await
588    }
589
590    #[cfg(test)]
591    pub(crate) fn select_query(&self) -> Result<SelectQuery, OrmError>
592    where
593        E: SoftDeleteEntity + TenantScopedEntity,
594        J: SoftDeleteEntity + TenantScopedEntity,
595    {
596        self.effective_select_query()
597    }
598
599    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
600    where
601        E: SoftDeleteEntity + TenantScopedEntity,
602        J: SoftDeleteEntity + TenantScopedEntity,
603    {
604        let query = self.query.effective_select_query()?;
605        let query = apply_include_policy_filters::<J>(
606            query,
607            self.query.active_tenant.as_ref(),
608            self.alias,
609        )?;
610        apply_include_projection::<E, J>(query, self.alias)
611    }
612}
613
614/// Query builder returned by `DbSetQuery::include_many::<T>(...)` for a
615/// collection navigation.
616pub struct DbSetQueryIncludeMany<E: Entity, J: Entity> {
617    query: DbSetQuery<E>,
618    navigation: &'static str,
619    alias: &'static str,
620    strategy: CollectionIncludeStrategy,
621    join_row_limit: Option<usize>,
622    _target: core::marker::PhantomData<fn() -> J>,
623}
624
625impl<E: Entity, J: Entity> DbSetQueryIncludeMany<E, J> {
626    /// Adds a predicate after configuring the collection include.
627    pub fn filter(mut self, predicate: Predicate) -> Self {
628        self.query = self.query.filter(predicate);
629        self
630    }
631
632    /// Adds an explicit join after configuring the collection include.
633    pub fn join(mut self, join: Join) -> Self {
634        self.query = self.query.join(join);
635        self
636    }
637
638    /// Adds an explicit `INNER JOIN` after configuring the collection include.
639    pub fn inner_join<K: Entity>(mut self, on: Predicate) -> Self {
640        self.query = self.query.inner_join::<K>(on);
641        self
642    }
643
644    /// Adds an explicit `LEFT JOIN` after configuring the collection include.
645    pub fn left_join<K: Entity>(mut self, on: Predicate) -> Self {
646        self.query = self.query.left_join::<K>(on);
647        self
648    }
649
650    /// Adds an ordering expression after configuring the collection include.
651    pub fn order_by(mut self, order: OrderBy) -> Self {
652        self.query = self.query.order_by(order);
653        self
654    }
655
656    /// Uses the join-based collection loading strategy.
657    ///
658    /// This is the default strategy. The row limit protects callers from
659    /// accidentally loading an unbounded cartesian result through one join.
660    pub fn join_strategy(mut self) -> Self {
661        self.strategy = CollectionIncludeStrategy::Join;
662        self
663    }
664
665    /// Selects the planned split-query loading strategy.
666    ///
667    /// Execution currently returns a clear error because split queries need a
668    /// separate implementation that loads roots first and related rows second.
669    pub fn split_query(mut self) -> Self {
670        self.strategy = CollectionIncludeStrategy::SplitQuery;
671        self
672    }
673
674    /// Overrides the maximum number of joined rows accepted before grouping.
675    ///
676    /// Use this only when the expected root/collection cardinality is known.
677    pub fn max_joined_rows(mut self, limit: usize) -> Self {
678        self.join_row_limit = Some(limit);
679        self
680    }
681
682    /// Removes the join row safety limit.
683    ///
684    /// This keeps the API explicit for callers that intentionally accept a
685    /// large join result.
686    pub fn unbounded_join(mut self) -> Self {
687        self.join_row_limit = None;
688        self
689    }
690
691    /// Includes logically deleted root rows for entities with `soft_delete`.
692    ///
693    /// This affects only the root entity `E`; included collection entities
694    /// still apply their own default `soft_delete` visibility inside the join.
695    pub fn with_deleted(mut self) -> Self {
696        self.query = self.query.with_deleted();
697        self
698    }
699
700    /// Returns only logically deleted root rows for entities with `soft_delete`.
701    ///
702    /// This affects only the root entity `E`; included collection entities
703    /// still apply their own default `soft_delete` visibility inside the join.
704    pub fn only_deleted(mut self) -> Self {
705        self.query = self.query.only_deleted();
706        self
707    }
708
709    /// Executes the query and materializes root entities with one collection
710    /// navigation attached.
711    pub async fn all(self) -> Result<Vec<E>, OrmError>
712    where
713        E: FromRow + IncludeCollection<J> + Send + SoftDeleteEntity + TenantScopedEntity,
714        J: FromRow + Send + SoftDeleteEntity + TenantScopedEntity,
715    {
716        if self.strategy == CollectionIncludeStrategy::SplitQuery {
717            return Err(OrmError::new(
718                "include_many split-query loading is not implemented yet; use join_strategy() with an explicit max_joined_rows(...) limit",
719            ));
720        }
721
722        let navigation = self.navigation;
723        let alias = self.alias;
724        let compiled = SqlServerCompiler::compile_select(&self.effective_select_query()?)?;
725        let shared_connection = self.query.require_connection()?;
726        let mut connection = shared_connection.lock().await?;
727        let rows = connection
728            .fetch_all_with(compiled, move |row| {
729                materialize_include_many_row::<E, J>(&row, alias)
730            })
731            .await?;
732
733        enforce_include_many_join_row_limit(rows.len(), self.join_row_limit)?;
734        group_include_many_rows::<E, J>(rows, navigation)
735    }
736
737    #[cfg(test)]
738    pub(crate) fn select_query(&self) -> Result<SelectQuery, OrmError>
739    where
740        E: SoftDeleteEntity + TenantScopedEntity,
741        J: SoftDeleteEntity + TenantScopedEntity,
742    {
743        self.effective_select_query()
744    }
745
746    fn effective_select_query(&self) -> Result<SelectQuery, OrmError>
747    where
748        E: SoftDeleteEntity + TenantScopedEntity,
749        J: SoftDeleteEntity + TenantScopedEntity,
750    {
751        let query = self.query.effective_select_query()?;
752        if query.pagination.is_some() {
753            return Err(OrmError::new(
754                "include_many does not support pagination in the join-based collection loading cut",
755            ));
756        }
757
758        let query = apply_include_policy_filters::<J>(
759            query,
760            self.query.active_tenant.as_ref(),
761            self.alias,
762        )?;
763        apply_include_projection::<E, J>(query, self.alias)
764    }
765}
766
767fn tenant_predicate_for<E: TenantScopedEntity>(
768    active_tenant: Option<&ActiveTenant>,
769    table: TableRef,
770) -> Result<Option<Predicate>, OrmError> {
771    let Some(policy) = E::tenant_policy() else {
772        return Ok(None);
773    };
774
775    if policy.columns.len() != 1 {
776        return Err(OrmError::new(
777            "tenant query filter requires exactly one tenant policy column",
778        ));
779    }
780
781    let tenant_column = &policy.columns[0];
782    let active_tenant = active_tenant.ok_or_else(|| {
783        OrmError::new("tenant-scoped query requires an active tenant in the DbContext")
784    })?;
785
786    if active_tenant.column_name != tenant_column.column_name {
787        return Err(OrmError::new(format!(
788            "active tenant column `{}` does not match entity tenant column `{}`",
789            active_tenant.column_name, tenant_column.column_name
790        )));
791    }
792
793    if !tenant_value_matches_column_type(&active_tenant.value, tenant_column) {
794        return Err(OrmError::new(format!(
795            "active tenant value is not compatible with entity tenant column `{}`",
796            tenant_column.column_name
797        )));
798    }
799
800    Ok(Some(Predicate::eq(
801        Expr::Column(ColumnRef::new(
802            table,
803            tenant_column.rust_field,
804            tenant_column.column_name,
805        )),
806        Expr::Value(active_tenant.value.clone()),
807    )))
808}
809
810fn soft_delete_visibility_predicate_for<E: SoftDeleteEntity>(
811    table: TableRef,
812    visibility: SoftDeleteVisibility,
813) -> Result<Option<Predicate>, OrmError> {
814    let Some(policy) = E::soft_delete_policy() else {
815        return Ok(None);
816    };
817
818    let visibility = match visibility {
819        SoftDeleteVisibility::Default => SoftDeleteVisibility::Default,
820        SoftDeleteVisibility::WithDeleted => return Ok(None),
821        SoftDeleteVisibility::OnlyDeleted => SoftDeleteVisibility::OnlyDeleted,
822    };
823
824    let indicator = policy.columns.first().ok_or_else(|| {
825        OrmError::new("soft_delete query visibility requires at least one policy column")
826    })?;
827    let column = Expr::Column(ColumnRef::new(
828        table,
829        indicator.rust_field,
830        indicator.column_name,
831    ));
832
833    if indicator.sql_type == SqlServerType::Bit {
834        return Ok(Some(match visibility {
835            SoftDeleteVisibility::Default => {
836                Predicate::eq(column, Expr::Value(SqlValue::Bool(false)))
837            }
838            SoftDeleteVisibility::OnlyDeleted => {
839                Predicate::eq(column, Expr::Value(SqlValue::Bool(true)))
840            }
841            SoftDeleteVisibility::WithDeleted => unreachable!(),
842        }));
843    }
844
845    if indicator.nullable {
846        return Ok(Some(match visibility {
847            SoftDeleteVisibility::Default => Predicate::is_null(column),
848            SoftDeleteVisibility::OnlyDeleted => Predicate::is_not_null(column),
849            SoftDeleteVisibility::WithDeleted => unreachable!(),
850        }));
851    }
852
853    Err(OrmError::new(
854        "soft_delete query visibility requires the first policy column to be nullable or bit",
855    ))
856}
857
858fn apply_include_policy_filters<J: Entity + SoftDeleteEntity + TenantScopedEntity>(
859    mut query: SelectQuery,
860    active_tenant: Option<&ActiveTenant>,
861    alias: &'static str,
862) -> Result<SelectQuery, OrmError> {
863    let target_table = TableRef::for_entity_as::<J>(alias);
864    let mut predicates = Vec::new();
865
866    if let Some(predicate) = tenant_predicate_for::<J>(active_tenant, target_table)? {
867        predicates.push(predicate);
868    }
869
870    if let Some(predicate) =
871        soft_delete_visibility_predicate_for::<J>(target_table, SoftDeleteVisibility::Default)?
872    {
873        predicates.push(predicate);
874    }
875
876    if predicates.is_empty() {
877        return Ok(query);
878    }
879
880    let include_join = query
881        .joins
882        .iter_mut()
883        .find(|join| join.table == target_table)
884        .ok_or_else(|| {
885            OrmError::new(format!(
886                "include join for entity `{}` with alias `{}` was not found",
887                J::metadata().rust_name,
888                alias
889            ))
890        })?;
891
892    let policy_predicate = if predicates.len() == 1 {
893        predicates.remove(0)
894    } else {
895        Predicate::and(predicates)
896    };
897    include_join.on = Predicate::and(vec![include_join.on.clone(), policy_predicate]);
898
899    Ok(query)
900}
901
902fn apply_include_projection<E: Entity, J: Entity>(
903    mut query: SelectQuery,
904    alias: &'static str,
905) -> Result<SelectQuery, OrmError> {
906    let mut projection = Vec::new();
907
908    projection.extend(E::metadata().columns.iter().map(|column| {
909        SelectProjection::expr_as(
910            Expr::Column(ColumnRef::new(
911                query.from,
912                column.rust_field,
913                column.column_name,
914            )),
915            column.column_name,
916        )
917    }));
918
919    let target_table = TableRef::for_entity_as::<J>(alias);
920    for column in J::metadata().columns {
921        projection.push(SelectProjection::expr_as(
922            Expr::Column(ColumnRef::new(
923                target_table,
924                column.rust_field,
925                column.column_name,
926            )),
927            include_column_alias(alias, column.column_name),
928        ));
929    }
930
931    query.projection = projection;
932    Ok(query)
933}
934
935fn materialize_include_one<E, J>(
936    row: &impl Row,
937    navigation: &'static str,
938    alias: &'static str,
939) -> Result<E, OrmError>
940where
941    E: FromRow + IncludeNavigation<J>,
942    J: Entity + FromRow,
943{
944    let mut entity = E::from_row(row)?;
945    let related = materialize_prefixed_entity::<J>(row, alias)?;
946    entity.set_included_navigation(navigation, related)?;
947    Ok(entity)
948}
949
950fn materialize_prefixed_entity<J: Entity + FromRow>(
951    row: &impl Row,
952    alias: &'static str,
953) -> Result<Option<J>, OrmError> {
954    let prefix = include_prefix(alias);
955    let mut saw_value = false;
956
957    for column in J::metadata().columns {
958        let projected = prefixed_column_name(&prefix, column.column_name);
959        if let Some(value) = row.try_get(&projected)? {
960            if !value.is_null() {
961                saw_value = true;
962                break;
963            }
964        }
965    }
966
967    if !saw_value {
968        return Ok(None);
969    }
970
971    Ok(Some(J::from_row(&PrefixedRow { row, prefix })?))
972}
973
974struct IncludeManyRow<E, J> {
975    root_key: Vec<SqlValue>,
976    root: E,
977    related: Option<J>,
978}
979
980fn materialize_include_many_row<E, J>(
981    row: &impl Row,
982    alias: &'static str,
983) -> Result<IncludeManyRow<E, J>, OrmError>
984where
985    E: Entity + FromRow,
986    J: Entity + FromRow,
987{
988    Ok(IncludeManyRow {
989        root_key: root_primary_key_values::<E>(row)?,
990        root: E::from_row(row)?,
991        related: materialize_prefixed_entity::<J>(row, alias)?,
992    })
993}
994
995fn root_primary_key_values<E: Entity>(row: &impl Row) -> Result<Vec<SqlValue>, OrmError> {
996    let metadata = E::metadata();
997    if metadata.primary_key.columns.is_empty() {
998        return Err(OrmError::new(format!(
999            "include_many requires entity `{}` to declare a primary key for row grouping",
1000            metadata.rust_name
1001        )));
1002    }
1003
1004    metadata
1005        .primary_key
1006        .columns
1007        .iter()
1008        .map(|column_name| row.get_required(column_name))
1009        .collect()
1010}
1011
1012fn group_include_many_rows<E, J>(
1013    rows: Vec<IncludeManyRow<E, J>>,
1014    navigation: &'static str,
1015) -> Result<Vec<E>, OrmError>
1016where
1017    E: IncludeCollection<J>,
1018{
1019    let mut grouped: Vec<(Vec<SqlValue>, E, Vec<J>)> = Vec::new();
1020
1021    for row in rows {
1022        if let Some((_, _, related_values)) = grouped
1023            .iter_mut()
1024            .find(|(root_key, _, _)| *root_key == row.root_key)
1025        {
1026            if let Some(related) = row.related {
1027                related_values.push(related);
1028            }
1029            continue;
1030        }
1031
1032        let related_values = row.related.into_iter().collect();
1033        grouped.push((row.root_key, row.root, related_values));
1034    }
1035
1036    grouped
1037        .into_iter()
1038        .map(|(_, mut root, related_values)| {
1039            root.set_included_collection(navigation, related_values)?;
1040            Ok(root)
1041        })
1042        .collect()
1043}
1044
1045fn enforce_include_many_join_row_limit(
1046    row_count: usize,
1047    limit: Option<usize>,
1048) -> Result<(), OrmError> {
1049    let Some(limit) = limit else {
1050        return Ok(());
1051    };
1052
1053    if row_count > limit {
1054        return Err(OrmError::new(format!(
1055            "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"
1056        )));
1057    }
1058
1059    Ok(())
1060}
1061
1062struct PrefixedRow<'a, R: Row + ?Sized> {
1063    row: &'a R,
1064    prefix: String,
1065}
1066
1067impl<R: Row + ?Sized> Row for PrefixedRow<'_, R> {
1068    fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
1069        self.row
1070            .try_get(&prefixed_column_name(&self.prefix, column))
1071    }
1072}
1073
1074fn include_prefix(alias: &'static str) -> String {
1075    format!("{alias}__")
1076}
1077
1078fn include_column_alias(alias: &'static str, column_name: &'static str) -> &'static str {
1079    Box::leak(format!("{alias}__{column_name}").into_boxed_str())
1080}
1081
1082fn prefixed_column_name(prefix: &str, column_name: &str) -> String {
1083    format!("{prefix}{column_name}")
1084}
1085
1086fn metadata_column_expr(
1087    metadata: &'static EntityMetadata,
1088    table: TableRef,
1089    column_name: &str,
1090) -> Result<Expr, OrmError> {
1091    let column = metadata.column(column_name).ok_or_else(|| {
1092        OrmError::new(format!(
1093            "entity `{}` metadata does not contain column `{}` required by navigation join",
1094            metadata.rust_name, column_name
1095        ))
1096    })?;
1097
1098    Ok(Expr::Column(ColumnRef::new(
1099        table,
1100        column.rust_field,
1101        column.column_name,
1102    )))
1103}
1104
1105pub(crate) fn tenant_value_matches_column_type(value: &SqlValue, column: &ColumnMetadata) -> bool {
1106    if value.is_null() {
1107        return false;
1108    }
1109
1110    match column.sql_type {
1111        SqlServerType::BigInt => matches!(value, SqlValue::I64(_)),
1112        SqlServerType::Int | SqlServerType::SmallInt | SqlServerType::TinyInt => {
1113            matches!(value, SqlValue::I32(_))
1114        }
1115        SqlServerType::Bit => matches!(value, SqlValue::Bool(_)),
1116        SqlServerType::UniqueIdentifier => matches!(value, SqlValue::Uuid(_)),
1117        SqlServerType::Date => matches!(value, SqlValue::Date(_)),
1118        SqlServerType::DateTime2 => matches!(value, SqlValue::DateTime(_)),
1119        SqlServerType::Decimal | SqlServerType::Money => matches!(value, SqlValue::Decimal(_)),
1120        SqlServerType::Float => matches!(value, SqlValue::F64(_)),
1121        SqlServerType::NVarChar | SqlServerType::Custom(_) => {
1122            matches!(value, SqlValue::String(_))
1123        }
1124        SqlServerType::VarBinary | SqlServerType::RowVersion => matches!(value, SqlValue::Bytes(_)),
1125    }
1126}
1127
1128impl<E: Entity> core::fmt::Debug for DbSetQuery<E> {
1129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1130        f.debug_struct("DbSetQuery")
1131            .field("entity", &E::metadata().rust_name)
1132            .field("table", &E::metadata().table)
1133            .field("select_query", &self.select_query)
1134            .finish()
1135    }
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1139struct CountRow {
1140    value: i64,
1141}
1142
1143impl FromRow for CountRow {
1144    fn from_row<R: Row>(row: &R) -> Result<Self, OrmError> {
1145        match row.get_required("count")? {
1146            SqlValue::I32(value) => Ok(Self {
1147                value: i64::from(value),
1148            }),
1149            SqlValue::I64(value) => Ok(Self { value }),
1150            _ => Err(OrmError::new(
1151                "expected SQL Server COUNT result as i32 or i64",
1152            )),
1153        }
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::{
1160        DbSetQuery, enforce_include_many_join_row_limit, tenant_value_matches_column_type,
1161    };
1162    use crate::context::{ActiveTenant, DbSet};
1163    use crate::page_request::PageRequest;
1164    use crate::{IncludeCollection, SoftDeleteEntity, TenantScopedEntity};
1165    use insta::assert_snapshot;
1166    use sql_orm_core::{
1167        ColumnMetadata, Entity, EntityColumn, EntityMetadata, EntityPolicyMetadata, FromRow,
1168        NavigationKind, NavigationMetadata, OrmError, PrimaryKeyMetadata, Row, SqlServerType,
1169        SqlValue,
1170    };
1171    use sql_orm_query::{
1172        ColumnRef, CompiledQuery, Expr, Join, JoinType, OrderBy, Pagination, Predicate,
1173        SelectProjection, SelectQuery, SortDirection, TableRef,
1174    };
1175    use sql_orm_sqlserver::SqlServerCompiler;
1176
1177    struct TestEntity;
1178    struct JoinedEntity;
1179    #[derive(Debug)]
1180    struct NavigationRoot;
1181    #[derive(Debug)]
1182    struct NavigationTarget;
1183    struct TenantNavigationRoot;
1184    struct TenantNavigationTarget;
1185    struct SoftDeleteEntityUnderTest;
1186    struct BoolSoftDeleteEntity;
1187    struct TenantEntity;
1188
1189    static TEST_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1190        rust_name: "TestEntity",
1191        schema: "dbo",
1192        table: "test_entities",
1193        renamed_from: None,
1194        columns: &[],
1195        primary_key: PrimaryKeyMetadata {
1196            name: None,
1197            columns: &[],
1198        },
1199        indexes: &[],
1200        foreign_keys: &[],
1201        navigations: &[],
1202    };
1203
1204    impl Entity for TestEntity {
1205        fn metadata() -> &'static EntityMetadata {
1206            &TEST_ENTITY_METADATA
1207        }
1208    }
1209
1210    #[allow(non_upper_case_globals)]
1211    impl TestEntity {
1212        const id: EntityColumn<TestEntity> = EntityColumn::new("id", "id");
1213        const name: EntityColumn<TestEntity> = EntityColumn::new("name", "name");
1214    }
1215
1216    static JOINED_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1217        rust_name: "JoinedEntity",
1218        schema: "dbo",
1219        table: "joined_entities",
1220        renamed_from: None,
1221        columns: &[],
1222        primary_key: PrimaryKeyMetadata {
1223            name: None,
1224            columns: &[],
1225        },
1226        indexes: &[],
1227        foreign_keys: &[],
1228        navigations: &[],
1229    };
1230
1231    impl Entity for JoinedEntity {
1232        fn metadata() -> &'static EntityMetadata {
1233            &JOINED_ENTITY_METADATA
1234        }
1235    }
1236
1237    static NAVIGATION_ROOT_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1238        rust_field: "id",
1239        column_name: "id",
1240        renamed_from: None,
1241        sql_type: SqlServerType::BigInt,
1242        nullable: false,
1243        primary_key: true,
1244        identity: None,
1245        default_sql: None,
1246        computed_sql: None,
1247        rowversion: false,
1248        insertable: false,
1249        updatable: false,
1250        max_length: None,
1251        precision: None,
1252        scale: None,
1253    }];
1254
1255    static NAVIGATION_TARGET_COLUMNS: [ColumnMetadata; 2] = [
1256        ColumnMetadata {
1257            rust_field: "id",
1258            column_name: "id",
1259            renamed_from: None,
1260            sql_type: SqlServerType::BigInt,
1261            nullable: false,
1262            primary_key: true,
1263            identity: None,
1264            default_sql: None,
1265            computed_sql: None,
1266            rowversion: false,
1267            insertable: false,
1268            updatable: false,
1269            max_length: None,
1270            precision: None,
1271            scale: None,
1272        },
1273        ColumnMetadata {
1274            rust_field: "owner_id",
1275            column_name: "owner_id",
1276            renamed_from: None,
1277            sql_type: SqlServerType::BigInt,
1278            nullable: false,
1279            primary_key: false,
1280            identity: None,
1281            default_sql: None,
1282            computed_sql: None,
1283            rowversion: false,
1284            insertable: true,
1285            updatable: true,
1286            max_length: None,
1287            precision: None,
1288            scale: None,
1289        },
1290    ];
1291
1292    static NAVIGATION_ROOT_NAVIGATIONS: [NavigationMetadata; 1] = [NavigationMetadata::new(
1293        "orders",
1294        NavigationKind::HasMany,
1295        "NavigationTarget",
1296        "sales",
1297        "navigation_targets",
1298        &["id"],
1299        &["owner_id"],
1300        Some("fk_navigation_targets_owner"),
1301    )];
1302
1303    static NAVIGATION_ROOT_METADATA: EntityMetadata = EntityMetadata {
1304        rust_name: "NavigationRoot",
1305        schema: "dbo",
1306        table: "navigation_roots",
1307        renamed_from: None,
1308        columns: &NAVIGATION_ROOT_COLUMNS,
1309        primary_key: PrimaryKeyMetadata {
1310            name: None,
1311            columns: &["id"],
1312        },
1313        indexes: &[],
1314        foreign_keys: &[],
1315        navigations: &NAVIGATION_ROOT_NAVIGATIONS,
1316    };
1317
1318    static NAVIGATION_TARGET_NAVIGATIONS: [NavigationMetadata; 1] = [NavigationMetadata::new(
1319        "owner",
1320        NavigationKind::BelongsTo,
1321        "NavigationRoot",
1322        "dbo",
1323        "navigation_roots",
1324        &["owner_id"],
1325        &["id"],
1326        Some("fk_navigation_targets_owner"),
1327    )];
1328
1329    static NAVIGATION_TARGET_METADATA: EntityMetadata = EntityMetadata {
1330        rust_name: "NavigationTarget",
1331        schema: "sales",
1332        table: "navigation_targets",
1333        renamed_from: None,
1334        columns: &NAVIGATION_TARGET_COLUMNS,
1335        primary_key: PrimaryKeyMetadata {
1336            name: None,
1337            columns: &["id"],
1338        },
1339        indexes: &[],
1340        foreign_keys: &[],
1341        navigations: &NAVIGATION_TARGET_NAVIGATIONS,
1342    };
1343
1344    impl Entity for NavigationRoot {
1345        fn metadata() -> &'static EntityMetadata {
1346            &NAVIGATION_ROOT_METADATA
1347        }
1348    }
1349
1350    impl Entity for NavigationTarget {
1351        fn metadata() -> &'static EntityMetadata {
1352            &NAVIGATION_TARGET_METADATA
1353        }
1354    }
1355
1356    impl FromRow for NavigationRoot {
1357        fn from_row<R: Row>(_row: &R) -> Result<Self, OrmError> {
1358            Ok(Self)
1359        }
1360    }
1361
1362    impl FromRow for NavigationTarget {
1363        fn from_row<R: Row>(_row: &R) -> Result<Self, OrmError> {
1364            Ok(Self)
1365        }
1366    }
1367
1368    static SOFT_DELETE_POLICY_COLUMNS: [ColumnMetadata; 2] = [
1369        ColumnMetadata {
1370            rust_field: "deleted_at",
1371            column_name: "deleted_at",
1372            renamed_from: None,
1373            sql_type: SqlServerType::DateTime2,
1374            nullable: true,
1375            primary_key: false,
1376            identity: None,
1377            default_sql: None,
1378            computed_sql: None,
1379            rowversion: false,
1380            insertable: false,
1381            updatable: true,
1382            max_length: None,
1383            precision: None,
1384            scale: None,
1385        },
1386        ColumnMetadata {
1387            rust_field: "deleted_by",
1388            column_name: "deleted_by",
1389            renamed_from: None,
1390            sql_type: SqlServerType::NVarChar,
1391            nullable: true,
1392            primary_key: false,
1393            identity: None,
1394            default_sql: None,
1395            computed_sql: None,
1396            rowversion: false,
1397            insertable: false,
1398            updatable: true,
1399            max_length: Some(120),
1400            precision: None,
1401            scale: None,
1402        },
1403    ];
1404
1405    static BOOL_SOFT_DELETE_POLICY_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1406        rust_field: "is_deleted",
1407        column_name: "is_deleted",
1408        renamed_from: None,
1409        sql_type: SqlServerType::Bit,
1410        nullable: false,
1411        primary_key: false,
1412        identity: None,
1413        default_sql: Some("0"),
1414        computed_sql: None,
1415        rowversion: false,
1416        insertable: false,
1417        updatable: true,
1418        max_length: None,
1419        precision: None,
1420        scale: None,
1421    }];
1422
1423    static SOFT_DELETE_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1424        rust_name: "SoftDeleteEntityUnderTest",
1425        schema: "dbo",
1426        table: "soft_delete_entities",
1427        renamed_from: None,
1428        columns: &[],
1429        primary_key: PrimaryKeyMetadata {
1430            name: None,
1431            columns: &[],
1432        },
1433        indexes: &[],
1434        foreign_keys: &[],
1435        navigations: &[],
1436    };
1437
1438    static BOOL_SOFT_DELETE_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1439        rust_name: "BoolSoftDeleteEntity",
1440        schema: "dbo",
1441        table: "bool_soft_delete_entities",
1442        renamed_from: None,
1443        columns: &[],
1444        primary_key: PrimaryKeyMetadata {
1445            name: None,
1446            columns: &[],
1447        },
1448        indexes: &[],
1449        foreign_keys: &[],
1450        navigations: &[],
1451    };
1452
1453    static TENANT_POLICY_COLUMNS: [ColumnMetadata; 1] = [ColumnMetadata {
1454        rust_field: "tenant_id",
1455        column_name: "tenant_id",
1456        renamed_from: None,
1457        sql_type: SqlServerType::BigInt,
1458        nullable: false,
1459        primary_key: false,
1460        identity: None,
1461        default_sql: None,
1462        computed_sql: None,
1463        rowversion: false,
1464        insertable: true,
1465        updatable: false,
1466        max_length: None,
1467        precision: None,
1468        scale: None,
1469    }];
1470
1471    static TENANT_ENTITY_METADATA: EntityMetadata = EntityMetadata {
1472        rust_name: "TenantEntity",
1473        schema: "sales",
1474        table: "tenant_entities",
1475        renamed_from: None,
1476        columns: &TENANT_POLICY_COLUMNS,
1477        primary_key: PrimaryKeyMetadata {
1478            name: None,
1479            columns: &[],
1480        },
1481        indexes: &[],
1482        foreign_keys: &[],
1483        navigations: &[],
1484    };
1485
1486    static TENANT_NAVIGATION_ROOT_COLUMNS: [ColumnMetadata; 2] = [
1487        ColumnMetadata {
1488            rust_field: "id",
1489            column_name: "id",
1490            renamed_from: None,
1491            sql_type: SqlServerType::BigInt,
1492            nullable: false,
1493            primary_key: true,
1494            identity: None,
1495            default_sql: None,
1496            computed_sql: None,
1497            rowversion: false,
1498            insertable: false,
1499            updatable: false,
1500            max_length: None,
1501            precision: None,
1502            scale: None,
1503        },
1504        TENANT_POLICY_COLUMNS[0],
1505    ];
1506
1507    static TENANT_NAVIGATION_TARGET_COLUMNS: [ColumnMetadata; 2] = [
1508        ColumnMetadata {
1509            rust_field: "id",
1510            column_name: "id",
1511            renamed_from: None,
1512            sql_type: SqlServerType::BigInt,
1513            nullable: false,
1514            primary_key: true,
1515            identity: None,
1516            default_sql: None,
1517            computed_sql: None,
1518            rowversion: false,
1519            insertable: false,
1520            updatable: false,
1521            max_length: None,
1522            precision: None,
1523            scale: None,
1524        },
1525        ColumnMetadata {
1526            rust_field: "owner_id",
1527            column_name: "owner_id",
1528            renamed_from: None,
1529            sql_type: SqlServerType::BigInt,
1530            nullable: false,
1531            primary_key: false,
1532            identity: None,
1533            default_sql: None,
1534            computed_sql: None,
1535            rowversion: false,
1536            insertable: true,
1537            updatable: true,
1538            max_length: None,
1539            precision: None,
1540            scale: None,
1541        },
1542    ];
1543
1544    static TENANT_NAVIGATION_TARGET_NAVIGATIONS: [NavigationMetadata; 1] =
1545        [NavigationMetadata::new(
1546            "owner",
1547            NavigationKind::BelongsTo,
1548            "TenantNavigationRoot",
1549            "sales",
1550            "tenant_navigation_roots",
1551            &["owner_id"],
1552            &["id"],
1553            Some("fk_tenant_navigation_targets_owner"),
1554        )];
1555
1556    static TENANT_NAVIGATION_ROOT_METADATA: EntityMetadata = EntityMetadata {
1557        rust_name: "TenantNavigationRoot",
1558        schema: "sales",
1559        table: "tenant_navigation_roots",
1560        renamed_from: None,
1561        columns: &TENANT_NAVIGATION_ROOT_COLUMNS,
1562        primary_key: PrimaryKeyMetadata {
1563            name: None,
1564            columns: &["id"],
1565        },
1566        indexes: &[],
1567        foreign_keys: &[],
1568        navigations: &[],
1569    };
1570
1571    static TENANT_NAVIGATION_TARGET_METADATA: EntityMetadata = EntityMetadata {
1572        rust_name: "TenantNavigationTarget",
1573        schema: "sales",
1574        table: "tenant_navigation_targets",
1575        renamed_from: None,
1576        columns: &TENANT_NAVIGATION_TARGET_COLUMNS,
1577        primary_key: PrimaryKeyMetadata {
1578            name: None,
1579            columns: &["id"],
1580        },
1581        indexes: &[],
1582        foreign_keys: &[],
1583        navigations: &TENANT_NAVIGATION_TARGET_NAVIGATIONS,
1584    };
1585
1586    impl Entity for SoftDeleteEntityUnderTest {
1587        fn metadata() -> &'static EntityMetadata {
1588            &SOFT_DELETE_ENTITY_METADATA
1589        }
1590    }
1591
1592    impl Entity for BoolSoftDeleteEntity {
1593        fn metadata() -> &'static EntityMetadata {
1594            &BOOL_SOFT_DELETE_ENTITY_METADATA
1595        }
1596    }
1597
1598    impl Entity for TenantEntity {
1599        fn metadata() -> &'static EntityMetadata {
1600            &TENANT_ENTITY_METADATA
1601        }
1602    }
1603
1604    impl Entity for TenantNavigationRoot {
1605        fn metadata() -> &'static EntityMetadata {
1606            &TENANT_NAVIGATION_ROOT_METADATA
1607        }
1608    }
1609
1610    impl Entity for TenantNavigationTarget {
1611        fn metadata() -> &'static EntityMetadata {
1612            &TENANT_NAVIGATION_TARGET_METADATA
1613        }
1614    }
1615
1616    impl SoftDeleteEntity for TestEntity {
1617        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1618            None
1619        }
1620    }
1621
1622    impl SoftDeleteEntity for JoinedEntity {
1623        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1624            None
1625        }
1626    }
1627
1628    impl SoftDeleteEntity for SoftDeleteEntityUnderTest {
1629        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1630            Some(EntityPolicyMetadata::new(
1631                "soft_delete",
1632                &SOFT_DELETE_POLICY_COLUMNS,
1633            ))
1634        }
1635    }
1636
1637    impl SoftDeleteEntity for BoolSoftDeleteEntity {
1638        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1639            Some(EntityPolicyMetadata::new(
1640                "soft_delete",
1641                &BOOL_SOFT_DELETE_POLICY_COLUMNS,
1642            ))
1643        }
1644    }
1645
1646    impl SoftDeleteEntity for TenantEntity {
1647        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1648            None
1649        }
1650    }
1651
1652    impl TenantScopedEntity for TestEntity {
1653        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1654            None
1655        }
1656    }
1657
1658    impl TenantScopedEntity for JoinedEntity {
1659        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1660            None
1661        }
1662    }
1663
1664    impl TenantScopedEntity for SoftDeleteEntityUnderTest {
1665        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1666            None
1667        }
1668    }
1669
1670    impl TenantScopedEntity for BoolSoftDeleteEntity {
1671        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1672            None
1673        }
1674    }
1675
1676    impl TenantScopedEntity for TenantEntity {
1677        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1678            Some(EntityPolicyMetadata::new("tenant", &TENANT_POLICY_COLUMNS))
1679        }
1680    }
1681
1682    impl SoftDeleteEntity for NavigationRoot {
1683        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1684            Some(EntityPolicyMetadata::new(
1685                "soft_delete",
1686                &SOFT_DELETE_POLICY_COLUMNS,
1687            ))
1688        }
1689    }
1690
1691    impl SoftDeleteEntity for NavigationTarget {
1692        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1693            None
1694        }
1695    }
1696
1697    impl SoftDeleteEntity for TenantNavigationRoot {
1698        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1699            None
1700        }
1701    }
1702
1703    impl SoftDeleteEntity for TenantNavigationTarget {
1704        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
1705            None
1706        }
1707    }
1708
1709    impl TenantScopedEntity for NavigationRoot {
1710        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1711            None
1712        }
1713    }
1714
1715    impl TenantScopedEntity for NavigationTarget {
1716        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1717            None
1718        }
1719    }
1720
1721    impl TenantScopedEntity for TenantNavigationRoot {
1722        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1723            Some(EntityPolicyMetadata::new("tenant", &TENANT_POLICY_COLUMNS))
1724        }
1725    }
1726
1727    impl TenantScopedEntity for TenantNavigationTarget {
1728        fn tenant_policy() -> Option<EntityPolicyMetadata> {
1729            None
1730        }
1731    }
1732
1733    impl IncludeCollection<NavigationTarget> for NavigationRoot {
1734        fn set_included_collection(
1735            &mut self,
1736            _navigation: &str,
1737            _values: Vec<NavigationTarget>,
1738        ) -> Result<(), OrmError> {
1739            Ok(())
1740        }
1741    }
1742
1743    #[derive(Debug)]
1744    struct TestProjectionRow;
1745
1746    impl FromRow for TestProjectionRow {
1747        fn from_row<R: Row>(_row: &R) -> Result<Self, OrmError> {
1748            Ok(Self)
1749        }
1750    }
1751
1752    #[test]
1753    fn dbset_query_starts_from_entity_select_query() {
1754        let dbset = DbSet::<TestEntity>::disconnected();
1755        let query = dbset.query();
1756
1757        assert_eq!(
1758            query.select_query(),
1759            &SelectQuery::from_entity::<TestEntity>()
1760        );
1761    }
1762
1763    #[test]
1764    fn dbset_query_accepts_replacement_select_query() {
1765        let dbset = DbSet::<TestEntity>::disconnected();
1766        let custom = SelectQuery::from_entity::<TestEntity>().filter(Predicate::eq(
1767            Expr::value(SqlValue::Bool(true)),
1768            Expr::value(SqlValue::Bool(true)),
1769        ));
1770
1771        let query = dbset.query().with_select_query(custom.clone());
1772
1773        assert_eq!(query.select_query(), &custom);
1774        assert_eq!(query.into_select_query(), custom);
1775    }
1776
1777    #[test]
1778    fn dbset_query_filter_builds_on_internal_select_query() {
1779        let dbset = DbSet::<TestEntity>::disconnected();
1780
1781        let query = dbset.query().filter(Predicate::eq(
1782            Expr::value(SqlValue::Bool(true)),
1783            Expr::value(SqlValue::Bool(true)),
1784        ));
1785
1786        assert_eq!(
1787            query.into_select_query(),
1788            SelectQuery::from_entity::<TestEntity>().filter(Predicate::eq(
1789                Expr::value(SqlValue::Bool(true)),
1790                Expr::value(SqlValue::Bool(true)),
1791            ))
1792        );
1793    }
1794
1795    #[test]
1796    fn dbset_query_select_builds_projection_with_aliases() {
1797        let dbset = DbSet::<TestEntity>::disconnected();
1798
1799        let query = dbset
1800            .query()
1801            .select((TestEntity::id, TestEntity::name))
1802            .into_select_query();
1803
1804        assert_eq!(
1805            query.projection,
1806            vec![
1807                SelectProjection::column(TestEntity::id),
1808                SelectProjection::column(TestEntity::name),
1809            ]
1810        );
1811    }
1812
1813    #[tokio::test]
1814    async fn dbset_query_all_as_reuses_projection_compilation_before_connection() {
1815        let dbset = DbSet::<TestEntity>::disconnected();
1816
1817        let error = dbset
1818            .query()
1819            .select(TestEntity::id)
1820            .all_as::<TestProjectionRow>()
1821            .await
1822            .unwrap_err();
1823
1824        assert_eq!(
1825            error.message(),
1826            "DbSetQuery requires an initialized shared connection"
1827        );
1828    }
1829
1830    #[tokio::test]
1831    async fn dbset_query_first_as_rejects_unaliased_expression_projection() {
1832        let dbset = DbSet::<TestEntity>::disconnected();
1833
1834        let error = dbset
1835            .query()
1836            .select(Expr::function("LOWER", vec![Expr::from(TestEntity::name)]))
1837            .first_as::<TestProjectionRow>()
1838            .await
1839            .unwrap_err();
1840
1841        assert_eq!(
1842            error.message(),
1843            "SQL Server projection expressions require an explicit alias"
1844        );
1845    }
1846
1847    #[test]
1848    fn dbset_query_applies_active_only_visibility_for_nullable_indicator() {
1849        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
1850
1851        let query = dbset.query().effective_select_query().unwrap();
1852
1853        assert_eq!(
1854            query,
1855            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>().filter(Predicate::is_null(
1856                Expr::Column(sql_orm_query::ColumnRef::new(
1857                    TableRef::new("dbo", "soft_delete_entities"),
1858                    "deleted_at",
1859                    "deleted_at",
1860                )),
1861            ))
1862        );
1863    }
1864
1865    #[test]
1866    fn dbset_query_with_deleted_removes_soft_delete_filter() {
1867        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
1868
1869        let query = dbset
1870            .query()
1871            .with_deleted()
1872            .effective_select_query()
1873            .unwrap();
1874
1875        assert_eq!(
1876            query,
1877            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>()
1878        );
1879    }
1880
1881    #[test]
1882    fn dbset_query_only_deleted_filters_nullable_indicator() {
1883        let dbset = DbSet::<SoftDeleteEntityUnderTest>::disconnected();
1884
1885        let query = dbset
1886            .query()
1887            .only_deleted()
1888            .effective_select_query()
1889            .unwrap();
1890
1891        assert_eq!(
1892            query,
1893            SelectQuery::from_entity::<SoftDeleteEntityUnderTest>().filter(Predicate::is_not_null(
1894                Expr::Column(sql_orm_query::ColumnRef::new(
1895                    TableRef::new("dbo", "soft_delete_entities"),
1896                    "deleted_at",
1897                    "deleted_at",
1898                ))
1899            ))
1900        );
1901    }
1902
1903    #[test]
1904    fn dbset_query_uses_bool_indicator_when_soft_delete_column_is_bit() {
1905        let dbset = DbSet::<BoolSoftDeleteEntity>::disconnected();
1906
1907        let active = dbset.query().effective_select_query().unwrap();
1908        let deleted = dbset
1909            .query()
1910            .only_deleted()
1911            .effective_select_query()
1912            .unwrap();
1913
1914        assert_eq!(
1915            active,
1916            SelectQuery::from_entity::<BoolSoftDeleteEntity>().filter(Predicate::eq(
1917                Expr::Column(sql_orm_query::ColumnRef::new(
1918                    TableRef::new("dbo", "bool_soft_delete_entities"),
1919                    "is_deleted",
1920                    "is_deleted",
1921                )),
1922                Expr::Value(SqlValue::Bool(false)),
1923            ))
1924        );
1925        assert_eq!(
1926            deleted,
1927            SelectQuery::from_entity::<BoolSoftDeleteEntity>().filter(Predicate::eq(
1928                Expr::Column(sql_orm_query::ColumnRef::new(
1929                    TableRef::new("dbo", "bool_soft_delete_entities"),
1930                    "is_deleted",
1931                    "is_deleted",
1932                )),
1933                Expr::Value(SqlValue::Bool(true)),
1934            ))
1935        );
1936    }
1937
1938    #[test]
1939    fn dbset_query_applies_active_tenant_filter_for_tenant_scoped_entities() {
1940        let query = DbSetQuery::<TenantEntity>::new(
1941            None,
1942            SelectQuery::from_entity::<TenantEntity>().filter(Predicate::eq(
1943                Expr::value(SqlValue::Bool(true)),
1944                Expr::value(SqlValue::Bool(true)),
1945            )),
1946        )
1947        .with_active_tenant_for_test(ActiveTenant {
1948            column_name: "tenant_id",
1949            value: SqlValue::I64(42),
1950        })
1951        .effective_select_query()
1952        .unwrap();
1953
1954        assert_eq!(
1955            query,
1956            SelectQuery::from_entity::<TenantEntity>()
1957                .filter(Predicate::eq(
1958                    Expr::value(SqlValue::Bool(true)),
1959                    Expr::value(SqlValue::Bool(true)),
1960                ))
1961                .filter(Predicate::eq(
1962                    Expr::Column(sql_orm_query::ColumnRef::new(
1963                        TableRef::new("sales", "tenant_entities"),
1964                        "tenant_id",
1965                        "tenant_id",
1966                    )),
1967                    Expr::Value(SqlValue::I64(42)),
1968                ))
1969        );
1970    }
1971
1972    #[test]
1973    fn tenant_security_guardrail_keeps_joined_read_sql_tenant_scoped() {
1974        let query = DbSetQuery::<TenantEntity>::new(
1975            None,
1976            SelectQuery::from_entity::<TenantEntity>().inner_join::<JoinedEntity>(Predicate::eq(
1977                Expr::value(SqlValue::Bool(true)),
1978                Expr::value(SqlValue::Bool(true)),
1979            )),
1980        )
1981        .with_active_tenant_for_test(ActiveTenant {
1982            column_name: "tenant_id",
1983            value: SqlValue::I64(42),
1984        })
1985        .effective_select_query()
1986        .unwrap();
1987
1988        let compiled = SqlServerCompiler::compile_select(&query).unwrap();
1989
1990        assert!(
1991            compiled.sql.contains("INNER JOIN [dbo].[joined_entities]"),
1992            "joined tenant read should preserve explicit joins: {}",
1993            compiled.sql
1994        );
1995        assert!(
1996            compiled
1997                .sql
1998                .contains("[sales].[tenant_entities].[tenant_id] = @P"),
1999            "joined tenant read must include tenant predicate on the root entity: {}",
2000            compiled.sql
2001        );
2002        assert!(
2003            compiled.params.contains(&SqlValue::I64(42)),
2004            "joined tenant read params must include active tenant value: {:?}",
2005            compiled.params
2006        );
2007    }
2008
2009    #[test]
2010    fn dbset_query_fails_closed_without_active_tenant_for_tenant_scoped_entities() {
2011        let error =
2012            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2013                .effective_select_query()
2014                .unwrap_err();
2015
2016        assert!(
2017            error
2018                .message()
2019                .contains("requires an active tenant in the DbContext")
2020        );
2021    }
2022
2023    #[test]
2024    fn dbset_query_rejects_mismatched_active_tenant_column() {
2025        let error =
2026            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2027                .with_active_tenant_for_test(ActiveTenant {
2028                    column_name: "company_id",
2029                    value: SqlValue::I64(42),
2030                })
2031                .effective_select_query()
2032                .unwrap_err();
2033
2034        assert!(error.message().contains("does not match"));
2035    }
2036
2037    #[test]
2038    fn dbset_query_rejects_incompatible_active_tenant_value() {
2039        let error =
2040            DbSetQuery::<TenantEntity>::new(None, SelectQuery::from_entity::<TenantEntity>())
2041                .with_active_tenant_for_test(ActiveTenant {
2042                    column_name: "tenant_id",
2043                    value: SqlValue::String("not-a-bigint".to_string()),
2044                })
2045                .effective_select_query()
2046                .unwrap_err();
2047
2048        assert!(error.message().contains("not compatible"));
2049    }
2050
2051    #[test]
2052    fn tenant_value_type_matching_rejects_null_even_for_nullable_columns() {
2053        assert!(!tenant_value_matches_column_type(
2054            &SqlValue::Null,
2055            &TENANT_POLICY_COLUMNS[0],
2056        ));
2057    }
2058
2059    #[test]
2060    fn dbset_query_order_by_builds_on_internal_select_query() {
2061        let dbset = DbSet::<TestEntity>::disconnected();
2062
2063        let query = dbset.query().order_by(OrderBy::new(
2064            TableRef::new("dbo", "test_entities"),
2065            "created_at",
2066            SortDirection::Desc,
2067        ));
2068
2069        assert_eq!(
2070            query.into_select_query(),
2071            SelectQuery::from_entity::<TestEntity>().order_by(OrderBy::new(
2072                TableRef::new("dbo", "test_entities"),
2073                "created_at",
2074                SortDirection::Desc,
2075            ))
2076        );
2077    }
2078
2079    #[test]
2080    fn dbset_query_join_builds_on_internal_select_query() {
2081        let dbset = DbSet::<TestEntity>::disconnected();
2082        let join = Join::left(
2083            TableRef::new("dbo", "joined_entities"),
2084            Predicate::eq(
2085                Expr::value(SqlValue::Bool(true)),
2086                Expr::value(SqlValue::Bool(true)),
2087            ),
2088        );
2089
2090        let query = dbset.query().join(join.clone());
2091
2092        assert_eq!(
2093            query.into_select_query(),
2094            SelectQuery::from_entity::<TestEntity>().join(join)
2095        );
2096    }
2097
2098    #[test]
2099    fn dbset_query_exposes_entity_targeted_join_helpers() {
2100        let dbset = DbSet::<TestEntity>::disconnected();
2101
2102        let query = dbset
2103            .query()
2104            .inner_join::<JoinedEntity>(Predicate::eq(
2105                Expr::value(SqlValue::Bool(true)),
2106                Expr::value(SqlValue::Bool(true)),
2107            ))
2108            .left_join::<JoinedEntity>(Predicate::eq(
2109                Expr::value(SqlValue::Bool(false)),
2110                Expr::value(SqlValue::Bool(false)),
2111            ));
2112
2113        let select = query.into_select_query();
2114
2115        assert_eq!(select.joins.len(), 2);
2116        assert_eq!(select.joins[0].join_type, JoinType::Inner);
2117        assert_eq!(
2118            select.joins[0].table,
2119            TableRef::new("dbo", "joined_entities")
2120        );
2121        assert_eq!(select.joins[1].join_type, JoinType::Left);
2122        assert_eq!(
2123            select.joins[1].table,
2124            TableRef::new("dbo", "joined_entities")
2125        );
2126    }
2127
2128    #[test]
2129    fn dbset_query_infers_navigation_join_from_metadata() {
2130        let dbset = DbSet::<NavigationRoot>::disconnected();
2131
2132        let select = dbset
2133            .query()
2134            .try_inner_join_navigation::<NavigationTarget>("orders")
2135            .unwrap()
2136            .into_select_query();
2137
2138        assert_eq!(select.joins.len(), 1);
2139        assert_eq!(select.joins[0].join_type, JoinType::Inner);
2140        assert_eq!(
2141            select.joins[0].table,
2142            TableRef::new("sales", "navigation_targets")
2143        );
2144        assert_eq!(
2145            select.joins[0].on,
2146            Predicate::eq(
2147                Expr::Column(ColumnRef::new(
2148                    TableRef::new("dbo", "navigation_roots"),
2149                    "id",
2150                    "id",
2151                )),
2152                Expr::Column(ColumnRef::new(
2153                    TableRef::new("sales", "navigation_targets"),
2154                    "owner_id",
2155                    "owner_id",
2156                )),
2157            )
2158        );
2159    }
2160
2161    #[test]
2162    fn dbset_query_infers_aliased_navigation_join_from_metadata() {
2163        let dbset = DbSet::<NavigationRoot>::disconnected();
2164
2165        let select = dbset
2166            .query()
2167            .try_left_join_navigation_as::<NavigationTarget>("orders", "orders")
2168            .unwrap()
2169            .into_select_query();
2170
2171        assert_eq!(select.joins.len(), 1);
2172        assert_eq!(select.joins[0].join_type, JoinType::Left);
2173        assert_eq!(
2174            select.joins[0].table,
2175            TableRef::with_alias("sales", "navigation_targets", "orders")
2176        );
2177        assert_eq!(
2178            select.joins[0].on,
2179            Predicate::eq(
2180                Expr::Column(ColumnRef::new(
2181                    TableRef::new("dbo", "navigation_roots"),
2182                    "id",
2183                    "id",
2184                )),
2185                Expr::Column(ColumnRef::new(
2186                    TableRef::with_alias("sales", "navigation_targets", "orders"),
2187                    "owner_id",
2188                    "owner_id",
2189                )),
2190            )
2191        );
2192    }
2193
2194    #[test]
2195    fn dbset_query_rejects_unknown_navigation_join() {
2196        let error = DbSet::<NavigationRoot>::disconnected()
2197            .query()
2198            .try_inner_join_navigation::<NavigationTarget>("missing")
2199            .unwrap_err();
2200
2201        assert!(
2202            error
2203                .message()
2204                .contains("does not declare navigation `missing`")
2205        );
2206    }
2207
2208    #[test]
2209    fn dbset_query_rejects_navigation_join_target_mismatch() {
2210        let error = DbSet::<NavigationRoot>::disconnected()
2211            .query()
2212            .try_inner_join_navigation::<JoinedEntity>("orders")
2213            .unwrap_err();
2214
2215        assert!(
2216            error
2217                .message()
2218                .contains("targets `sales.navigation_targets`")
2219        );
2220    }
2221
2222    #[test]
2223    fn dbset_query_include_projects_root_and_prefixed_related_columns() {
2224        let include = DbSet::<NavigationTarget>::disconnected()
2225            .query()
2226            .include_as::<NavigationRoot>("owner", "owner")
2227            .unwrap();
2228
2229        let select = include.select_query().unwrap();
2230
2231        assert_eq!(select.joins.len(), 1);
2232        assert_eq!(select.joins[0].join_type, JoinType::Left);
2233        assert_eq!(
2234            select.joins[0].table,
2235            TableRef::with_alias("dbo", "navigation_roots", "owner")
2236        );
2237        assert_eq!(select.projection.len(), 3);
2238        assert_eq!(select.projection[0].alias, Some("id"));
2239        assert_eq!(select.projection[1].alias, Some("owner_id"));
2240        assert_eq!(select.projection[2].alias, Some("owner__id"));
2241    }
2242
2243    #[test]
2244    fn compiled_include_sql_preserves_projection_aliases_soft_delete_and_params() {
2245        let include = DbSet::<NavigationTarget>::disconnected()
2246            .query()
2247            .include_as::<NavigationRoot>("owner", "owner")
2248            .unwrap()
2249            .filter(Predicate::gt(
2250                Expr::Column(ColumnRef::new(
2251                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2252                    "id",
2253                    "id",
2254                )),
2255                Expr::value(SqlValue::I64(7)),
2256            ))
2257            .order_by(OrderBy::new(
2258                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2259                "id",
2260                SortDirection::Desc,
2261            ))
2262            .paginate(PageRequest::new(2, 10));
2263
2264        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
2265
2266        assert_snapshot!(
2267            "compiled_include_one_with_soft_delete_and_parameters",
2268            render_compiled_query(&compiled)
2269        );
2270    }
2271
2272    #[test]
2273    fn dbset_query_include_applies_included_soft_delete_filter_to_join_on() {
2274        let include = DbSet::<NavigationTarget>::disconnected()
2275            .query()
2276            .include_as::<NavigationRoot>("owner", "owner")
2277            .unwrap();
2278
2279        let select = include.select_query().unwrap();
2280
2281        assert_eq!(
2282            select.joins[0].on,
2283            Predicate::and(vec![
2284                Predicate::eq(
2285                    Expr::Column(ColumnRef::new(
2286                        TableRef::new("sales", "navigation_targets"),
2287                        "owner_id",
2288                        "owner_id",
2289                    )),
2290                    Expr::Column(ColumnRef::new(
2291                        TableRef::with_alias("dbo", "navigation_roots", "owner"),
2292                        "id",
2293                        "id",
2294                    )),
2295                ),
2296                Predicate::is_null(Expr::Column(ColumnRef::new(
2297                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2298                    "deleted_at",
2299                    "deleted_at",
2300                ))),
2301            ])
2302        );
2303    }
2304
2305    #[test]
2306    fn dbset_query_include_applies_included_tenant_filter_to_join_on() {
2307        let include = DbSet::<TenantNavigationTarget>::disconnected()
2308            .query()
2309            .with_active_tenant_for_test(ActiveTenant {
2310                column_name: "tenant_id",
2311                value: SqlValue::I64(42),
2312            })
2313            .include_as::<TenantNavigationRoot>("owner", "owner")
2314            .unwrap();
2315
2316        let select = include.select_query().unwrap();
2317
2318        assert_eq!(
2319            select.joins[0].on,
2320            Predicate::and(vec![
2321                Predicate::eq(
2322                    Expr::Column(ColumnRef::new(
2323                        TableRef::new("sales", "tenant_navigation_targets"),
2324                        "owner_id",
2325                        "owner_id",
2326                    )),
2327                    Expr::Column(ColumnRef::new(
2328                        TableRef::with_alias("sales", "tenant_navigation_roots", "owner"),
2329                        "id",
2330                        "id",
2331                    )),
2332                ),
2333                Predicate::eq(
2334                    Expr::Column(ColumnRef::new(
2335                        TableRef::with_alias("sales", "tenant_navigation_roots", "owner"),
2336                        "tenant_id",
2337                        "tenant_id",
2338                    )),
2339                    Expr::Value(SqlValue::I64(42)),
2340                ),
2341            ])
2342        );
2343    }
2344
2345    #[test]
2346    fn compiled_include_sql_preserves_included_tenant_parameter_order() {
2347        let include = DbSet::<TenantNavigationTarget>::disconnected()
2348            .query()
2349            .filter(Predicate::gt(
2350                Expr::Column(ColumnRef::new(
2351                    TableRef::new("sales", "tenant_navigation_targets"),
2352                    "id",
2353                    "id",
2354                )),
2355                Expr::value(SqlValue::I64(100)),
2356            ))
2357            .with_active_tenant_for_test(ActiveTenant {
2358                column_name: "tenant_id",
2359                value: SqlValue::I64(42),
2360            })
2361            .include_as::<TenantNavigationRoot>("owner", "owner")
2362            .unwrap();
2363
2364        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
2365
2366        assert_snapshot!(
2367            "compiled_include_one_with_included_tenant_parameter_order",
2368            render_compiled_query(&compiled)
2369        );
2370        assert_eq!(compiled.params, vec![SqlValue::I64(42), SqlValue::I64(100)]);
2371    }
2372
2373    #[test]
2374    fn dbset_query_include_fails_closed_for_included_tenant_without_active_tenant() {
2375        let include = DbSet::<TenantNavigationTarget>::disconnected()
2376            .query()
2377            .include_as::<TenantNavigationRoot>("owner", "owner")
2378            .unwrap();
2379
2380        let error = include.select_query().unwrap_err();
2381
2382        assert!(
2383            error
2384                .message()
2385                .contains("requires an active tenant in the DbContext")
2386        );
2387    }
2388
2389    #[test]
2390    fn dbset_query_include_supports_chained_filter_order_and_pagination() {
2391        let include = DbSet::<NavigationTarget>::disconnected()
2392            .query()
2393            .include_as::<NavigationRoot>("owner", "owner")
2394            .unwrap()
2395            .filter(Predicate::gt(
2396                Expr::Column(ColumnRef::new(
2397                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2398                    "id",
2399                    "id",
2400                )),
2401                Expr::value(SqlValue::I64(0)),
2402            ))
2403            .order_by(OrderBy::new(
2404                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2405                "id",
2406                SortDirection::Desc,
2407            ))
2408            .paginate(PageRequest::new(2, 10));
2409
2410        let select = include.select_query().unwrap();
2411
2412        assert_eq!(
2413            select.predicate,
2414            Some(Predicate::gt(
2415                Expr::Column(ColumnRef::new(
2416                    TableRef::with_alias("dbo", "navigation_roots", "owner"),
2417                    "id",
2418                    "id",
2419                )),
2420                Expr::value(SqlValue::I64(0)),
2421            ))
2422        );
2423        assert_eq!(
2424            select.order_by,
2425            vec![OrderBy::new(
2426                TableRef::with_alias("dbo", "navigation_roots", "owner"),
2427                "id",
2428                SortDirection::Desc,
2429            )]
2430        );
2431        assert_eq!(select.pagination, Some(Pagination::new(10, 10)));
2432    }
2433
2434    #[test]
2435    fn dbset_query_include_rejects_collection_navigation() {
2436        let result = DbSet::<NavigationRoot>::disconnected()
2437            .query()
2438            .include::<NavigationTarget>("orders");
2439        let error = match result {
2440            Ok(_) => panic!("expected collection include to be rejected"),
2441            Err(error) => error,
2442        };
2443
2444        assert!(error.message().contains("belongs_to and has_one"));
2445    }
2446
2447    #[test]
2448    fn dbset_query_include_many_projects_root_and_prefixed_related_columns() {
2449        let include = DbSet::<NavigationRoot>::disconnected()
2450            .query()
2451            .include_many_as::<NavigationTarget>("orders", "orders")
2452            .unwrap();
2453
2454        let select = include.select_query().unwrap();
2455
2456        assert_eq!(select.joins.len(), 1);
2457        assert_eq!(select.joins[0].join_type, JoinType::Left);
2458        assert_eq!(
2459            select.joins[0].table,
2460            TableRef::with_alias("sales", "navigation_targets", "orders")
2461        );
2462        assert_eq!(select.projection.len(), 3);
2463        assert_eq!(select.projection[0].alias, Some("id"));
2464        assert_eq!(select.projection[1].alias, Some("orders__id"));
2465        assert_eq!(select.projection[2].alias, Some("orders__owner_id"));
2466    }
2467
2468    #[test]
2469    fn compiled_include_many_sql_preserves_grouping_projection_and_root_soft_delete() {
2470        let include = DbSet::<NavigationRoot>::disconnected()
2471            .query()
2472            .include_many_as::<NavigationTarget>("orders", "orders")
2473            .unwrap()
2474            .filter(Predicate::gte(
2475                Expr::Column(ColumnRef::new(
2476                    TableRef::with_alias("sales", "navigation_targets", "orders"),
2477                    "owner_id",
2478                    "owner_id",
2479                )),
2480                Expr::value(SqlValue::I64(7)),
2481            ))
2482            .order_by(OrderBy::new(
2483                TableRef::with_alias("sales", "navigation_targets", "orders"),
2484                "id",
2485                SortDirection::Asc,
2486            ));
2487
2488        let compiled = SqlServerCompiler::compile_select(&include.select_query().unwrap()).unwrap();
2489
2490        assert_snapshot!(
2491            "compiled_include_many_with_root_soft_delete_and_parameters",
2492            render_compiled_query(&compiled)
2493        );
2494        assert_eq!(compiled.params, vec![SqlValue::I64(7)]);
2495    }
2496
2497    #[test]
2498    fn dbset_query_include_many_rejects_non_collection_navigation() {
2499        let result = DbSet::<NavigationTarget>::disconnected()
2500            .query()
2501            .include_many::<NavigationRoot>("owner");
2502        let error = match result {
2503            Ok(_) => panic!("expected non-collection include_many to be rejected"),
2504            Err(error) => error,
2505        };
2506
2507        assert!(error.message().contains("has_many"));
2508    }
2509
2510    #[test]
2511    fn dbset_query_include_many_rejects_pagination_for_join_grouping() {
2512        let include = DbSet::<NavigationRoot>::disconnected()
2513            .query()
2514            .take(10)
2515            .include_many_as::<NavigationTarget>("orders", "orders")
2516            .unwrap();
2517
2518        let error = include.select_query().unwrap_err();
2519
2520        assert!(error.message().contains("does not support pagination"));
2521    }
2522
2523    #[tokio::test]
2524    async fn dbset_query_include_many_split_query_reports_explicit_error() {
2525        let error = DbSet::<NavigationRoot>::disconnected()
2526            .query()
2527            .include_many_as::<NavigationTarget>("orders", "orders")
2528            .unwrap()
2529            .split_query()
2530            .all()
2531            .await
2532            .unwrap_err();
2533
2534        assert!(
2535            error
2536                .message()
2537                .contains("split-query loading is not implemented yet")
2538        );
2539    }
2540
2541    #[test]
2542    fn include_many_join_row_limit_reports_clear_error() {
2543        let error = enforce_include_many_join_row_limit(11, Some(10)).unwrap_err();
2544
2545        assert!(error.message().contains("produced 11 rows"));
2546        assert!(error.message().contains("configured limit of 10"));
2547    }
2548
2549    #[test]
2550    fn include_many_join_row_limit_allows_explicit_unbounded_join() {
2551        enforce_include_many_join_row_limit(usize::MAX, None).unwrap();
2552    }
2553
2554    #[test]
2555    fn compiled_self_join_sql_preserves_repeated_aliases_and_parameter_order() {
2556        let query = SelectQuery::from_entity_as::<TestEntity>("root")
2557            .select(vec![
2558                SelectProjection::expr_as(Expr::column_as(TestEntity::id, "root"), "root_id"),
2559                SelectProjection::expr_as(
2560                    Expr::column_as(TestEntity::name, "parent"),
2561                    "parent_name",
2562                ),
2563                SelectProjection::expr_as(Expr::column_as(TestEntity::name, "child"), "child_name"),
2564            ])
2565            .inner_join_as::<TestEntity>(
2566                "parent",
2567                Predicate::eq(
2568                    Expr::column_as(TestEntity::id, "root"),
2569                    Expr::column_as(TestEntity::id, "parent"),
2570                ),
2571            )
2572            .left_join_as::<TestEntity>(
2573                "child",
2574                Predicate::eq(
2575                    Expr::column_as(TestEntity::id, "root"),
2576                    Expr::column_as(TestEntity::id, "child"),
2577                ),
2578            )
2579            .filter(Predicate::like(
2580                Expr::column_as(TestEntity::name, "parent"),
2581                Expr::value(SqlValue::String("%admin%".to_string())),
2582            ))
2583            .filter(Predicate::gte(
2584                Expr::column_as(TestEntity::id, "child"),
2585                Expr::value(SqlValue::I64(10)),
2586            ))
2587            .order_by(OrderBy::new(
2588                TableRef::with_alias("dbo", "test_entities", "child"),
2589                "name",
2590                SortDirection::Asc,
2591            ))
2592            .paginate(Pagination::new(20, 10));
2593
2594        let compiled = SqlServerCompiler::compile_select(&query).unwrap();
2595
2596        assert_snapshot!(
2597            "compiled_self_join_repeated_aliases_and_parameter_order",
2598            render_compiled_query(&compiled)
2599        );
2600        assert_eq!(
2601            compiled.params,
2602            vec![
2603                SqlValue::String("%admin%".to_string()),
2604                SqlValue::I64(10),
2605                SqlValue::I64(20),
2606                SqlValue::I64(10),
2607            ]
2608        );
2609    }
2610
2611    #[test]
2612    fn dbset_query_supports_chaining_filter_and_order_by() {
2613        let dbset = DbSet::<TestEntity>::disconnected();
2614
2615        let query = dbset
2616            .query()
2617            .filter(Predicate::eq(
2618                Expr::value(SqlValue::Bool(true)),
2619                Expr::value(SqlValue::Bool(true)),
2620            ))
2621            .order_by(OrderBy::new(
2622                TableRef::new("dbo", "test_entities"),
2623                "created_at",
2624                SortDirection::Asc,
2625            ));
2626
2627        assert_eq!(
2628            query.into_select_query(),
2629            SelectQuery::from_entity::<TestEntity>()
2630                .filter(Predicate::eq(
2631                    Expr::value(SqlValue::Bool(true)),
2632                    Expr::value(SqlValue::Bool(true)),
2633                ))
2634                .order_by(OrderBy::new(
2635                    TableRef::new("dbo", "test_entities"),
2636                    "created_at",
2637                    SortDirection::Asc,
2638                ))
2639        );
2640    }
2641
2642    #[test]
2643    fn dbset_query_limit_builds_zero_offset_pagination() {
2644        let dbset = DbSet::<TestEntity>::disconnected();
2645
2646        let query = dbset.query().limit(25);
2647
2648        assert_eq!(
2649            query.into_select_query(),
2650            SelectQuery::from_entity::<TestEntity>().paginate(Pagination::new(0, 25))
2651        );
2652    }
2653
2654    #[test]
2655    fn dbset_query_take_is_alias_for_limit() {
2656        let dbset = DbSet::<TestEntity>::disconnected();
2657
2658        let limited = dbset.query().limit(10).into_select_query();
2659        let taken = dbset.query().take(10).into_select_query();
2660
2661        assert_eq!(limited, taken);
2662    }
2663
2664    #[test]
2665    fn dbset_query_paginate_uses_page_request_contract() {
2666        let dbset = DbSet::<TestEntity>::disconnected();
2667
2668        let query = dbset.query().paginate(PageRequest::new(3, 25));
2669
2670        assert_eq!(
2671            query.into_select_query(),
2672            SelectQuery::from_entity::<TestEntity>().paginate(Pagination::new(50, 25))
2673        );
2674    }
2675
2676    #[test]
2677    fn count_row_accepts_i32_and_i64_results() {
2678        struct CountTestRow {
2679            value: SqlValue,
2680        }
2681
2682        impl Row for CountTestRow {
2683            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
2684                Ok((column == "count").then(|| self.value.clone()))
2685            }
2686        }
2687
2688        let from_i32 = super::CountRow::from_row(&CountTestRow {
2689            value: SqlValue::I32(7),
2690        })
2691        .unwrap();
2692        let from_i64 = super::CountRow::from_row(&CountTestRow {
2693            value: SqlValue::I64(9),
2694        })
2695        .unwrap();
2696
2697        assert_eq!(from_i32.value, 7);
2698        assert_eq!(from_i64.value, 9);
2699    }
2700
2701    #[test]
2702    fn count_row_rejects_non_integer_results() {
2703        struct CountTestRow;
2704
2705        impl Row for CountTestRow {
2706            fn try_get(&self, column: &str) -> Result<Option<SqlValue>, OrmError> {
2707                Ok((column == "count").then(|| SqlValue::String("7".to_string())))
2708            }
2709        }
2710
2711        let error = super::CountRow::from_row(&CountTestRow).unwrap_err();
2712
2713        assert_eq!(
2714            error.message(),
2715            "expected SQL Server COUNT result as i32 or i64"
2716        );
2717    }
2718
2719    #[test]
2720    fn debug_mentions_entity_type() {
2721        let query = DbSetQuery::<TestEntity>::new(None, SelectQuery::from_entity::<TestEntity>());
2722
2723        let rendered = format!("{query:?}");
2724
2725        assert!(rendered.contains("DbSetQuery"));
2726        assert!(rendered.contains("test_entities"));
2727    }
2728
2729    fn render_compiled_query(compiled: &CompiledQuery) -> String {
2730        let params = compiled
2731            .params
2732            .iter()
2733            .enumerate()
2734            .map(|(index, value)| format!("{}: {:?}", index + 1, value))
2735            .collect::<Vec<_>>();
2736
2737        if params.is_empty() {
2738            format!("SQL: {}\nParams:\n<none>", compiled.sql)
2739        } else {
2740            format!("SQL: {}\nParams:\n{}", compiled.sql, params.join("\n"))
2741        }
2742    }
2743}