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)]
16pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CollectionIncludeStrategy {
40 Join,
43 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 pub fn with_select_query(mut self, select_query: SelectQuery) -> Self {
75 self.select_query = select_query;
76 self
77 }
78
79 pub fn filter(mut self, predicate: Predicate) -> Self {
81 self.select_query = self.select_query.filter(predicate);
82 self
83 }
84
85 pub fn join(mut self, join: Join) -> Self {
87 self.select_query = self.select_query.join(join);
88 self
89 }
90
91 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 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 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 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 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 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 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 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 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 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 pub fn order_by(mut self, order: OrderBy) -> Self {
238 self.select_query = self.select_query.order_by(order);
239 self
240 }
241
242 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 pub fn take(self, limit: u64) -> Self {
250 self.limit(limit)
251 }
252
253 pub fn paginate(mut self, request: PageRequest) -> Self {
255 self.select_query = self.select_query.paginate(request.to_pagination());
256 self
257 }
258
259 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 pub fn with_deleted(mut self) -> Self {
282 self.visibility = SoftDeleteVisibility::WithDeleted;
283 self
284 }
285
286 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 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 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 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 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 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
477pub 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 pub fn filter(mut self, predicate: Predicate) -> Self {
489 self.query = self.query.filter(predicate);
490 self
491 }
492
493 pub fn join(mut self, join: Join) -> Self {
495 self.query = self.query.join(join);
496 self
497 }
498
499 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 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 pub fn order_by(mut self, order: OrderBy) -> Self {
513 self.query = self.query.order_by(order);
514 self
515 }
516
517 pub fn limit(mut self, limit: u64) -> Self {
519 self.query = self.query.limit(limit);
520 self
521 }
522
523 pub fn take(self, limit: u64) -> Self {
525 self.limit(limit)
526 }
527
528 pub fn paginate(mut self, request: PageRequest) -> Self {
530 self.query = self.query.paginate(request);
531 self
532 }
533
534 pub fn with_deleted(mut self) -> Self {
539 self.query = self.query.with_deleted();
540 self
541 }
542
543 pub fn only_deleted(mut self) -> Self {
548 self.query = self.query.only_deleted();
549 self
550 }
551
552 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 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
614pub 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 pub fn filter(mut self, predicate: Predicate) -> Self {
628 self.query = self.query.filter(predicate);
629 self
630 }
631
632 pub fn join(mut self, join: Join) -> Self {
634 self.query = self.query.join(join);
635 self
636 }
637
638 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 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 pub fn order_by(mut self, order: OrderBy) -> Self {
652 self.query = self.query.order_by(order);
653 self
654 }
655
656 pub fn join_strategy(mut self) -> Self {
661 self.strategy = CollectionIncludeStrategy::Join;
662 self
663 }
664
665 pub fn split_query(mut self) -> Self {
670 self.strategy = CollectionIncludeStrategy::SplitQuery;
671 self
672 }
673
674 pub fn max_joined_rows(mut self, limit: usize) -> Self {
678 self.join_row_limit = Some(limit);
679 self
680 }
681
682 pub fn unbounded_join(mut self) -> Self {
687 self.join_row_limit = None;
688 self
689 }
690
691 pub fn with_deleted(mut self) -> Self {
696 self.query = self.query.with_deleted();
697 self
698 }
699
700 pub fn only_deleted(mut self) -> Self {
705 self.query = self.query.only_deleted();
706 self
707 }
708
709 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}