1use crate::sql::{Dialect, SqlValue};
36use crate::tenancy::ResolvedScope;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Agg {
43 Count,
44 Sum,
45 Avg,
46 Min,
47 Max,
48}
49
50impl Agg {
51 fn keyword(self) -> &'static str {
52 match self {
53 Self::Count => "count",
54 Self::Sum => "sum",
55 Self::Avg => "avg",
56 Self::Min => "min",
57 Self::Max => "max",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BinOp {
65 Add,
66 Sub,
67 Mul,
68 Div,
69 Mod,
70}
71
72impl BinOp {
73 fn symbol(self) -> &'static str {
74 match self {
75 Self::Add => "+",
76 Self::Sub => "-",
77 Self::Mul => "*",
78 Self::Div => "/",
79 Self::Mod => "%",
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Func {
88 Lower,
89 Upper,
90 Length,
91 Trim,
92 Abs,
93 Round,
94 Coalesce,
95 Now,
97}
98
99impl Func {
100 fn spec(self) -> (&'static str, usize, Option<usize>) {
103 match self {
104 Self::Lower => ("lower", 1, Some(1)),
105 Self::Upper => ("upper", 1, Some(1)),
106 Self::Length => ("length", 1, Some(1)),
107 Self::Trim => ("trim", 1, Some(1)),
108 Self::Abs => ("abs", 1, Some(1)),
109 Self::Round => ("round", 1, Some(2)),
110 Self::Coalesce => ("coalesce", 2, None),
111 Self::Now => ("current_timestamp", 0, Some(0)),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Metric {
120 Cosine,
122 L2,
124}
125
126impl Metric {
127 fn operator(self) -> &'static str {
128 match self {
129 Self::Cosine => "<=>",
130 Self::L2 => "<->",
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum RelArg {
140 Star,
142 Column(String),
144}
145
146#[derive(Debug, Clone, PartialEq)]
149pub enum Expr {
150 Column(String),
152 Value(SqlValue),
154 Star,
156 Aggregate(Agg, Box<Self>),
158 Binary(BinOp, Box<Self>, Box<Self>),
160 Func(Func, Vec<Self>),
162 JsonExtract(Box<Self>, Vec<String>),
166 Distance {
171 left: Box<Self>,
172 right: Box<Self>,
173 metric: Metric,
174 },
175 VectorLiteral(String),
179 RelatedAggregate {
188 agg: Agg,
189 arg: RelArg,
190 table: String,
191 filter: Box<Predicate>,
192 },
193 Case {
197 branches: Vec<(Predicate, Self)>,
198 otherwise: Option<Box<Self>>,
199 },
200 JsonExtractDyn(Box<Self>, Box<Self>),
204 JsonConcat(Box<Self>, Box<Self>),
207 RelatedScalar {
212 column: String,
213 table: String,
214 filter: Box<Predicate>,
215 },
216 IsOwn,
226}
227
228impl Expr {
229 pub fn col(name: impl Into<String>) -> Self {
231 Self::Column(name.into())
232 }
233 pub fn val(v: impl Into<SqlValue>) -> Self {
235 Self::Value(v.into())
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum CmpOp {
244 Eq,
245 Ne,
246 Lt,
247 Le,
248 Gt,
249 Ge,
250}
251
252impl CmpOp {
253 fn symbol(self) -> &'static str {
255 match self {
256 Self::Eq => "=",
257 Self::Ne => "<>",
258 Self::Lt => "<",
259 Self::Le => "<=",
260 Self::Gt => ">",
261 Self::Ge => ">=",
262 }
263 }
264}
265
266#[derive(Debug, Clone, PartialEq)]
268pub enum Predicate {
269 And(Vec<Self>),
271 Or(Vec<Self>),
273 Not(Box<Self>),
275 Cmp { left: Expr, op: CmpOp, right: Expr },
277 Between {
279 expr: Expr,
280 low: Expr,
281 high: Expr,
282 negated: bool,
283 },
284 In {
287 expr: Expr,
288 values: Vec<Expr>,
289 negated: bool,
290 },
291 Like {
294 expr: Expr,
295 pattern: String,
296 insensitive: bool,
297 negated: bool,
298 },
299 Null { expr: Expr, negated: bool },
301 InSubquery {
304 expr: Expr,
305 column: String,
306 table: String,
307 filter: Box<Self>,
308 negated: bool,
309 },
310}
311
312pub fn all(preds: impl IntoIterator<Item = Predicate>) -> Predicate {
314 Predicate::And(preds.into_iter().collect())
315}
316pub fn any(preds: impl IntoIterator<Item = Predicate>) -> Predicate {
318 Predicate::Or(preds.into_iter().collect())
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub enum JoinKind {
326 Inner,
327 Left,
328}
329
330#[derive(Debug, Clone, PartialEq)]
332pub struct Join {
333 pub kind: JoinKind,
334 pub table: String,
335 pub alias: Option<String>,
336 pub on: Predicate,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum Direction {
342 Asc,
343 Desc,
344}
345
346#[derive(Debug, Clone, PartialEq)]
348pub struct OrderBy {
349 pub expr: Expr,
350 pub dir: Direction,
351}
352
353#[derive(Debug, Clone, PartialEq)]
355pub struct SelectItem {
356 pub expr: Expr,
357 pub alias: Option<String>,
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
365pub enum ScopeMode {
366 #[default]
368 Own,
369 OwnOrNull,
371 NullOnly,
373 All,
376}
377
378#[derive(Debug, Clone, PartialEq, Default)]
388pub enum TableKeys {
389 #[default]
390 Uniform,
391 PerTable(std::collections::BTreeMap<String, ResolvedScope>),
392 PerTableTarget {
397 keys: std::collections::BTreeMap<String, ResolvedScope>,
398 public: std::collections::BTreeMap<String, Vec<PublicTermSql>>,
399 write: std::collections::BTreeSet<String>,
407 require_public: bool,
417 },
418}
419
420#[derive(Debug, Clone, PartialEq)]
424pub enum PublicTermSql {
425 Cmp {
427 column: String,
428 op: CmpOp,
429 value: SqlValue,
430 },
431 Null { column: String, negated: bool },
433}
434
435pub fn lower_public_terms(pred: &crate::tenancy::PublicPredicate) -> Vec<PublicTermSql> {
439 use crate::tenancy::{PublicCmp, PublicLiteral, PublicTerm};
440 pred.terms
441 .iter()
442 .map(|t| match t {
443 PublicTerm::Cmp { column, op, value } => {
444 let op = match op {
445 PublicCmp::Eq => CmpOp::Eq,
446 PublicCmp::Ne => CmpOp::Ne,
447 PublicCmp::Lt => CmpOp::Lt,
448 PublicCmp::Le => CmpOp::Le,
449 PublicCmp::Gt => CmpOp::Gt,
450 PublicCmp::Ge => CmpOp::Ge,
451 };
452 let value = match value {
453 PublicLiteral::Bool(b) => SqlValue::Boolean(*b),
454 PublicLiteral::Int(n) => SqlValue::Integer(*n),
455 PublicLiteral::Text(s) => SqlValue::Text(s.clone()),
456 };
457 PublicTermSql::Cmp {
458 column: column.clone(),
459 op,
460 value,
461 }
462 }
463 PublicTerm::Null { column, negated } => PublicTermSql::Null {
464 column: column.clone(),
465 negated: *negated,
466 },
467 })
468 .collect()
469}
470
471#[derive(Debug, Clone, PartialEq)]
480pub struct Scope {
481 pub column: String,
482 pub value: Option<SqlValue>,
484 pub session: Option<SqlValue>,
487 pub mode: ScopeMode,
488 pub keys: TableKeys,
491}
492
493impl Scope {
494 fn resolve_table(&self, table: &str) -> Result<ResolvedScope, OrmError> {
499 match &self.keys {
500 TableKeys::Uniform => Ok(ResolvedScope::Column(self.column.clone())),
501 TableKeys::PerTable(m) | TableKeys::PerTableTarget { keys: m, .. } => m
502 .get(table)
503 .cloned()
504 .ok_or_else(|| OrmError::TenancyUndeclared(table.to_string())),
505 }
506 }
507
508 fn public_pred(
524 &self,
525 table: &str,
526 qualifier: Option<&str>,
527 ) -> Result<Option<Predicate>, OrmError> {
528 let TableKeys::PerTableTarget {
529 public,
530 require_public,
531 ..
532 } = &self.keys
533 else {
534 return Ok(None);
535 };
536 if !require_public {
540 return Ok(None);
541 }
542 let terms = match public.get(table) {
543 Some(t) => t,
544 None => return Err(OrmError::PublicSubsetUndeclared(table.to_string())),
547 };
548 let mut preds = Vec::with_capacity(terms.len());
549 for term in terms {
550 match term {
551 PublicTermSql::Cmp { column, op, value } => {
552 ident(column)?;
553 preds.push(Predicate::Cmp {
554 left: Self::col_expr(column, qualifier),
555 op: *op,
556 right: Expr::Value(value.clone()),
557 });
558 }
559 PublicTermSql::Null { column, negated } => {
560 ident(column)?;
561 preds.push(Predicate::Null {
562 expr: Self::col_expr(column, qualifier),
563 negated: *negated,
564 });
565 }
566 }
567 }
568 Ok(match preds.len() {
569 0 => None,
570 1 => Some(preds.pop().unwrap()),
571 _ => Some(Predicate::And(preds)),
572 })
573 }
574
575 fn col_expr(column: &str, qualifier: Option<&str>) -> Expr {
577 Expr::Column(match qualifier {
578 Some(q) => format!("{q}.{column}"),
579 None => column.to_string(),
580 })
581 }
582
583 fn tenant_pred(
596 &self,
597 column: &str,
598 qualifier: Option<&str>,
599 base_inclusive: bool,
600 ) -> Result<Option<Predicate>, OrmError> {
601 let is_null = Predicate::Null {
602 expr: Self::col_expr(column, qualifier),
603 negated: false,
604 };
605 let eq = |v: SqlValue| Predicate::Cmp {
606 left: Self::col_expr(column, qualifier),
607 op: CmpOp::Eq,
608 right: Expr::Value(v),
609 };
610 Ok(match self.mode {
611 ScopeMode::All => None,
612 ScopeMode::NullOnly => Some(is_null),
613 ScopeMode::Own => {
614 let v = self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
615 Some(if base_inclusive {
616 Predicate::Or(vec![eq(v), is_null])
617 } else {
618 eq(v)
619 })
620 }
621 ScopeMode::OwnOrNull => {
622 let v = self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
623 Some(Predicate::Or(vec![eq(v), is_null]))
624 }
625 })
626 }
627
628 fn disjunct_pred(
636 &self,
637 tenant_col: &str,
638 session_col: &str,
639 qualifier: Option<&str>,
640 ) -> Result<Option<Predicate>, OrmError> {
641 if matches!(self.mode, ScopeMode::All) {
642 return Ok(None);
643 }
644 let eq = |column: &str, v: SqlValue| Predicate::Cmp {
645 left: Self::col_expr(column, qualifier),
646 op: CmpOp::Eq,
647 right: Expr::Value(v),
648 };
649 let mut arms = Vec::new();
650 if let Some(v) = self.value.clone() {
651 arms.push(eq(tenant_col, v));
652 }
653 if let Some(s) = self.session.clone() {
654 arms.push(eq(session_col, s));
655 }
656 match arms.len() {
657 0 => Err(OrmError::TenancyNoPrincipal),
658 1 => Ok(arms.pop()),
659 _ => Ok(Some(Predicate::Or(arms))),
660 }
661 }
662
663 fn read_pred(
668 &self,
669 table: &str,
670 qualifier: Option<&str>,
671 ) -> Result<Option<Predicate>, OrmError> {
672 let tenant = match self.resolve_table(table)? {
673 ResolvedScope::Column(col) => {
674 ident(&col)?;
675 self.tenant_pred(&col, qualifier, false)?
676 }
677 ResolvedScope::Unscoped => None,
678 ResolvedScope::TenantOrSession { tenant, session } => {
679 ident(&tenant)?;
680 ident(&session)?;
681 self.disjunct_pred(&tenant, &session, qualifier)?
682 }
683 ResolvedScope::TenantOrBase { tenant } => {
686 ident(&tenant)?;
687 self.tenant_pred(&tenant, qualifier, true)?
688 }
689 };
690 let public = self.public_pred(table, qualifier)?;
694 let mut out = Predicate::And(Vec::new());
695 conjoin_front(&mut out, tenant);
696 conjoin_front(&mut out, public);
697 Ok(match out {
698 Predicate::And(v) if v.is_empty() => None,
699 p => Some(p),
700 })
701 }
702
703 fn write_target(&self, table: &str) -> Result<Option<(String, SqlValue)>, OrmError> {
712 let tenant_stamp = || -> Result<Option<SqlValue>, OrmError> {
715 Ok(match self.mode {
716 ScopeMode::All => None,
717 ScopeMode::NullOnly => Some(SqlValue::Null),
718 ScopeMode::Own | ScopeMode::OwnOrNull => {
719 Some(self.value.clone().ok_or(OrmError::TenancyNoPrincipal)?)
720 }
721 })
722 };
723 match self.resolve_table(table)? {
724 ResolvedScope::Column(col) => Ok(tenant_stamp()?.map(|v| (col, v))),
725 ResolvedScope::TenantOrBase { tenant } => Ok(tenant_stamp()?.map(|v| (tenant, v))),
730 ResolvedScope::Unscoped => Err(OrmError::UnscopedWrite(table.to_string())),
731 ResolvedScope::TenantOrSession { tenant, session } => {
732 if matches!(self.mode, ScopeMode::All) {
733 return Ok(None);
734 }
735 if self.is_target() {
741 return Err(OrmError::TargetWriteToSessionTable(table.to_string()));
742 }
743 if let Some(v) = self.value.clone() {
745 Ok(Some((tenant, v)))
746 } else if let Some(s) = self.session.clone() {
747 Ok(Some((session, s)))
748 } else {
749 Err(OrmError::TenancyNoPrincipal)
750 }
751 }
752 }
753 }
754
755 pub fn is_target(&self) -> bool {
758 matches!(self.keys, TableKeys::PerTableTarget { .. })
759 }
760
761 fn target_write_allowlist(&self) -> Option<&std::collections::BTreeSet<String>> {
766 match &self.keys {
767 TableKeys::PerTableTarget { write, .. } => Some(write),
768 _ => None,
769 }
770 }
771
772 fn target_require_public(&self) -> bool {
779 matches!(
780 &self.keys,
781 TableKeys::PerTableTarget {
782 require_public: true,
783 ..
784 }
785 )
786 }
787
788 fn target_tenant_column(&self, table: &str) -> Option<String> {
792 match &self.keys {
793 TableKeys::PerTableTarget { keys, .. } => match keys.get(table) {
794 Some(ResolvedScope::Column(c)) => Some(c.clone()),
795 Some(ResolvedScope::TenantOrBase { tenant }) => Some(tenant.clone()),
798 _ => None,
799 },
800 _ => None,
801 }
802 }
803
804 fn assert_target_table_writable(&self, table: &str) -> Result<(), OrmError> {
813 if !self.is_target() {
814 return Ok(());
815 }
816 if let ResolvedScope::TenantOrSession { .. } = self.resolve_table(table)? {
817 return Err(OrmError::TargetWriteToSessionTable(table.to_string()));
818 }
819 Ok(())
820 }
821
822 fn public_force_cells(&self, table: &str) -> Result<Vec<(String, SqlValue)>, OrmError> {
832 let TableKeys::PerTableTarget {
833 public,
834 require_public,
835 ..
836 } = &self.keys
837 else {
838 return Ok(Vec::new());
839 };
840 let terms = match public.get(table) {
841 Some(t) => t,
842 None if !require_public => return Ok(Vec::new()),
843 None => return Err(OrmError::PublicSubsetUndeclared(table.to_string())),
844 };
845 let mut out = Vec::with_capacity(terms.len());
846 for term in terms {
847 match term {
848 PublicTermSql::Cmp {
849 column,
850 op: CmpOp::Eq,
851 value,
852 } => {
853 ident(column)?;
854 out.push((column.clone(), value.clone()));
855 }
856 PublicTermSql::Null {
857 column,
858 negated: false,
859 } => {
860 ident(column)?;
861 out.push((column.clone(), SqlValue::Null));
862 }
863 PublicTermSql::Cmp { .. } | PublicTermSql::Null { .. } => {
865 return Err(OrmError::PublicSubsetNotForceable(table.to_string()))
866 }
867 }
868 }
869 Ok(out)
870 }
871
872 fn assert_target_settable(&self, table: &str, column: &str) -> Result<(), OrmError> {
879 let TableKeys::PerTableTarget {
880 keys,
881 public,
882 write,
883 ..
884 } = &self.keys
885 else {
886 return Ok(());
887 };
888 let denied = || OrmError::TargetWriteColumnDenied(column.to_string());
889 if column.contains('.') {
895 return Err(denied());
896 }
897 if !write.iter().any(|c| same_col(c, column)) {
899 return Err(denied());
900 }
901 if let Some(rs) = keys.get(table) {
903 let tenant_cols: &[&str] = match rs {
904 ResolvedScope::Column(c) => &[c],
905 ResolvedScope::TenantOrSession { tenant, session } => &[tenant, session],
906 ResolvedScope::TenantOrBase { tenant } => &[tenant],
909 ResolvedScope::Unscoped => &[],
910 };
911 if tenant_cols.iter().any(|t| same_col(t, column)) {
912 return Err(denied());
913 }
914 }
915 if let Some(terms) = public.get(table) {
917 let is_public_col = terms.iter().any(|t| match t {
918 PublicTermSql::Cmp { column: c, .. } | PublicTermSql::Null { column: c, .. } => {
919 same_col(c, column)
920 }
921 });
922 if is_public_col {
923 return Err(denied());
924 }
925 }
926 Ok(())
927 }
928}
929
930#[derive(Debug, Clone, PartialEq)]
932pub struct Select {
933 pub table: String,
934 pub table_alias: Option<String>,
935 pub columns: Vec<SelectItem>,
937 pub joins: Vec<Join>,
938 pub filter: Option<Predicate>,
939 pub scope: Option<Scope>,
940 pub group_by: Vec<Expr>,
941 pub having: Option<Predicate>,
942 pub distinct: bool,
943 pub distinct_on: Vec<Expr>,
946 pub order: Vec<OrderBy>,
947 pub limit: Option<u32>,
948 pub offset: Option<u32>,
949 pub union: Option<Box<Union>>,
952}
953
954#[derive(Debug, Clone, PartialEq)]
956pub struct Union {
957 pub all: bool,
958 pub query: Select,
959}
960
961#[derive(Debug, Clone, PartialEq)]
963pub struct Assignment {
964 pub column: String,
965 pub value: Expr,
966}
967
968#[derive(Debug, Clone, PartialEq)]
970pub struct RowValues {
971 pub cells: Vec<Assignment>,
972}
973
974#[derive(Debug, Clone, PartialEq)]
976pub struct OnConflict {
977 pub conflict_columns: Vec<String>,
978 pub update: Vec<Assignment>,
979}
980
981#[derive(Debug, Clone, PartialEq)]
983pub struct Insert {
984 pub table: String,
985 pub rows: Vec<RowValues>,
986 pub conflict: Option<OnConflict>,
987 pub scope: Option<Scope>,
989 pub returning: Vec<SelectItem>,
991 pub from_select: Option<(Vec<String>, Box<Select>)>,
996}
997
998#[derive(Debug, Clone, PartialEq)]
1000pub struct Update {
1001 pub table: String,
1002 pub set: Vec<Assignment>,
1003 pub filter: Predicate,
1004 pub scope: Option<Scope>,
1005 pub returning: Vec<SelectItem>,
1006}
1007
1008#[derive(Debug, Clone, PartialEq)]
1010pub struct Delete {
1011 pub table: String,
1012 pub filter: Predicate,
1013 pub scope: Option<Scope>,
1014 pub returning: Vec<SelectItem>,
1015}
1016
1017impl Insert {
1018 pub fn uniform_scope_value(&self, col: &str) -> Option<SqlValue> {
1028 if self.from_select.is_some() || self.rows.is_empty() {
1029 return None;
1030 }
1031 let mut found: Option<SqlValue> = None;
1032 for row in &self.rows {
1033 let cell = row.cells.iter().find(|a| same_col(&a.column, col))?;
1034 let v = match &cell.value {
1035 Expr::Value(v) => v.clone(),
1036 _ => return None, };
1038 match &found {
1039 None => found = Some(v),
1040 Some(prev) if *prev == v => {}
1041 Some(_) => return None, }
1043 }
1044 found
1045 }
1046}
1047
1048impl Update {
1049 pub fn pinned_scope_value(&self, col: &str) -> Option<SqlValue> {
1057 fn find(pred: &Predicate, col: &str) -> Option<SqlValue> {
1058 match pred {
1059 Predicate::Cmp {
1060 left,
1061 op: CmpOp::Eq,
1062 right,
1063 } => match (left, right) {
1064 (Expr::Column(c), Expr::Value(v)) | (Expr::Value(v), Expr::Column(c))
1065 if same_col(c, col) =>
1066 {
1067 Some(v.clone())
1068 }
1069 _ => None,
1070 },
1071 Predicate::And(children) => {
1072 let mut found: Option<SqlValue> = None;
1073 for ch in children {
1074 if let Some(v) = find(ch, col) {
1075 match &found {
1076 None => found = Some(v),
1077 Some(prev) if *prev == v => {}
1078 Some(_) => return None, }
1080 }
1081 }
1082 found
1083 }
1084 _ => None,
1086 }
1087 }
1088 find(&self.filter, col)
1089 }
1090}
1091
1092impl Select {
1093 pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1098 self.scope = Some(scope.clone());
1099 self.inject_subquery_scope(scope)?;
1100 if let Some(u) = self.union.as_mut() {
1101 u.query.force_scope(scope)?;
1102 }
1103 Ok(())
1104 }
1105}
1106
1107impl Insert {
1108 pub fn force_scope(
1117 &mut self,
1118 write: Option<&Scope>,
1119 read: Option<&Scope>,
1120 ) -> Result<(), OrmError> {
1121 if let Some(w) = write {
1126 if let Some(allow) = w.target_write_allowlist() {
1127 self.confine_target_insert(w, allow.is_empty())?;
1128 }
1129 }
1130 self.scope = write.cloned();
1131 let target: Option<(String, SqlValue)> = match write {
1136 Some(w) => w.write_target(&self.table)?,
1137 None => None,
1138 };
1139 if let Some(r) = read {
1142 for row in &mut self.rows {
1143 for cell in &mut row.cells {
1144 inject_scope_expr(r, &mut cell.value)?;
1145 }
1146 }
1147 if let Some(c) = self.conflict.as_mut() {
1148 for a in &mut c.update {
1149 inject_scope_expr(r, &mut a.value)?;
1150 }
1151 }
1152 for it in &mut self.returning {
1153 inject_scope_expr(r, &mut it.expr)?;
1154 }
1155 }
1156 if let Some((cols, src)) = self.from_select.as_mut() {
1157 match read {
1158 Some(r) => src.force_scope(r)?,
1159 None => src.scope = None,
1160 }
1161 if let Some((column, v)) = &target {
1167 let column = column.clone();
1168 if let Some(i) = cols.iter().position(|c| same_col(c, &column)) {
1169 cols.remove(i);
1170 drop_projection_at(src, i);
1171 }
1172 let v = v.clone();
1173 cols.push(column);
1174 push_projection(
1175 src,
1176 SelectItem {
1177 expr: Expr::Value(v),
1178 alias: None,
1179 },
1180 );
1181 }
1182 }
1183 Ok(())
1184 }
1185
1186 fn confine_target_insert(
1199 &mut self,
1200 scope: &Scope,
1201 empty_allowlist: bool,
1202 ) -> Result<(), OrmError> {
1203 scope.assert_target_table_writable(&self.table)?;
1206 if empty_allowlist {
1207 return Err(OrmError::TargetWriteNotGranted(self.table.clone()));
1208 }
1209 if self.from_select.is_some() {
1210 return Err(OrmError::TargetWriteUnsupported("INSERT … SELECT"));
1211 }
1212 if let Some(oc) = &self.conflict {
1216 if scope.target_require_public() {
1223 return Err(OrmError::TargetWriteUnsupported(
1224 "ON CONFLICT upsert on an anonymous domain/handle/target_or_null target",
1225 ));
1226 }
1227 for c in &oc.conflict_columns {
1232 if c.contains('.') {
1233 return Err(OrmError::TargetWriteColumnDenied(c.clone()));
1234 }
1235 }
1236 match scope.target_tenant_column(&self.table) {
1242 Some(tcol) if oc.conflict_columns.iter().any(|c| same_col(c, &tcol)) => {}
1243 _ => return Err(OrmError::TargetUpsertKeyMissingTenant(self.table.clone())),
1244 }
1245 for a in &oc.update {
1248 scope.assert_target_settable(&self.table, &a.column)?;
1249 }
1250 }
1251 for row in &self.rows {
1253 for cell in &row.cells {
1254 scope.assert_target_settable(&self.table, &cell.column)?;
1255 }
1256 }
1257 let forced = scope.public_force_cells(&self.table)?;
1261 for row in &mut self.rows {
1262 for (column, value) in &forced {
1263 row.cells.push(Assignment {
1264 column: column.clone(),
1265 value: Expr::Value(value.clone()),
1266 });
1267 }
1268 }
1269 Ok(())
1270 }
1271}
1272
1273fn drop_projection_at(s: &mut Select, i: usize) {
1276 if i < s.columns.len() {
1277 s.columns.remove(i);
1278 }
1279 if let Some(u) = s.union.as_mut() {
1280 drop_projection_at(&mut u.query, i);
1281 }
1282}
1283
1284fn push_projection(s: &mut Select, item: SelectItem) {
1287 s.columns.push(item.clone());
1288 if let Some(u) = s.union.as_mut() {
1289 push_projection(&mut u.query, item);
1290 }
1291}
1292
1293fn conjoin_front(filter: &mut Predicate, add: Option<Predicate>) {
1297 let Some(a) = add else { return };
1298 if matches!(filter, Predicate::And(v) if v.is_empty()) {
1299 *filter = a;
1300 } else {
1301 let existing = std::mem::replace(filter, Predicate::And(Vec::new()));
1302 *filter = Predicate::And(vec![a, existing]);
1303 }
1304}
1305
1306fn own_rank_expr(scope: &Scope) -> Expr {
1313 let Some(value) = scope.value.clone() else {
1315 return Expr::Value(SqlValue::Integer(0));
1316 };
1317 let col = || Expr::Column(scope.column.clone());
1318 let own = Predicate::And(vec![
1319 Predicate::Null {
1320 expr: col(),
1321 negated: true,
1322 },
1323 Predicate::Cmp {
1324 left: col(),
1325 op: CmpOp::Eq,
1326 right: Expr::Value(value),
1327 },
1328 ]);
1329 Expr::Case {
1330 branches: vec![(own, Expr::Value(SqlValue::Integer(1)))],
1331 otherwise: Some(Box::new(Expr::Value(SqlValue::Integer(0)))),
1332 }
1333}
1334
1335fn conjoin_subquery_scope(
1343 scope: &Scope,
1344 table: &str,
1345 filter: &mut Predicate,
1346) -> Result<(), OrmError> {
1347 conjoin_front(filter, scope.read_pred(table, Some(table))?);
1348 Ok(())
1349}
1350
1351fn inject_scope_expr(scope: &Scope, e: &mut Expr) -> Result<(), OrmError> {
1360 match e {
1361 Expr::IsOwn => *e = own_rank_expr(scope),
1362 Expr::RelatedAggregate { table, filter, .. }
1363 | Expr::RelatedScalar { table, filter, .. } => {
1364 inject_scope_pred(scope, filter)?;
1365 conjoin_subquery_scope(scope, table, filter)?;
1366 }
1367 Expr::Aggregate(_, inner) | Expr::JsonExtract(inner, _) => inject_scope_expr(scope, inner)?,
1368 Expr::Binary(_, l, r) | Expr::JsonExtractDyn(l, r) | Expr::JsonConcat(l, r) => {
1369 inject_scope_expr(scope, l)?;
1370 inject_scope_expr(scope, r)?;
1371 }
1372 Expr::Distance { left, right, .. } => {
1373 inject_scope_expr(scope, left)?;
1374 inject_scope_expr(scope, right)?;
1375 }
1376 Expr::Func(_, args) => {
1377 for a in args.iter_mut() {
1378 inject_scope_expr(scope, a)?;
1379 }
1380 }
1381 Expr::Case {
1382 branches,
1383 otherwise,
1384 } => {
1385 for (when, then) in branches {
1386 inject_scope_pred(scope, when)?;
1387 inject_scope_expr(scope, then)?;
1388 }
1389 if let Some(e) = otherwise {
1390 inject_scope_expr(scope, e)?;
1391 }
1392 }
1393 Expr::Column(_) | Expr::Value(_) | Expr::Star | Expr::VectorLiteral(_) => {}
1394 }
1395 Ok(())
1396}
1397
1398fn inject_scope_pred(scope: &Scope, p: &mut Predicate) -> Result<(), OrmError> {
1401 match p {
1402 Predicate::InSubquery {
1403 expr,
1404 table,
1405 filter,
1406 ..
1407 } => {
1408 inject_scope_expr(scope, expr)?;
1409 inject_scope_pred(scope, filter)?;
1410 conjoin_subquery_scope(scope, table, filter)?;
1411 }
1412 Predicate::And(v) | Predicate::Or(v) => {
1413 for c in v.iter_mut() {
1414 inject_scope_pred(scope, c)?;
1415 }
1416 }
1417 Predicate::Not(inner) => inject_scope_pred(scope, inner)?,
1418 Predicate::Cmp { left, right, .. } => {
1419 inject_scope_expr(scope, left)?;
1420 inject_scope_expr(scope, right)?;
1421 }
1422 Predicate::Between {
1423 expr, low, high, ..
1424 } => {
1425 inject_scope_expr(scope, expr)?;
1426 inject_scope_expr(scope, low)?;
1427 inject_scope_expr(scope, high)?;
1428 }
1429 Predicate::In { expr, values, .. } => {
1430 inject_scope_expr(scope, expr)?;
1431 for v in values.iter_mut() {
1432 inject_scope_expr(scope, v)?;
1433 }
1434 }
1435 Predicate::Like { expr, .. } | Predicate::Null { expr, .. } => {
1436 inject_scope_expr(scope, expr)?;
1437 }
1438 }
1439 Ok(())
1440}
1441
1442impl Select {
1443 fn inject_subquery_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1449 for it in &mut self.columns {
1450 inject_scope_expr(scope, &mut it.expr)?;
1451 }
1452 for e in &mut self.distinct_on {
1453 inject_scope_expr(scope, e)?;
1454 }
1455 if let Some(f) = self.filter.as_mut() {
1456 inject_scope_pred(scope, f)?;
1457 }
1458 if let Some(h) = self.having.as_mut() {
1459 inject_scope_pred(scope, h)?;
1460 }
1461 for e in &mut self.group_by {
1462 inject_scope_expr(scope, e)?;
1463 }
1464 for o in &mut self.order {
1465 inject_scope_expr(scope, &mut o.expr)?;
1466 }
1467 for j in &mut self.joins {
1468 inject_scope_pred(scope, &mut j.on)?;
1469 }
1470 Ok(())
1471 }
1472}
1473
1474impl Update {
1475 pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1484 if let Some(allow) = scope.target_write_allowlist() {
1485 scope.assert_target_table_writable(&self.table)?;
1487 if allow.is_empty() {
1488 return Err(OrmError::TargetWriteNotGranted(self.table.clone()));
1489 }
1490 for a in &self.set {
1491 scope.assert_target_settable(&self.table, &a.column)?;
1492 }
1493 if let Some(pred) = scope.public_pred(&self.table, None)? {
1500 conjoin_front(&mut self.filter, Some(pred));
1501 }
1502 }
1503 self.scope = Some(scope.clone());
1504 for a in &mut self.set {
1505 inject_scope_expr(scope, &mut a.value)?;
1506 }
1507 inject_scope_pred(scope, &mut self.filter)?;
1508 for it in &mut self.returning {
1509 inject_scope_expr(scope, &mut it.expr)?;
1510 }
1511 Ok(())
1512 }
1513}
1514
1515impl Delete {
1516 pub fn force_scope(&mut self, scope: &Scope) -> Result<(), OrmError> {
1521 if scope.is_target() {
1522 return Err(OrmError::TargetDeleteRefused(self.table.clone()));
1523 }
1524 self.scope = Some(scope.clone());
1525 inject_scope_pred(scope, &mut self.filter)?;
1526 for it in &mut self.returning {
1527 inject_scope_expr(scope, &mut it.expr)?;
1528 }
1529 Ok(())
1530 }
1531}
1532
1533#[derive(Debug, Clone, PartialEq, thiserror::Error)]
1535pub enum OrmError {
1536 #[error("invalid identifier: {0:?}")]
1538 InvalidIdentifier(String),
1539 #[error("empty query: {0}")]
1541 Empty(&'static str),
1542 #[error("bad expression: {0}")]
1545 BadExpr(&'static str),
1546 #[error("tenancy: table {0:?} has no declared scope (deny-by-default)")]
1552 TenancyUndeclared(String),
1553 #[error("tenancy: table {0:?} is Unscoped (global reference); guest writes are refused (deny-by-default)")]
1558 UnscopedWrite(String),
1559 #[error("tenancy: no resolved principal for a scoped operation (deny-by-default)")]
1564 TenancyNoPrincipal,
1565 #[error(
1571 "tenancy: table {0:?} has no declared public subset for a target read (deny-by-default)"
1572 )]
1573 PublicSubsetUndeclared(String),
1574 #[error("tenancy: target route {0:?} has no write grant (read-only; deny-by-default)")]
1578 TargetWriteNotGranted(String),
1579 #[error("tenancy: target write may not set column {0:?} (not in the write allowlist)")]
1583 TargetWriteColumnDenied(String),
1584 #[error("tenancy: a target-tenant DELETE is refused (target writes are INSERT/UPDATE only)")]
1587 TargetDeleteRefused(String),
1588 #[error("tenancy: target INSERT cannot force table {0:?} into its public subset (a non-equality/non-null public term); refused")]
1593 PublicSubsetNotForceable(String),
1594 #[error("tenancy: unsupported target write shape ({0}); target writes are a plain INSERT, a confined UPDATE, or a capability-axis ON CONFLICT DO UPDATE upsert")]
1601 TargetWriteUnsupported(&'static str),
1602 #[error("tenancy: a target upsert's ON CONFLICT target must include the tenant column for table {0:?} (so a conflict is always the target tenant's own row)")]
1609 TargetUpsertKeyMissingTenant(String),
1610 #[error("tenancy: target write may not touch TenantOrSession table {0:?} (no session fact under a target scope — write it on the session-scoped path)")]
1617 TargetWriteToSessionTable(String),
1618}
1619
1620pub type Compiled = (String, Vec<SqlValue>);
1622
1623fn ident(name: &str) -> Result<&str, OrmError> {
1626 let ok = |s: &str| {
1627 let mut cs = s.chars();
1628 matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
1629 && s.chars().all(|c| c == '_' || c.is_ascii_alphanumeric())
1630 };
1631 let valid = match name.split_once('.') {
1632 Some((t, c)) => !t.is_empty() && !c.is_empty() && ok(t) && ok(c),
1633 None => ok(name),
1634 };
1635 if valid {
1636 Ok(name)
1637 } else {
1638 Err(OrmError::InvalidIdentifier(name.to_string()))
1639 }
1640}
1641
1642fn same_col(a: &str, b: &str) -> bool {
1647 let base = |s: &str| s.rsplit('.').next().unwrap_or(s).to_ascii_lowercase();
1648 base(a) == base(b)
1649}
1650
1651#[derive(Default)]
1653struct Params(Vec<SqlValue>);
1654
1655impl Params {
1656 fn bind(&mut self, v: SqlValue) -> String {
1657 self.0.push(v);
1658 format!("?{}", self.0.len())
1659 }
1660}
1661
1662fn render_expr(e: &Expr, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
1664 Ok(match e {
1665 Expr::Column(name) => ident(name)?.to_string(),
1666 Expr::Value(v) => params.bind(v.clone()),
1667 Expr::Star => {
1668 return Err(OrmError::BadExpr(
1669 "`*` is only valid as the count(*) argument",
1670 ))
1671 }
1672 Expr::Aggregate(agg, inner) => {
1673 let arg = match inner.as_ref() {
1674 Expr::Star if *agg == Agg::Count => "*".to_string(),
1675 Expr::Star => return Err(OrmError::BadExpr("`*` is only valid as count(*)")),
1676 other => render_expr(other, params, dialect)?,
1677 };
1678 format!("{}({arg})", agg.keyword())
1679 }
1680 Expr::Binary(op, l, r) => {
1681 format!(
1682 "({} {} {})",
1683 render_expr(l, params, dialect)?,
1684 op.symbol(),
1685 render_expr(r, params, dialect)?
1686 )
1687 }
1688 Expr::Func(f, args) => {
1689 let (name, min, max) = f.spec();
1690 if args.len() < min || max.is_some_and(|m| args.len() > m) {
1691 return Err(OrmError::BadExpr("function called with the wrong arity"));
1692 }
1693 if args.is_empty() {
1694 name.to_string()
1696 } else {
1697 let rendered: Result<Vec<String>, _> = args
1698 .iter()
1699 .map(|a| render_expr(a, params, dialect))
1700 .collect();
1701 format!("{name}({})", rendered?.join(", "))
1702 }
1703 }
1704 Expr::JsonExtract(inner, path) => {
1705 if path.is_empty() {
1706 return Err(OrmError::BadExpr("json extract needs at least one key"));
1707 }
1708 for k in path {
1710 ident(k)?;
1711 }
1712 let base = render_expr(inner, params, dialect)?;
1713 match dialect {
1714 Dialect::Postgres => format!("({base}) #>> '{{{}}}'", path.join(",")),
1717 Dialect::Sqlite | Dialect::Mysql => {
1719 let p = params.bind(SqlValue::Text(format!("$.{}", path.join("."))));
1720 format!("json_extract({base}, {p})")
1721 }
1722 }
1723 }
1724 Expr::Distance {
1725 left,
1726 right,
1727 metric,
1728 } => {
1729 if dialect != Dialect::Postgres {
1730 return Err(OrmError::BadExpr("vector distance is Postgres-only"));
1731 }
1732 format!(
1733 "({} {} {})",
1734 render_expr(left, params, dialect)?,
1735 metric.operator(),
1736 render_expr(right, params, dialect)?,
1737 )
1738 }
1739 Expr::VectorLiteral(v) => {
1740 if dialect != Dialect::Postgres {
1741 return Err(OrmError::BadExpr("vector literals are Postgres-only"));
1742 }
1743 let p = params.bind(SqlValue::Text(vector_literal(v)?));
1744 format!("{p}::vector")
1746 }
1747 Expr::RelatedAggregate {
1748 agg,
1749 arg,
1750 table,
1751 filter,
1752 } => {
1753 let arg_sql = match arg {
1754 RelArg::Star if *agg == Agg::Count => "*".to_string(),
1755 RelArg::Star => return Err(OrmError::BadExpr("`*` is only valid as count(*)")),
1756 RelArg::Column(c) => ident(c)?.to_string(),
1757 };
1758 let table_sql = ident(table)?;
1759 let where_sql = render_pred(filter, params, false, dialect)?;
1762 format!(
1763 "(SELECT {}({arg_sql}) FROM {table_sql} WHERE {where_sql})",
1764 agg.keyword()
1765 )
1766 }
1767 Expr::RelatedScalar {
1768 column,
1769 table,
1770 filter,
1771 } => {
1772 let col_sql = ident(column)?;
1773 let table_sql = ident(table)?;
1774 let where_sql = render_pred(filter, params, false, dialect)?;
1775 format!("(SELECT {col_sql} FROM {table_sql} WHERE {where_sql})")
1776 }
1777 Expr::JsonExtractDyn(base, key) => {
1778 if matches!(dialect, Dialect::Mysql) {
1779 return Err(OrmError::BadExpr(
1780 "dynamic-key json extract (->> <bound>) is not supported on MySQL",
1781 ));
1782 }
1783 format!(
1784 "({} ->> {})",
1785 render_expr(base, params, dialect)?,
1786 render_expr(key, params, dialect)?,
1787 )
1788 }
1789 Expr::JsonConcat(left, right) => {
1790 if dialect != Dialect::Postgres {
1791 return Err(OrmError::BadExpr("json concat (||) is Postgres-only"));
1792 }
1793 format!(
1794 "({} || {})",
1795 render_expr(left, params, dialect)?,
1796 render_expr(right, params, dialect)?,
1797 )
1798 }
1799 Expr::Case {
1800 branches,
1801 otherwise,
1802 } => {
1803 if branches.is_empty() {
1804 return Err(OrmError::BadExpr("CASE has no WHEN branches"));
1805 }
1806 let mut s = String::from("CASE");
1807 for (when, then) in branches {
1808 let w = render_pred(when, params, false, dialect)?;
1811 let t = render_expr(then, params, dialect)?;
1812 s.push_str(&format!(" WHEN {w} THEN {t}"));
1813 }
1814 if let Some(e) = otherwise {
1815 let e = render_expr(e, params, dialect)?;
1816 s.push_str(&format!(" ELSE {e}"));
1817 }
1818 s.push_str(" END");
1819 format!("({s})")
1820 }
1821 Expr::IsOwn => {
1825 return Err(OrmError::BadExpr(
1826 "is_own()/own_first() requires an own-tenant (own or own+null) read scope",
1827 ))
1828 }
1829 })
1830}
1831
1832fn vector_literal(s: &str) -> Result<String, OrmError> {
1837 let inner = s
1838 .trim()
1839 .strip_prefix('[')
1840 .and_then(|x| x.strip_suffix(']'))
1841 .ok_or(OrmError::BadExpr(
1842 "vector literal must be a bracketed list like [0.1, 0.2]",
1843 ))?;
1844 if inner.trim().is_empty() {
1845 return Err(OrmError::BadExpr(
1846 "vector literal must have at least one component",
1847 ));
1848 }
1849 let mut parts = Vec::new();
1850 for part in inner.split(',') {
1851 let p = part.trim();
1852 let f: f64 = p
1853 .parse()
1854 .map_err(|_| OrmError::BadExpr("vector literal component is not a number"))?;
1855 if !f.is_finite() {
1856 return Err(OrmError::BadExpr("vector literal component must be finite"));
1857 }
1858 parts.push(p);
1859 }
1860 Ok(format!("[{}]", parts.join(",")))
1861}
1862
1863fn render_pred(
1865 p: &Predicate,
1866 params: &mut Params,
1867 nested: bool,
1868 dialect: Dialect,
1869) -> Result<String, OrmError> {
1870 let compound = |body: String| {
1871 if nested {
1872 format!("({body})")
1873 } else {
1874 body
1875 }
1876 };
1877 Ok(match p {
1878 Predicate::And(ps) => {
1879 if ps.is_empty() {
1880 "1 = 1".to_string()
1881 } else {
1882 let parts: Result<Vec<String>, _> = ps
1883 .iter()
1884 .map(|c| render_pred(c, params, true, dialect))
1885 .collect();
1886 compound(parts?.join(" AND "))
1887 }
1888 }
1889 Predicate::Or(ps) => {
1890 if ps.is_empty() {
1891 "1 = 0".to_string()
1892 } else {
1893 let parts: Result<Vec<String>, _> = ps
1894 .iter()
1895 .map(|c| render_pred(c, params, true, dialect))
1896 .collect();
1897 compound(parts?.join(" OR "))
1898 }
1899 }
1900 Predicate::Not(inner) => format!("NOT {}", render_pred(inner, params, true, dialect)?),
1901 Predicate::Cmp { left, op, right } => format!(
1902 "{} {} {}",
1903 render_expr(left, params, dialect)?,
1904 op.symbol(),
1905 render_expr(right, params, dialect)?
1906 ),
1907 Predicate::Between {
1908 expr,
1909 low,
1910 high,
1911 negated,
1912 } => format!(
1913 "{} {}BETWEEN {} AND {}",
1914 render_expr(expr, params, dialect)?,
1915 if *negated { "NOT " } else { "" },
1916 render_expr(low, params, dialect)?,
1917 render_expr(high, params, dialect)?
1918 ),
1919 Predicate::In {
1920 expr,
1921 values,
1922 negated,
1923 } => {
1924 if values.is_empty() {
1925 if *negated { "1 = 1" } else { "1 = 0" }.to_string()
1927 } else {
1928 let lhs = render_expr(expr, params, dialect)?;
1929 let ph: Result<Vec<String>, _> = values
1930 .iter()
1931 .map(|v| render_expr(v, params, dialect))
1932 .collect();
1933 format!(
1934 "{lhs} {}IN ({})",
1935 if *negated { "NOT " } else { "" },
1936 ph?.join(", ")
1937 )
1938 }
1939 }
1940 Predicate::Like {
1941 expr,
1942 pattern,
1943 insensitive,
1944 negated,
1945 } => {
1946 let neg = if *negated { "NOT " } else { "" };
1947 let lhs = render_expr(expr, params, dialect)?;
1948 let pat = params.bind(SqlValue::Text(pattern.clone()));
1949 if *insensitive {
1950 format!("lower({lhs}) {neg}LIKE lower({pat})")
1952 } else {
1953 format!("{lhs} {neg}LIKE {pat}")
1954 }
1955 }
1956 Predicate::Null { expr, negated } => format!(
1957 "{} IS {}NULL",
1958 render_expr(expr, params, dialect)?,
1959 if *negated { "NOT " } else { "" }
1960 ),
1961 Predicate::InSubquery {
1962 expr,
1963 column,
1964 table,
1965 filter,
1966 negated,
1967 } => {
1968 let lhs = render_expr(expr, params, dialect)?;
1969 let col_sql = ident(column)?;
1970 let table_sql = ident(table)?;
1971 let where_sql = render_pred(filter, params, false, dialect)?;
1972 let not = if *negated { "NOT " } else { "" };
1973 format!("{lhs} {not}IN (SELECT {col_sql} FROM {table_sql} WHERE {where_sql})")
1974 }
1975 })
1976}
1977
1978fn render_where(
1983 scope_pred: Option<Predicate>,
1984 filter: Option<&Predicate>,
1985 params: &mut Params,
1986 dialect: Dialect,
1987) -> Result<Option<String>, OrmError> {
1988 let filter = filter.filter(|f| !matches!(f, Predicate::And(v) if v.is_empty()));
1991 let combined = match (scope_pred, filter) {
1994 (None, None) => return Ok(None),
1995 (Some(s), None) => s,
1996 (None, Some(f)) => f.clone(),
1997 (Some(s), Some(f)) => Predicate::And(vec![s, f.clone()]),
1998 };
1999 Ok(Some(render_pred(&combined, params, false, dialect)?))
2000}
2001
2002fn single_scope_pred(scope: Option<&Scope>, table: &str) -> Result<Option<Predicate>, OrmError> {
2010 let Some(s) = scope else { return Ok(None) };
2011 match s.write_target(table)? {
2012 None => Ok(None), Some((col, value)) => {
2014 ident(&col)?;
2015 let col_expr = Expr::Column(col);
2016 let pred = if matches!(value, SqlValue::Null) {
2019 Predicate::Null {
2020 expr: col_expr,
2021 negated: false,
2022 }
2023 } else {
2024 Predicate::Cmp {
2025 left: col_expr,
2026 op: CmpOp::Eq,
2027 right: Expr::Value(value),
2028 }
2029 };
2030 Ok(Some(pred))
2031 }
2032 }
2033}
2034
2035fn render_select_items(
2037 items: &[SelectItem],
2038 params: &mut Params,
2039 dialect: Dialect,
2040) -> Result<String, OrmError> {
2041 if items.is_empty() {
2042 return Ok("*".to_string());
2043 }
2044 let parts: Result<Vec<String>, _> = items
2045 .iter()
2046 .map(|it| {
2047 let e = render_expr(&it.expr, params, dialect)?;
2048 Ok::<String, OrmError>(match &it.alias {
2049 Some(a) => format!("{e} AS {}", ident(a)?),
2050 None => e,
2051 })
2052 })
2053 .collect();
2054 Ok(parts?.join(", "))
2055}
2056
2057fn render_returning(
2059 items: &[SelectItem],
2060 params: &mut Params,
2061 dialect: Dialect,
2062) -> Result<String, OrmError> {
2063 if items.is_empty() {
2064 Ok(String::new())
2065 } else {
2066 Ok(format!(
2067 " RETURNING {}",
2068 render_select_items(items, params, dialect)?
2069 ))
2070 }
2071}
2072
2073impl Select {
2074 pub fn from(table: impl Into<String>) -> Self {
2076 Self {
2077 table: table.into(),
2078 table_alias: None,
2079 columns: Vec::new(),
2080 joins: Vec::new(),
2081 filter: None,
2082 scope: None,
2083 group_by: Vec::new(),
2084 having: None,
2085 distinct: false,
2086 distinct_on: Vec::new(),
2087 order: Vec::new(),
2088 limit: None,
2089 offset: None,
2090 union: None,
2091 }
2092 }
2093
2094 pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2097 let mut params = Params::default();
2098 let sql = self.render_into(&mut params, dialect)?;
2099 Ok((sql, params.0))
2100 }
2101
2102 fn scope_where_pred(&self) -> Result<Option<Predicate>, OrmError> {
2110 let Some(scope) = &self.scope else {
2111 return Ok(None);
2112 };
2113 if self.joins.is_empty() {
2114 return scope.read_pred(&self.table, None);
2117 }
2118 let refs: Vec<(&str, &str)> = std::iter::once((
2124 self.table.as_str(),
2125 self.table_alias.as_deref().unwrap_or(&self.table),
2126 ))
2127 .chain(
2128 self.joins
2129 .iter()
2130 .filter(|j| matches!(j.kind, JoinKind::Inner))
2131 .map(|j| (j.table.as_str(), j.alias.as_deref().unwrap_or(&j.table))),
2132 )
2133 .collect();
2134 let mut parts: Vec<Predicate> = Vec::with_capacity(refs.len());
2135 for (table, qual) in refs {
2136 ident(qual)?;
2137 if let Some(p) = scope.read_pred(table, Some(qual))? {
2138 parts.push(p);
2139 }
2140 }
2141 Ok((!parts.is_empty()).then_some(Predicate::And(parts)))
2142 }
2143
2144 fn render_into(&self, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
2148 let mut sql = self.render_body(params, dialect)?;
2149 if let Some(u) = &self.union {
2150 let kw = if u.all { "UNION ALL" } else { "UNION" };
2151 let branch = u.query.render_body(params, dialect)?;
2152 sql.push_str(&format!(" {kw} {branch}"));
2153 }
2154 Ok(sql)
2155 }
2156
2157 fn render_body(&self, params: &mut Params, dialect: Dialect) -> Result<String, OrmError> {
2159 let table = ident(&self.table)?;
2160
2161 let distinct = if !self.distinct_on.is_empty() {
2163 if dialect != Dialect::Postgres {
2164 return Err(OrmError::BadExpr("DISTINCT ON is Postgres-only"));
2165 }
2166 let cols = self
2167 .distinct_on
2168 .iter()
2169 .map(|e| render_expr(e, &mut *params, dialect))
2170 .collect::<Result<Vec<_>, _>>()?;
2171 format!("DISTINCT ON ({}) ", cols.join(", "))
2172 } else if self.distinct {
2173 "DISTINCT ".to_string()
2174 } else {
2175 String::new()
2176 };
2177 let select_list = render_select_items(&self.columns, &mut *params, dialect)?;
2178 let mut sql = format!("SELECT {distinct}{select_list} FROM {table}");
2179 if let Some(a) = &self.table_alias {
2180 sql.push_str(&format!(" AS {}", ident(a)?));
2181 }
2182
2183 for j in &self.joins {
2184 let jt = ident(&j.table)?;
2185 let kw = match j.kind {
2186 JoinKind::Inner => "JOIN",
2187 JoinKind::Left => "LEFT JOIN",
2188 };
2189 sql.push_str(&format!(" {kw} {jt}"));
2190 if let Some(a) = &j.alias {
2191 sql.push_str(&format!(" AS {}", ident(a)?));
2192 }
2193 let qual = j.alias.as_deref().unwrap_or(&j.table);
2204 let on = match (&self.scope, j.kind) {
2205 (Some(scope), JoinKind::Left) => match scope.read_pred(&j.table, Some(qual))? {
2206 Some(conf) => Predicate::And(vec![j.on.clone(), conf]),
2207 None => j.on.clone(),
2208 },
2209 _ => j.on.clone(),
2210 };
2211 sql.push_str(&format!(
2212 " ON {}",
2213 render_pred(&on, &mut *params, false, dialect)?
2214 ));
2215 }
2216
2217 if let Some(w) = render_where(
2218 self.scope_where_pred()?,
2219 self.filter.as_ref(),
2220 &mut *params,
2221 dialect,
2222 )? {
2223 sql.push_str(&format!(" WHERE {w}"));
2224 }
2225
2226 if !self.group_by.is_empty() {
2227 let terms: Result<Vec<String>, _> = self
2228 .group_by
2229 .iter()
2230 .map(|e| render_expr(e, &mut *params, dialect))
2231 .collect();
2232 sql.push_str(&format!(" GROUP BY {}", terms?.join(", ")));
2233 }
2234
2235 if let Some(h) = &self.having {
2236 sql.push_str(&format!(
2237 " HAVING {}",
2238 render_pred(h, &mut *params, false, dialect)?
2239 ));
2240 }
2241
2242 if !self.order.is_empty() {
2243 let terms: Result<Vec<String>, _> = self
2244 .order
2245 .iter()
2246 .map(|o| {
2247 let e = render_expr(&o.expr, &mut *params, dialect)?;
2248 let d = match o.dir {
2249 Direction::Asc => "ASC",
2250 Direction::Desc => "DESC",
2251 };
2252 Ok::<String, OrmError>(format!("{e} {d}"))
2253 })
2254 .collect();
2255 sql.push_str(&format!(" ORDER BY {}", terms?.join(", ")));
2256 }
2257
2258 if let Some(n) = self.limit {
2259 sql.push_str(&format!(" LIMIT {n}"));
2260 }
2261 if let Some(n) = self.offset {
2262 sql.push_str(&format!(" OFFSET {n}"));
2263 }
2264
2265 Ok(sql)
2266 }
2267}
2268
2269impl Insert {
2270 pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2272 let table = ident(&self.table)?;
2273 let mut params = Params::default();
2274
2275 let stamp: Option<(String, SqlValue)> = match self.scope.as_ref() {
2282 Some(s) => s.write_target(&self.table)?,
2283 None => None,
2284 };
2285
2286 if let Some((cols, select)) = &self.from_select {
2288 let col_sql = cols
2289 .iter()
2290 .map(|c| ident(c).map(str::to_string))
2291 .collect::<Result<Vec<_>, _>>()?;
2292 if col_sql.is_empty() {
2293 return Err(OrmError::Empty("insert-select has no columns"));
2294 }
2295 let select_sql = select.render_into(&mut params, dialect)?;
2296 let mut sql = format!("INSERT INTO {table} ({}) {select_sql}", col_sql.join(", "));
2297 sql.push_str(&render_conflict(
2298 self.conflict.as_ref(),
2299 table,
2300 stamp.as_ref().map(|(c, v)| (c.as_str(), v)),
2301 &mut params,
2302 dialect,
2303 )?);
2304 sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2305 return Ok((sql, params.0));
2306 }
2307
2308 if self.rows.is_empty() {
2309 return Err(OrmError::Empty("insert has no rows"));
2310 }
2311
2312 let mut columns: Vec<String> = Vec::new();
2315 for a in &self.rows[0].cells {
2316 let c = ident(&a.column)?.to_string();
2317 if !columns.contains(&c) {
2318 columns.push(c);
2319 }
2320 }
2321 if let Some((column, _)) = &stamp {
2326 let c = ident(column)?.to_string();
2327 if !columns.iter().any(|existing| same_col(existing, &c)) {
2328 columns.push(c);
2329 }
2330 }
2331 if columns.is_empty() {
2332 return Err(OrmError::Empty("insert row has no columns"));
2333 }
2334
2335 let mut value_groups: Vec<String> = Vec::new();
2336 for row in &self.rows {
2337 let mut ph: Vec<String> = Vec::with_capacity(columns.len());
2338 for col in &columns {
2339 if let Some((column, value)) = &stamp {
2342 if same_col(column, col) {
2343 ph.push(params.bind(value.clone()));
2344 continue;
2345 }
2346 }
2347 match row.cells.iter().find(|a| same_col(&a.column, col)) {
2348 Some(a) => ph.push(render_expr(&a.value, &mut params, dialect)?),
2349 None => ph.push(params.bind(SqlValue::Null)),
2350 }
2351 }
2352 value_groups.push(format!("({})", ph.join(", ")));
2353 }
2354
2355 let mut sql = format!(
2356 "INSERT INTO {table} ({}) VALUES {}",
2357 columns.join(", "),
2358 value_groups.join(", ")
2359 );
2360
2361 sql.push_str(&render_conflict(
2362 self.conflict.as_ref(),
2363 table,
2364 stamp.as_ref().map(|(c, v)| (c.as_str(), v)),
2365 &mut params,
2366 dialect,
2367 )?);
2368 sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2369 Ok((sql, params.0))
2370 }
2371}
2372
2373fn render_conflict(
2382 conflict: Option<&OnConflict>,
2383 table: &str,
2384 stamp: Option<(&str, &SqlValue)>,
2385 params: &mut Params,
2386 dialect: Dialect,
2387) -> Result<String, OrmError> {
2388 let Some(oc) = conflict else {
2389 return Ok(String::new());
2390 };
2391 let conflict_cols = oc
2392 .conflict_columns
2393 .iter()
2394 .map(|c| ident(c).map(str::to_string))
2395 .collect::<Result<Vec<_>, _>>()?;
2396 let guard = stamp;
2399 let do_nothing = || format!(" ON CONFLICT ({}) DO NOTHING", conflict_cols.join(", "));
2400 if oc.update.is_empty() {
2401 return Ok(do_nothing());
2402 }
2403 if guard.is_some() && matches!(dialect, Dialect::Mysql) {
2404 return Err(OrmError::BadExpr(
2405 "a tenant-scoped upsert (ON CONFLICT DO UPDATE) is unsupported on MySQL \
2406 (ON DUPLICATE KEY UPDATE cannot be bounded to the tenant's rows)",
2407 ));
2408 }
2409 let sets = oc
2412 .update
2413 .iter()
2414 .filter(|a| guard.is_none_or(|(col, _)| !same_col(&a.column, col)))
2415 .map(|a| {
2416 let c = ident(&a.column)?;
2417 Ok::<String, OrmError>(format!("{c} = {}", render_expr(&a.value, params, dialect)?))
2418 })
2419 .collect::<Result<Vec<_>, _>>()?;
2420 if sets.is_empty() {
2421 return Ok(do_nothing());
2422 }
2423 let mut clause = format!(
2424 " ON CONFLICT ({}) DO UPDATE SET {}",
2425 conflict_cols.join(", "),
2426 sets.join(", ")
2427 );
2428 if let Some((col, value)) = guard {
2429 ident(col)?;
2430 let col_expr = Expr::Column(format!("{table}.{col}"));
2438 let pred = if matches!(value, SqlValue::Null) {
2439 Predicate::Null {
2440 expr: col_expr,
2441 negated: false,
2442 }
2443 } else {
2444 Predicate::Cmp {
2445 left: col_expr,
2446 op: CmpOp::Eq,
2447 right: Expr::Value(value.clone()),
2448 }
2449 };
2450 clause.push_str(&format!(
2451 " WHERE {}",
2452 render_pred(&pred, params, false, dialect)?
2453 ));
2454 }
2455 Ok(clause)
2456}
2457
2458impl Update {
2459 pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2462 if self.set.is_empty() {
2463 return Err(OrmError::Empty("update has no assignments"));
2464 }
2465 let empty_filter =
2469 matches!(&self.filter, Predicate::And(v) | Predicate::Or(v) if v.is_empty());
2470 if empty_filter && self.scope.is_none() {
2471 return Err(OrmError::Empty(
2472 "update has an empty filter (unbounded update refused)",
2473 ));
2474 }
2475 let table = ident(&self.table)?;
2476 let mut params = Params::default();
2477
2478 let scope_col: Option<String> = match self.scope.as_ref() {
2486 Some(s) => s.write_target(&self.table)?.map(|(col, _)| col),
2487 None => None,
2488 };
2489 let sets: Result<Vec<String>, _> = self
2491 .set
2492 .iter()
2493 .filter(|a| {
2494 scope_col
2495 .as_deref()
2496 .is_none_or(|col| !same_col(&a.column, col))
2497 })
2498 .map(|a| {
2499 let c = ident(&a.column)?;
2500 Ok::<String, OrmError>(format!(
2501 "{c} = {}",
2502 render_expr(&a.value, &mut params, dialect)?
2503 ))
2504 })
2505 .collect();
2506 let sets = sets?;
2507 if sets.is_empty() {
2508 return Err(OrmError::Empty(
2509 "update has no assignments left after dropping the tenant column",
2510 ));
2511 }
2512 let set_sql = sets.join(", ");
2513
2514 let where_sql = render_where(
2515 single_scope_pred(self.scope.as_ref(), &self.table)?,
2516 Some(&self.filter),
2517 &mut params,
2518 dialect,
2519 )?
2520 .ok_or(OrmError::Empty(
2521 "update has an empty filter (unbounded update refused)",
2522 ))?;
2523
2524 let mut sql = format!("UPDATE {table} SET {set_sql} WHERE {where_sql}");
2525 sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2526 Ok((sql, params.0))
2527 }
2528}
2529
2530impl Delete {
2531 pub fn compile(&self, dialect: Dialect) -> Result<Compiled, OrmError> {
2535 let empty_filter =
2536 matches!(&self.filter, Predicate::And(v) | Predicate::Or(v) if v.is_empty());
2537 if empty_filter && self.scope.is_none() {
2538 return Err(OrmError::Empty(
2539 "delete has an empty filter (unbounded delete refused)",
2540 ));
2541 }
2542 let table = ident(&self.table)?;
2543 let mut params = Params::default();
2544 let where_sql = render_where(
2545 single_scope_pred(self.scope.as_ref(), &self.table)?,
2546 Some(&self.filter),
2547 &mut params,
2548 dialect,
2549 )?
2550 .ok_or(OrmError::Empty(
2551 "delete has an empty filter (unbounded delete refused)",
2552 ))?;
2553 let mut sql = format!("DELETE FROM {table} WHERE {where_sql}");
2554 sql.push_str(&render_returning(&self.returning, &mut params, dialect)?);
2555 Ok((sql, params.0))
2556 }
2557}
2558
2559pub fn compile_promote(scope: &Scope, table: &str, dialect: Dialect) -> Result<Compiled, OrmError> {
2576 if scope.is_target() {
2579 return Err(OrmError::TargetWriteUnsupported("promote"));
2580 }
2581 let (tenant_col, session_col) = match scope.resolve_table(table)? {
2582 ResolvedScope::TenantOrSession { tenant, session } => (tenant, session),
2583 _ => {
2584 return Err(OrmError::BadExpr(
2585 "promote requires a TenantOrSession table (an anonymous-first table)",
2586 ))
2587 }
2588 };
2589 ident(&tenant_col)?;
2590 ident(&session_col)?;
2591 let tenant = scope.value.clone().ok_or(OrmError::TenancyNoPrincipal)?;
2593 let session = scope.session.clone().ok_or(OrmError::TenancyNoPrincipal)?;
2594 let promote = Update {
2595 table: table.to_string(),
2596 set: vec![Assignment {
2598 column: tenant_col.clone(),
2599 value: Expr::val(tenant),
2600 }],
2601 filter: Predicate::And(vec![
2603 Predicate::Cmp {
2604 left: Expr::Column(session_col),
2605 op: CmpOp::Eq,
2606 right: Expr::val(session),
2607 },
2608 Predicate::Null {
2609 expr: Expr::Column(tenant_col),
2610 negated: false,
2611 },
2612 ]),
2613 scope: None,
2616 returning: vec![],
2617 };
2618 promote.compile(dialect)
2619}
2620
2621#[derive(Debug, Clone, PartialEq)]
2625pub struct AttachReference {
2626 pub child: String,
2628 pub parent: String,
2630 pub ref_column: String,
2632 pub ref_value: SqlValue,
2635 pub set: Vec<Assignment>,
2638}
2639
2640pub fn compile_attach_reference(
2658 scope: &Scope,
2659 spec: &AttachReference,
2660 dialect: Dialect,
2661) -> Result<Compiled, OrmError> {
2662 ident(&spec.ref_column)?;
2663 let child_tenant = match scope.resolve_table(&spec.child)? {
2666 ResolvedScope::Column(c) => c,
2667 _ => {
2668 return Err(OrmError::BadExpr(
2669 "attach_reference child must be a plain tenant table",
2670 ))
2671 }
2672 };
2673 let parent_tenant = match scope.resolve_table(&spec.parent)? {
2674 ResolvedScope::Column(c) => c,
2675 _ => {
2676 return Err(OrmError::BadExpr(
2677 "attach_reference parent must be a plain tenant table",
2678 ))
2679 }
2680 };
2681 ident(&child_tenant)?;
2682 ident(&parent_tenant)?;
2683
2684 let is_target = scope.is_target();
2685 let mut columns: Vec<String> = Vec::with_capacity(spec.set.len() + 2);
2686 let mut projection: Vec<SelectItem> = Vec::with_capacity(spec.set.len() + 2);
2687 for a in &spec.set {
2693 if same_col(&a.column, &child_tenant) {
2694 return Err(OrmError::TargetWriteColumnDenied(a.column.clone()));
2695 }
2696 if is_target {
2697 scope.assert_target_settable(&spec.child, &a.column)?;
2698 } else {
2699 ident(&a.column)?;
2700 }
2701 columns.push(a.column.clone());
2702 projection.push(SelectItem {
2703 expr: a.value.clone(),
2704 alias: None,
2705 });
2706 }
2707 columns.push(child_tenant);
2710 projection.push(SelectItem {
2711 expr: Expr::Column(parent_tenant),
2712 alias: None,
2713 });
2714 if is_target {
2717 for (col, val) in scope.public_force_cells(&spec.child)? {
2718 columns.push(col);
2719 projection.push(SelectItem {
2720 expr: Expr::Value(val),
2721 alias: None,
2722 });
2723 }
2724 }
2725 let mut source = Select {
2729 columns: projection,
2730 filter: Some(Predicate::Cmp {
2731 left: Expr::Column(spec.ref_column.clone()),
2732 op: CmpOp::Eq,
2733 right: Expr::Value(spec.ref_value.clone()),
2734 }),
2735 ..Select::from(spec.parent.clone())
2736 };
2737 source.force_scope(scope)?;
2738 let insert = Insert {
2742 table: spec.child.clone(),
2743 rows: vec![],
2744 conflict: None,
2745 scope: None,
2746 returning: vec![],
2747 from_select: Some((columns, Box::new(source))),
2748 };
2749 insert.compile(dialect)
2750}
2751
2752#[cfg(test)]
2753mod rls_scope_value_tests {
2754 use super::*;
2755
2756 fn v(s: &str) -> SqlValue {
2757 SqlValue::Text(s.to_string())
2758 }
2759 fn cell(col: &str, val: SqlValue) -> Assignment {
2760 Assignment {
2761 column: col.into(),
2762 value: Expr::Value(val),
2763 }
2764 }
2765 fn insert(rows: Vec<Vec<Assignment>>) -> Insert {
2766 Insert {
2767 table: "t".into(),
2768 rows: rows.into_iter().map(|cells| RowValues { cells }).collect(),
2769 conflict: None,
2770 scope: None,
2771 returning: vec![],
2772 from_select: None,
2773 }
2774 }
2775
2776 #[test]
2777 fn insert_uniform_scope_value_extracts_only_a_single_declared_tenant() {
2778 assert_eq!(
2780 insert(vec![vec![cell("tenant_id", v("A")), cell("body", v("x"))]])
2781 .uniform_scope_value("tenant_id"),
2782 Some(v("A"))
2783 );
2784 assert_eq!(
2786 insert(vec![
2787 vec![cell("tenant_id", v("A")), cell("body", v("x"))],
2788 vec![cell("tenant_id", v("A")), cell("body", v("y"))],
2789 ])
2790 .uniform_scope_value("tenant_id"),
2791 Some(v("A"))
2792 );
2793 assert_eq!(
2795 insert(vec![
2796 vec![cell("tenant_id", v("A"))],
2797 vec![cell("tenant_id", v("B"))],
2798 ])
2799 .uniform_scope_value("tenant_id"),
2800 None
2801 );
2802 assert_eq!(
2804 insert(vec![vec![cell("body", v("x"))]]).uniform_scope_value("tenant_id"),
2805 None
2806 );
2807 assert_eq!(
2808 insert(vec![vec![cell("tenant_id", v("A")), cell("body", v("x"))]])
2809 .uniform_scope_value("id"),
2810 None
2811 );
2812 let non_lit = insert(vec![vec![Assignment {
2814 column: "tenant_id".into(),
2815 value: Expr::col("other"),
2816 }]]);
2817 assert_eq!(non_lit.uniform_scope_value("tenant_id"), None);
2818 }
2819
2820 #[test]
2821 fn update_pinned_scope_value_extracts_only_a_pinned_single_tenant() {
2822 let upd = |filter: Predicate| Update {
2823 table: "t".into(),
2824 set: vec![cell("body", v("x"))],
2825 filter,
2826 scope: None,
2827 returning: vec![],
2828 };
2829 assert_eq!(
2831 upd(Predicate::Cmp {
2832 left: Expr::col("tenant_id"),
2833 op: CmpOp::Eq,
2834 right: Expr::Value(v("A")),
2835 })
2836 .pinned_scope_value("tenant_id"),
2837 Some(v("A"))
2838 );
2839 assert_eq!(
2841 upd(Predicate::And(vec![
2842 Predicate::Cmp {
2843 left: Expr::col("tenant_id"),
2844 op: CmpOp::Eq,
2845 right: Expr::Value(v("A")),
2846 },
2847 Predicate::Cmp {
2848 left: Expr::col("active"),
2849 op: CmpOp::Eq,
2850 right: Expr::Value(SqlValue::Boolean(true)),
2851 },
2852 ]))
2853 .pinned_scope_value("tenant_id"),
2854 Some(v("A"))
2855 );
2856 assert_eq!(
2858 upd(Predicate::Or(vec![
2859 Predicate::Cmp {
2860 left: Expr::col("tenant_id"),
2861 op: CmpOp::Eq,
2862 right: Expr::Value(v("A")),
2863 },
2864 Predicate::Cmp {
2865 left: Expr::col("tenant_id"),
2866 op: CmpOp::Eq,
2867 right: Expr::Value(v("B")),
2868 },
2869 ]))
2870 .pinned_scope_value("tenant_id"),
2871 None
2872 );
2873 assert_eq!(
2875 upd(Predicate::Cmp {
2876 left: Expr::col("active"),
2877 op: CmpOp::Eq,
2878 right: Expr::Value(SqlValue::Boolean(true)),
2879 })
2880 .pinned_scope_value("tenant_id"),
2881 None
2882 );
2883 }
2884}
2885
2886#[cfg(test)]
2887mod tests {
2888 use super::*;
2889
2890 fn t(s: &str) -> SqlValue {
2891 SqlValue::Text(s.to_string())
2892 }
2893 fn cmp(col: &str, op: CmpOp, v: SqlValue) -> Predicate {
2894 Predicate::Cmp {
2895 left: Expr::Column(col.into()),
2896 op,
2897 right: Expr::Value(v),
2898 }
2899 }
2900 fn item(e: Expr) -> SelectItem {
2901 SelectItem {
2902 expr: e,
2903 alias: None,
2904 }
2905 }
2906
2907 #[test]
2908 fn select_basic_where_order_limit() {
2909 let q = Select {
2910 columns: vec![item(Expr::col("id")), item(Expr::col("state"))],
2911 filter: Some(cmp("project_id", CmpOp::Eq, t("prj_1"))),
2912 order: vec![OrderBy {
2913 expr: Expr::col("created_at"),
2914 dir: Direction::Desc,
2915 }],
2916 limit: Some(10),
2917 ..Select::from("work_order")
2918 };
2919 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2920 assert_eq!(
2921 sql,
2922 "SELECT id, state FROM work_order WHERE project_id = ?1 ORDER BY created_at DESC LIMIT 10"
2923 );
2924 assert_eq!(params, vec![t("prj_1")]);
2925 }
2926
2927 #[test]
2928 fn scope_is_anded_and_bound_first() {
2929 let q = Select {
2930 filter: Some(cmp("kind", CmpOp::Eq, t("supplier"))),
2931 scope: Some(Scope {
2932 column: "tenant_id".into(),
2933 value: Some(t("ten_1")),
2934 session: None,
2935 mode: ScopeMode::Own,
2936 keys: TableKeys::Uniform,
2937 }),
2938 ..Select::from("party")
2939 };
2940 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2941 assert_eq!(
2942 sql,
2943 "SELECT * FROM party WHERE tenant_id = ?1 AND kind = ?2"
2944 );
2945 assert_eq!(params, vec![t("ten_1"), t("supplier")]);
2946 }
2947
2948 #[test]
2949 fn per_table_keys_scope_each_ref_on_its_own_column() {
2950 use std::collections::BTreeMap;
2951 let q = Select {
2954 table_alias: Some("sc".into()),
2955 joins: vec![Join {
2956 kind: JoinKind::Left,
2957 table: "tenant".into(),
2958 alias: Some("t".into()),
2959 on: Predicate::Cmp {
2960 left: Expr::col("sc.tenant_id"),
2961 op: CmpOp::Eq,
2962 right: Expr::col("t.id"),
2963 },
2964 }],
2965 scope: Some(Scope {
2966 column: "tenant_id".into(),
2967 value: Some(t("acme")),
2968 session: None,
2969 mode: ScopeMode::Own,
2970 keys: TableKeys::PerTable(BTreeMap::from([
2971 (
2972 "storefront_config".to_string(),
2973 ResolvedScope::Column("tenant_id".to_string()),
2974 ),
2975 (
2976 "tenant".to_string(),
2977 ResolvedScope::Column("id".to_string()),
2978 ),
2979 ])),
2980 }),
2981 ..Select::from("storefront_config")
2982 };
2983 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
2984 assert!(
2985 sql.contains("sc.tenant_id = ?"),
2986 "base scoped on tenant_id: {sql}"
2987 );
2988 assert!(
2989 sql.contains("t.id = ?"),
2990 "identity table scoped on its own PK: {sql}"
2991 );
2992 assert_eq!(params, vec![t("acme"), t("acme")]);
2993
2994 let mut q2 = Select {
2996 table_alias: Some("sc".into()),
2997 joins: vec![Join {
2998 kind: JoinKind::Left,
2999 table: "countries".into(),
3000 alias: Some("c".into()),
3001 on: Predicate::Cmp {
3002 left: Expr::col("sc.country"),
3003 op: CmpOp::Eq,
3004 right: Expr::col("c.code"),
3005 },
3006 }],
3007 ..Select::from("storefront_config")
3008 };
3009 q2.force_scope(&Scope {
3010 column: "tenant_id".into(),
3011 value: Some(t("acme")),
3012 session: None,
3013 mode: ScopeMode::Own,
3014 keys: TableKeys::PerTable(BTreeMap::from([
3015 (
3016 "storefront_config".to_string(),
3017 ResolvedScope::Column("tenant_id".to_string()),
3018 ),
3019 ("countries".to_string(), ResolvedScope::Unscoped),
3020 ])),
3021 })
3022 .unwrap();
3023 let (sql2, params2) = q2.compile(Dialect::Sqlite).unwrap();
3024 assert!(sql2.contains("sc.tenant_id = ?"), "sql2: {sql2}");
3025 assert_eq!(
3029 params2,
3030 vec![t("acme")],
3031 "unscoped join adds no tenant predicate: {sql2}"
3032 );
3033
3034 let mut q3 = Select::from("secret_table");
3036 q3.force_scope(&Scope {
3037 column: "tenant_id".into(),
3038 value: Some(t("acme")),
3039 session: None,
3040 mode: ScopeMode::Own,
3041 keys: TableKeys::PerTable(BTreeMap::from([(
3042 "orders".to_string(),
3043 ResolvedScope::Column("tenant_id".to_string()),
3044 )])),
3045 })
3046 .unwrap();
3047 assert!(matches!(
3048 q3.compile(Dialect::Sqlite),
3049 Err(OrmError::TenancyUndeclared(tbl)) if tbl == "secret_table"
3050 ));
3051 }
3052
3053 #[test]
3054 fn is_own_lowers_to_a_case_rank_and_orders_own_first() {
3055 let mut q = Select {
3058 columns: vec![item(Expr::col("body"))],
3059 filter: Some(cmp("key_name", CmpOp::Eq, t("k"))),
3060 order: vec![OrderBy {
3061 expr: Expr::IsOwn,
3062 dir: Direction::Desc,
3063 }],
3064 limit: Some(1),
3065 ..Select::from("knowledge_entry")
3066 };
3067 q.force_scope(&Scope {
3068 column: "tenant_id".into(),
3069 value: Some(t("acme")),
3070 session: None,
3071 mode: ScopeMode::OwnOrNull,
3072 keys: TableKeys::Uniform,
3073 })
3074 .unwrap();
3075 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3076 assert_eq!(
3077 sql,
3078 "SELECT body FROM knowledge_entry WHERE (tenant_id = ?1 OR tenant_id IS NULL) \
3079 AND key_name = ?2 ORDER BY (CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?3 \
3080 THEN ?4 ELSE ?5 END) DESC LIMIT 1"
3081 );
3082 assert_eq!(
3084 params,
3085 vec![
3086 t("acme"),
3087 t("k"),
3088 t("acme"),
3089 SqlValue::Integer(1),
3090 SqlValue::Integer(0)
3091 ]
3092 );
3093 }
3094
3095 #[test]
3096 fn is_own_in_select_under_all_uses_the_resolved_own_value() {
3097 let mut q = Select {
3099 columns: vec![item(Expr::IsOwn)],
3100 ..Select::from("t")
3101 };
3102 q.force_scope(&Scope {
3103 column: "tenant_id".into(),
3104 value: Some(t("acme")),
3105 session: None,
3106 mode: ScopeMode::All,
3107 keys: TableKeys::Uniform,
3108 })
3109 .unwrap();
3110 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3111 assert_eq!(
3112 sql,
3113 "SELECT (CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?1 THEN ?2 ELSE ?3 END) FROM t"
3114 );
3115 assert_eq!(
3116 params,
3117 vec![t("acme"), SqlValue::Integer(1), SqlValue::Integer(0)]
3118 );
3119 }
3120
3121 #[test]
3122 fn is_own_without_a_scope_is_rejected() {
3123 let q = Select {
3126 order: vec![OrderBy {
3127 expr: Expr::IsOwn,
3128 dir: Direction::Desc,
3129 }],
3130 ..Select::from("t")
3131 };
3132 let err = q.compile(Dialect::Sqlite).unwrap_err();
3133 assert!(
3134 matches!(err, OrmError::BadExpr(m) if m.contains("is_own")),
3135 "expected a fail-closed is_own error, got {err:?}"
3136 );
3137 }
3138
3139 #[test]
3140 fn is_own_in_a_filter_does_not_subtract_the_scope_predicate() {
3141 let mut q = Select {
3144 filter: Some(Predicate::Cmp {
3145 left: Expr::IsOwn,
3146 op: CmpOp::Eq,
3147 right: Expr::Value(SqlValue::Integer(1)),
3148 }),
3149 ..Select::from("notes")
3150 };
3151 q.force_scope(&Scope {
3152 column: "tenant_id".into(),
3153 value: Some(t("acme")),
3154 session: None,
3155 mode: ScopeMode::OwnOrNull,
3156 keys: TableKeys::Uniform,
3157 })
3158 .unwrap();
3159 let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
3160 assert!(
3162 sql.contains("(tenant_id = ?1 OR tenant_id IS NULL) AND"),
3163 "scope predicate must survive the is_own filter: {sql}"
3164 );
3165 assert!(
3166 sql.contains("CASE WHEN tenant_id IS NOT NULL AND tenant_id = ?2 THEN"),
3167 "is_own lowered to the own-rank CASE: {sql}"
3168 );
3169 }
3170
3171 fn scoped_select(mode: ScopeMode) -> Select {
3172 Select {
3173 filter: Some(cmp("kind", CmpOp::Eq, t("supplier"))),
3174 scope: Some(Scope {
3175 column: "tenant_id".into(),
3176 value: Some(t("ten_1")),
3177 session: None,
3178 mode,
3179 keys: TableKeys::Uniform,
3180 }),
3181 ..Select::from("party")
3182 }
3183 }
3184
3185 #[test]
3186 fn scope_mode_own_or_null_admits_the_shared_baseline() {
3187 let (sql, params) = scoped_select(ScopeMode::OwnOrNull)
3188 .compile(Dialect::Sqlite)
3189 .unwrap();
3190 assert_eq!(
3191 sql,
3192 "SELECT * FROM party WHERE (tenant_id = ?1 OR tenant_id IS NULL) AND kind = ?2"
3193 );
3194 assert_eq!(params, vec![t("ten_1"), t("supplier")]);
3195 }
3196
3197 #[test]
3198 fn scope_mode_null_only_sees_only_the_baseline() {
3199 let (sql, params) = scoped_select(ScopeMode::NullOnly)
3200 .compile(Dialect::Sqlite)
3201 .unwrap();
3202 assert_eq!(
3204 sql,
3205 "SELECT * FROM party WHERE tenant_id IS NULL AND kind = ?1"
3206 );
3207 assert_eq!(params, vec![t("supplier")]);
3208 }
3209
3210 #[test]
3211 fn scope_mode_all_injects_no_tenant_predicate() {
3212 let (sql, params) = scoped_select(ScopeMode::All)
3213 .compile(Dialect::Sqlite)
3214 .unwrap();
3215 assert_eq!(sql, "SELECT * FROM party WHERE kind = ?1");
3217 assert_eq!(params, vec![t("supplier")]);
3218 }
3219
3220 #[test]
3221 fn force_scope_reaches_every_union_branch() {
3222 let branch = Select::from("archived_party");
3225 let mut q = Select {
3226 union: Some(Box::new(Union {
3227 all: false,
3228 query: branch,
3229 })),
3230 ..Select::from("party")
3231 };
3232 q.force_scope(&Scope {
3233 column: "tenant_id".into(),
3234 value: Some(t("ten_1")),
3235 session: None,
3236 mode: ScopeMode::Own,
3237 keys: TableKeys::Uniform,
3238 })
3239 .unwrap();
3240 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3241 assert_eq!(
3242 sql,
3243 "SELECT * FROM party WHERE tenant_id = ?1 UNION SELECT * FROM archived_party WHERE tenant_id = ?2"
3244 );
3245 assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3246 }
3247
3248 #[test]
3249 fn scoped_select_scopes_every_joined_table() {
3250 let mut q = Select {
3256 table: "orders".into(),
3257 table_alias: Some("o".into()),
3258 columns: vec![item(Expr::col("v.secret"))],
3259 joins: vec![Join {
3260 kind: JoinKind::Left,
3261 table: "victim".into(),
3262 alias: Some("v".into()),
3263 on: Predicate::Cmp {
3264 left: Expr::col("v.order_id"),
3265 op: CmpOp::Eq,
3266 right: Expr::col("o.id"),
3267 },
3268 }],
3269 ..Select::from("orders")
3270 };
3271 q.force_scope(&Scope {
3272 column: "tenant_id".into(),
3273 value: Some(t("ten_1")),
3274 session: None,
3275 mode: ScopeMode::Own,
3276 keys: TableKeys::Uniform,
3277 })
3278 .unwrap();
3279 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3280 assert_eq!(
3281 sql,
3282 "SELECT v.secret FROM orders AS o LEFT JOIN victim AS v ON v.order_id = o.id \
3283 AND v.tenant_id = ?1 WHERE o.tenant_id = ?2"
3284 );
3285 assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3286 }
3287
3288 #[test]
3289 fn scoped_returning_and_distinct_on_subqueries_are_scoped() {
3290 let sub = || Expr::RelatedScalar {
3291 column: "balance".into(),
3292 table: "victim".into(),
3293 filter: Box::new(Predicate::And(Vec::new())),
3294 };
3295 let scope = Scope {
3296 column: "tenant_id".into(),
3297 value: Some(t("ten_1")),
3298 session: None,
3299 mode: ScopeMode::Own,
3300 keys: TableKeys::Uniform,
3301 };
3302 let mut del = Delete {
3304 table: "orders".into(),
3305 filter: cmp("id", CmpOp::Eq, t("o_1")),
3306 scope: None,
3307 returning: vec![item(sub())],
3308 };
3309 del.force_scope(&scope).unwrap();
3310 let (sql, _) = del.compile(Dialect::Sqlite).unwrap();
3311 assert!(
3312 sql.contains("RETURNING (SELECT balance FROM victim WHERE victim.tenant_id = ?"),
3313 "RETURNING subquery unscoped: {sql}"
3314 );
3315 let mut sel = Select {
3317 columns: vec![item(Expr::col("id"))],
3318 distinct_on: vec![sub()],
3319 ..Select::from("orders")
3320 };
3321 sel.force_scope(&scope).unwrap();
3322 let (sql, _) = sel.compile(Dialect::Postgres).unwrap();
3324 assert!(
3325 sql.contains("DISTINCT ON ((SELECT balance FROM victim WHERE victim.tenant_id = ?"),
3326 "DISTINCT ON subquery unscoped: {sql}"
3327 );
3328 }
3329
3330 #[test]
3331 fn scoped_select_scopes_a_subquerys_inner_table() {
3332 let mut q = Select {
3335 columns: vec![item(Expr::RelatedScalar {
3336 column: "balance".into(),
3337 table: "victim".into(),
3338 filter: Box::new(Predicate::And(Vec::new())), })],
3340 ..Select::from("orders")
3341 };
3342 q.force_scope(&Scope {
3343 column: "tenant_id".into(),
3344 value: Some(t("ten_1")),
3345 session: None,
3346 mode: ScopeMode::Own,
3347 keys: TableKeys::Uniform,
3348 })
3349 .unwrap();
3350 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3351 assert_eq!(
3353 sql,
3354 "SELECT (SELECT balance FROM victim WHERE victim.tenant_id = ?1) \
3355 FROM orders WHERE tenant_id = ?2"
3356 );
3357 assert_eq!(params, vec![t("ten_1"), t("ten_1")]);
3358 }
3359
3360 #[test]
3361 fn insert_select_cannot_forge_the_target_tenant() {
3362 let source = Select {
3365 columns: vec![
3366 item(Expr::val(t("VICTIM"))), item(Expr::col("total")),
3368 ],
3369 ..Select::from("orders")
3370 };
3371 let mut ins = Insert {
3372 table: "orders".into(),
3373 rows: vec![],
3374 conflict: None,
3375 scope: None,
3376 returning: vec![],
3377 from_select: Some((vec!["TENANT_ID".into(), "total".into()], Box::new(source))),
3379 };
3380 let own = Scope {
3381 column: "tenant_id".into(),
3382 value: Some(t("OWN")),
3383 session: None,
3384 mode: ScopeMode::Own,
3385 keys: TableKeys::Uniform,
3386 };
3387 ins.force_scope(Some(&own), Some(&own)).unwrap();
3388 let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
3389 assert_eq!(
3391 sql,
3392 "INSERT INTO orders (total, tenant_id) SELECT total, ?1 FROM orders WHERE tenant_id = ?2"
3393 );
3394 assert_eq!(params, vec![t("OWN"), t("OWN")]);
3395 assert!(
3396 !params.contains(&t("VICTIM")),
3397 "the forged tenant never binds"
3398 );
3399 }
3400
3401 #[test]
3402 fn scoped_update_cannot_reassign_the_tenant() {
3403 let q = Update {
3406 table: "orders".into(),
3407 set: vec![
3408 Assignment {
3409 column: "TENANT_ID".into(),
3410 value: Expr::val(t("VICTIM")),
3411 },
3412 Assignment {
3413 column: "status".into(),
3414 value: Expr::val(t("paid")),
3415 },
3416 ],
3417 filter: cmp("id", CmpOp::Eq, t("o_1")),
3418 scope: Some(Scope {
3419 column: "tenant_id".into(),
3420 value: Some(t("OWN")),
3421 session: None,
3422 mode: ScopeMode::Own,
3423 keys: TableKeys::Uniform,
3424 }),
3425 returning: vec![],
3426 };
3427 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3428 assert_eq!(
3429 sql,
3430 "UPDATE orders SET status = ?1 WHERE tenant_id = ?2 AND id = ?3"
3431 );
3432 assert_eq!(params, vec![t("paid"), t("OWN"), t("o_1")]);
3433 assert!(!params.contains(&t("VICTIM")));
3434 }
3435
3436 #[test]
3437 fn scoped_upsert_drops_tenant_reassignment_and_bounds_the_do_update() {
3438 let mut ins = Insert {
3442 table: "orders".into(),
3443 rows: vec![RowValues {
3444 cells: vec![Assignment {
3445 column: "id".into(),
3446 value: Expr::val(t("k")),
3447 }],
3448 }],
3449 conflict: Some(OnConflict {
3450 conflict_columns: vec!["id".into()],
3451 update: vec![
3452 Assignment {
3454 column: "TENANT_ID".into(),
3455 value: Expr::val(t("VICTIM")),
3456 },
3457 Assignment {
3458 column: "total".into(),
3459 value: Expr::val(SqlValue::Integer(999)),
3460 },
3461 ],
3462 }),
3463 scope: None,
3464 returning: vec![],
3465 from_select: None,
3466 };
3467 let own = Scope {
3468 column: "tenant_id".into(),
3469 value: Some(t("OWN")),
3470 session: None,
3471 mode: ScopeMode::Own,
3472 keys: TableKeys::Uniform,
3473 };
3474 ins.force_scope(Some(&own), Some(&own)).unwrap();
3475 let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
3476 assert_eq!(
3477 sql,
3478 "INSERT INTO orders (id, tenant_id) VALUES (?1, ?2) \
3479 ON CONFLICT (id) DO UPDATE SET total = ?3 WHERE orders.tenant_id = ?4"
3480 );
3481 assert_eq!(
3483 params,
3484 vec![t("k"), t("OWN"), SqlValue::Integer(999), t("OWN")]
3485 );
3486 assert!(!params.contains(&t("VICTIM")));
3487 assert!(matches!(
3489 ins.compile(Dialect::Mysql),
3490 Err(OrmError::BadExpr(_))
3491 ));
3492 }
3493
3494 #[test]
3495 fn upsert_do_update_guard_is_target_table_qualified() {
3496 let build = |mode, value: Option<SqlValue>| {
3502 let mut ins = Insert {
3503 table: "module_config".into(),
3504 rows: vec![RowValues {
3505 cells: vec![Assignment {
3506 column: "module".into(),
3507 value: Expr::val(t("m")),
3508 }],
3509 }],
3510 conflict: Some(OnConflict {
3514 conflict_columns: vec!["module".into()],
3515 update: vec![Assignment {
3516 column: "enabled".into(),
3517 value: Expr::col("excluded.enabled"),
3518 }],
3519 }),
3520 scope: None,
3521 returning: vec![],
3522 from_select: None,
3523 };
3524 let s = Scope {
3525 column: "tenant_id".into(),
3526 value,
3527 session: None,
3528 mode,
3529 keys: TableKeys::Uniform,
3530 };
3531 ins.force_scope(Some(&s), Some(&s)).unwrap();
3532 ins
3533 };
3534 for d in [Dialect::Postgres, Dialect::Sqlite] {
3536 let (sql, _) = build(ScopeMode::Own, Some(t("OWN"))).compile(d).unwrap();
3537 assert!(
3538 sql.ends_with(
3539 "ON CONFLICT (module) DO UPDATE SET enabled = excluded.enabled \
3540 WHERE module_config.tenant_id = ?3"
3541 ),
3542 "{d:?}: {sql}"
3543 );
3544 }
3545 let (sql, _) = build(ScopeMode::NullOnly, None)
3547 .compile(Dialect::Postgres)
3548 .unwrap();
3549 assert!(
3550 sql.ends_with(
3551 "ON CONFLICT (module) DO UPDATE SET enabled = excluded.enabled \
3552 WHERE module_config.tenant_id IS NULL"
3553 ),
3554 "{sql}"
3555 );
3556 }
3557
3558 #[test]
3559 fn insert_null_mode_stamps_null_all_mode_stamps_nothing() {
3560 let base = |mode| Insert {
3561 table: "audit_event".into(),
3562 rows: vec![RowValues {
3563 cells: vec![Assignment {
3564 column: "detail".into(),
3565 value: Expr::val(t("x")),
3566 }],
3567 }],
3568 conflict: None,
3569 scope: Some(Scope {
3570 column: "tenant_id".into(),
3571 value: Some(t("ten_1")),
3572 session: None,
3573 mode,
3574 keys: TableKeys::Uniform,
3575 }),
3576 returning: vec![],
3577 from_select: None,
3578 };
3579 let (sql, params) = base(ScopeMode::NullOnly).compile(Dialect::Sqlite).unwrap();
3581 assert_eq!(
3582 sql,
3583 "INSERT INTO audit_event (detail, tenant_id) VALUES (?1, ?2)"
3584 );
3585 assert_eq!(params, vec![t("x"), SqlValue::Null]);
3586 let (sql, params) = base(ScopeMode::All).compile(Dialect::Sqlite).unwrap();
3588 assert_eq!(sql, "INSERT INTO audit_event (detail) VALUES (?1)");
3589 assert_eq!(params, vec![t("x")]);
3590 }
3591
3592 #[test]
3593 fn nested_and_or_not_is_parenthesized() {
3594 let q = Select {
3596 filter: Some(all([
3597 Predicate::In {
3598 expr: Expr::col("state"),
3599 values: vec![Expr::val(t("po_linked")), Expr::val(t("awarded"))],
3600 negated: false,
3601 },
3602 any([
3603 cmp("priority", CmpOp::Ge, SqlValue::Integer(3)),
3604 cmp("escalated", CmpOp::Eq, SqlValue::Boolean(true)),
3605 ]),
3606 Predicate::Not(Box::new(cmp(
3607 "archived",
3608 CmpOp::Eq,
3609 SqlValue::Boolean(true),
3610 ))),
3611 ])),
3612 scope: Some(Scope {
3613 column: "tenant_id".into(),
3614 value: Some(t("ten_1")),
3615 session: None,
3616 mode: ScopeMode::Own,
3617 keys: TableKeys::Uniform,
3618 }),
3619 ..Select::from("order_to_network")
3620 };
3621 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3622 assert_eq!(
3623 sql,
3624 "SELECT * FROM order_to_network WHERE tenant_id = ?1 AND (state IN (?2, ?3) AND (priority >= ?4 OR escalated = ?5) AND NOT archived = ?6)"
3625 );
3626 assert_eq!(
3627 params,
3628 vec![
3629 t("ten_1"),
3630 t("po_linked"),
3631 t("awarded"),
3632 SqlValue::Integer(3),
3633 SqlValue::Boolean(true),
3634 SqlValue::Boolean(true)
3635 ]
3636 );
3637 }
3638
3639 #[test]
3640 fn group_by_having_with_aggregate_and_alias() {
3641 let q = Select {
3642 columns: vec![
3643 item(Expr::col("network_id")),
3644 SelectItem {
3645 expr: Expr::Aggregate(Agg::Sum, Box::new(Expr::col("committed_minor"))),
3646 alias: Some("total".into()),
3647 },
3648 ],
3649 group_by: vec![Expr::col("network_id")],
3650 having: Some(Predicate::Cmp {
3651 left: Expr::Aggregate(Agg::Sum, Box::new(Expr::col("committed_minor"))),
3652 op: CmpOp::Gt,
3653 right: Expr::val(SqlValue::Integer(1000)),
3654 }),
3655 order: vec![OrderBy {
3656 expr: Expr::col("total"),
3657 dir: Direction::Desc,
3658 }],
3659 ..Select::from("order_to_network")
3660 };
3661 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3662 assert_eq!(
3663 sql,
3664 "SELECT network_id, sum(committed_minor) AS total FROM order_to_network GROUP BY network_id HAVING sum(committed_minor) > ?1 ORDER BY total DESC"
3665 );
3666 assert_eq!(params, vec![SqlValue::Integer(1000)]);
3667 }
3668
3669 #[test]
3670 fn join_with_alias_and_column_ref_condition() {
3671 let q = Select {
3672 columns: vec![item(Expr::Aggregate(Agg::Count, Box::new(Expr::Star)))],
3673 joins: vec![Join {
3674 kind: JoinKind::Inner,
3675 table: "element".into(),
3676 alias: Some("e".into()),
3677 on: Predicate::Cmp {
3678 left: Expr::col("order_to_network.element_id"),
3679 op: CmpOp::Eq,
3680 right: Expr::col("e.id"),
3681 },
3682 }],
3683 filter: Some(cmp("order_id", CmpOp::Eq, SqlValue::Integer(7))),
3684 ..Select::from("order_to_network")
3685 };
3686 let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
3687 assert_eq!(
3688 sql,
3689 "SELECT count(*) FROM order_to_network JOIN element AS e ON order_to_network.element_id = e.id WHERE order_id = ?1"
3690 );
3691 }
3692
3693 #[test]
3694 fn between_like_insensitive_and_notin() {
3695 let q = Select {
3696 filter: Some(all([
3697 Predicate::Between {
3698 expr: Expr::col("amount"),
3699 low: Expr::val(SqlValue::Integer(10)),
3700 high: Expr::val(SqlValue::Integer(20)),
3701 negated: false,
3702 },
3703 Predicate::Like {
3704 expr: Expr::col("name"),
3705 pattern: "ac%".into(),
3706 insensitive: true,
3707 negated: false,
3708 },
3709 Predicate::In {
3710 expr: Expr::col("state"),
3711 values: vec![Expr::val(t("void"))],
3712 negated: true,
3713 },
3714 ])),
3715 ..Select::from("invoice")
3716 };
3717 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3718 assert_eq!(
3719 sql,
3720 "SELECT * FROM invoice WHERE amount BETWEEN ?1 AND ?2 AND lower(name) LIKE lower(?3) AND state NOT IN (?4)"
3721 );
3722 assert_eq!(
3723 params,
3724 vec![
3725 SqlValue::Integer(10),
3726 SqlValue::Integer(20),
3727 t("ac%"),
3728 t("void")
3729 ]
3730 );
3731 }
3732
3733 #[test]
3734 fn arithmetic_and_functions_in_select_and_set() {
3735 let q = Select {
3736 columns: vec![
3737 SelectItem {
3738 expr: Expr::Func(Func::Lower, vec![Expr::col("email")]),
3739 alias: Some("email_lc".into()),
3740 },
3741 item(Expr::Binary(
3742 BinOp::Mul,
3743 Box::new(Expr::col("qty")),
3744 Box::new(Expr::val(SqlValue::Integer(2))),
3745 )),
3746 item(Expr::Func(
3747 Func::Coalesce,
3748 vec![Expr::col("nickname"), Expr::val(t("n/a"))],
3749 )),
3750 ],
3751 ..Select::from("account")
3752 };
3753 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3754 assert_eq!(
3755 sql,
3756 "SELECT lower(email) AS email_lc, (qty * ?1), coalesce(nickname, ?2) FROM account"
3757 );
3758 assert_eq!(params, vec![SqlValue::Integer(2), t("n/a")]);
3759 }
3760
3761 #[test]
3762 fn empty_in_and_not_in_are_identities() {
3763 let matches_none = Select {
3764 filter: Some(Predicate::In {
3765 expr: Expr::col("x"),
3766 values: vec![],
3767 negated: false,
3768 }),
3769 ..Select::from("t")
3770 };
3771 assert_eq!(
3772 matches_none.compile(Dialect::Sqlite).unwrap().0,
3773 "SELECT * FROM t WHERE 1 = 0"
3774 );
3775 let matches_all = Select {
3776 filter: Some(Predicate::In {
3777 expr: Expr::col("x"),
3778 values: vec![],
3779 negated: true,
3780 }),
3781 ..Select::from("t")
3782 };
3783 assert_eq!(
3784 matches_all.compile(Dialect::Sqlite).unwrap().0,
3785 "SELECT * FROM t WHERE 1 = 1"
3786 );
3787 }
3788
3789 #[test]
3790 fn insert_with_scope_and_returning() {
3791 let q = Insert {
3792 table: "work_area".into(),
3793 rows: vec![RowValues {
3794 cells: vec![
3795 Assignment {
3796 column: "id".into(),
3797 value: Expr::val(t("wa_1")),
3798 },
3799 Assignment {
3800 column: "project_id".into(),
3801 value: Expr::val(t("prj_1")),
3802 },
3803 ],
3804 }],
3805 conflict: None,
3806 scope: Some(Scope {
3807 column: "tenant_id".into(),
3808 value: Some(t("ten_1")),
3809 session: None,
3810 mode: ScopeMode::Own,
3811 keys: TableKeys::Uniform,
3812 }),
3813 returning: vec![item(Expr::col("id"))],
3814 from_select: None,
3815 };
3816 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3817 assert_eq!(
3818 sql,
3819 "INSERT INTO work_area (id, project_id, tenant_id) VALUES (?1, ?2, ?3) RETURNING id"
3820 );
3821 assert_eq!(params, vec![t("wa_1"), t("prj_1"), t("ten_1")]);
3822 }
3823
3824 #[test]
3825 fn upsert_do_update_and_do_nothing() {
3826 let base = |update: Vec<Assignment>| Insert {
3827 table: "country_pack".into(),
3828 rows: vec![RowValues {
3829 cells: vec![
3830 Assignment {
3831 column: "country".into(),
3832 value: Expr::val(t("US")),
3833 },
3834 Assignment {
3835 column: "currency".into(),
3836 value: Expr::val(t("USD")),
3837 },
3838 ],
3839 }],
3840 conflict: Some(OnConflict {
3841 conflict_columns: vec!["tenant_id".into(), "country".into()],
3842 update,
3843 }),
3844 scope: None,
3845 returning: vec![],
3846 from_select: None,
3847 };
3848 let (sql_do, _) = base(vec![Assignment {
3849 column: "currency".into(),
3850 value: Expr::val(t("USD")),
3851 }])
3852 .compile(Dialect::Sqlite)
3853 .unwrap();
3854 assert_eq!(
3855 sql_do,
3856 "INSERT INTO country_pack (country, currency) VALUES (?1, ?2) ON CONFLICT (tenant_id, country) DO UPDATE SET currency = ?3"
3857 );
3858 let (sql_nothing, _) = base(vec![]).compile(Dialect::Sqlite).unwrap();
3859 assert_eq!(
3860 sql_nothing,
3861 "INSERT INTO country_pack (country, currency) VALUES (?1, ?2) ON CONFLICT (tenant_id, country) DO NOTHING"
3862 );
3863 }
3864
3865 #[test]
3866 fn update_binds_set_before_where_and_supports_expr_set() {
3867 let q = Update {
3868 table: "counter".into(),
3869 set: vec![Assignment {
3870 column: "hits".into(),
3871 value: Expr::Binary(
3872 BinOp::Add,
3873 Box::new(Expr::col("hits")),
3874 Box::new(Expr::val(SqlValue::Integer(1))),
3875 ),
3876 }],
3877 filter: cmp("id", CmpOp::Eq, t("c_1")),
3878 scope: Some(Scope {
3879 column: "tenant_id".into(),
3880 value: Some(t("ten_1")),
3881 session: None,
3882 mode: ScopeMode::Own,
3883 keys: TableKeys::Uniform,
3884 }),
3885 returning: vec![],
3886 };
3887 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3888 assert_eq!(
3889 sql,
3890 "UPDATE counter SET hits = (hits + ?1) WHERE tenant_id = ?2 AND id = ?3"
3891 );
3892 assert_eq!(params, vec![SqlValue::Integer(1), t("ten_1"), t("c_1")]);
3893 }
3894
3895 #[test]
3896 fn identifier_injection_is_rejected() {
3897 let q = Select {
3898 columns: vec![item(Expr::col("id; DROP TABLE users"))],
3899 ..Select::from("t")
3900 };
3901 assert!(matches!(
3902 q.compile(Dialect::Sqlite),
3903 Err(OrmError::InvalidIdentifier(_))
3904 ));
3905 }
3906
3907 #[test]
3908 fn qualified_identifier_allowed() {
3909 let q = Select {
3910 columns: vec![item(Expr::col("t.id"))],
3911 ..Select::from("t")
3912 };
3913 assert_eq!(q.compile(Dialect::Sqlite).unwrap().0, "SELECT t.id FROM t");
3914 }
3915
3916 #[test]
3917 fn function_arity_is_checked() {
3918 let q = Select {
3919 columns: vec![item(Expr::Func(Func::Lower, vec![]))],
3920 ..Select::from("t")
3921 };
3922 assert!(matches!(
3923 q.compile(Dialect::Sqlite),
3924 Err(OrmError::BadExpr(_))
3925 ));
3926 }
3927
3928 #[test]
3929 fn update_with_empty_all_filter_is_refused() {
3930 let q = Update {
3931 table: "t".into(),
3932 set: vec![Assignment {
3933 column: "x".into(),
3934 value: Expr::val(SqlValue::Integer(1)),
3935 }],
3936 filter: Predicate::And(vec![]),
3937 scope: None,
3938 returning: vec![],
3939 };
3940 assert!(matches!(
3942 q.compile(Dialect::Sqlite),
3943 Err(OrmError::Empty(_))
3944 ));
3945 }
3946
3947 #[test]
3948 fn empty_filter_with_scope_is_allowed() {
3949 let q = Update {
3951 table: "t".into(),
3952 set: vec![Assignment {
3953 column: "x".into(),
3954 value: Expr::val(SqlValue::Integer(1)),
3955 }],
3956 filter: Predicate::And(vec![]),
3957 scope: Some(Scope {
3958 column: "tenant_id".into(),
3959 value: Some(t("ten_1")),
3960 session: None,
3961 mode: ScopeMode::Own,
3962 keys: TableKeys::Uniform,
3963 }),
3964 returning: vec![],
3965 };
3966 assert_eq!(
3967 q.compile(Dialect::Sqlite).unwrap().0,
3968 "UPDATE t SET x = ?1 WHERE tenant_id = ?2"
3969 );
3970 }
3971
3972 #[test]
3973 fn delete_by_predicate_compiles() {
3974 let q = Delete {
3975 table: "payment".into(),
3976 filter: cmp("id", CmpOp::Eq, t("pay_1")),
3977 scope: None,
3978 returning: vec![],
3979 };
3980 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
3981 assert_eq!(sql, "DELETE FROM payment WHERE id = ?1");
3982 assert_eq!(params, vec![t("pay_1")]);
3983 }
3984
3985 #[test]
3986 fn delete_returning_renders() {
3987 let q = Delete {
3990 table: "pending_signup".into(),
3991 filter: cmp("slug", CmpOp::Eq, t("acme")),
3992 scope: None,
3993 returning: vec![item(Expr::col("name")), item(Expr::col("password_hash"))],
3994 };
3995 assert_eq!(
3996 q.compile(Dialect::Postgres).unwrap().0,
3997 "DELETE FROM pending_signup WHERE slug = ?1 RETURNING name, password_hash"
3998 );
3999 }
4000
4001 #[test]
4002 fn delete_with_empty_filter_is_refused() {
4003 let q = Delete {
4005 table: "t".into(),
4006 filter: Predicate::And(vec![]),
4007 scope: None,
4008 returning: vec![],
4009 };
4010 assert!(matches!(
4011 q.compile(Dialect::Sqlite),
4012 Err(OrmError::Empty(_))
4013 ));
4014 }
4015
4016 #[test]
4017 fn delete_empty_filter_with_scope_is_allowed() {
4018 let q = Delete {
4020 table: "t".into(),
4021 filter: Predicate::And(vec![]),
4022 scope: Some(Scope {
4023 column: "tenant_id".into(),
4024 value: Some(t("ten_1")),
4025 session: None,
4026 mode: ScopeMode::Own,
4027 keys: TableKeys::Uniform,
4028 }),
4029 returning: vec![],
4030 };
4031 assert_eq!(
4032 q.compile(Dialect::Sqlite).unwrap().0,
4033 "DELETE FROM t WHERE tenant_id = ?1"
4034 );
4035 }
4036
4037 #[test]
4038 fn delete_rejects_identifier_injection_in_table() {
4039 let q = Delete {
4040 table: "t; DROP TABLE users".into(),
4041 filter: cmp("id", CmpOp::Eq, t("x")),
4042 scope: None,
4043 returning: vec![],
4044 };
4045 assert!(matches!(
4046 q.compile(Dialect::Sqlite),
4047 Err(OrmError::InvalidIdentifier(_))
4048 ));
4049 }
4050
4051 #[test]
4052 fn case_expression_renders_with_bound_params() {
4053 let q = Select {
4055 columns: vec![item(Expr::Case {
4056 branches: vec![(
4057 cmp("state", CmpOp::Eq, t("open")),
4058 Expr::val(SqlValue::Integer(1)),
4059 )],
4060 otherwise: Some(Box::new(Expr::val(SqlValue::Integer(0)))),
4061 })],
4062 ..Select::from("t")
4063 };
4064 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4065 assert_eq!(
4066 sql,
4067 "SELECT (CASE WHEN state = ?1 THEN ?2 ELSE ?3 END) FROM t"
4068 );
4069 assert_eq!(
4070 params,
4071 vec![t("open"), SqlValue::Integer(1), SqlValue::Integer(0)]
4072 );
4073 }
4074
4075 #[test]
4076 fn distinct_on_renders_on_postgres_and_fails_closed_elsewhere() {
4077 let q = Select {
4078 distinct_on: vec![Expr::col("key")],
4079 columns: vec![item(Expr::col("key")), item(Expr::col("val"))],
4080 ..Select::from("consent_state")
4081 };
4082 assert_eq!(
4083 q.compile(Dialect::Postgres).unwrap().0,
4084 "SELECT DISTINCT ON (key) key, val FROM consent_state"
4085 );
4086 assert!(matches!(
4088 q.compile(Dialect::Sqlite),
4089 Err(OrmError::BadExpr(_))
4090 ));
4091 }
4092
4093 #[test]
4094 fn empty_case_is_rejected() {
4095 let q = Select {
4096 columns: vec![item(Expr::Case {
4097 branches: vec![],
4098 otherwise: None,
4099 })],
4100 ..Select::from("t")
4101 };
4102 assert!(matches!(
4103 q.compile(Dialect::Sqlite),
4104 Err(OrmError::BadExpr(_))
4105 ));
4106 }
4107
4108 #[test]
4109 fn json_extract_dyn_binds_the_key() {
4110 let q = Select {
4112 columns: vec![item(Expr::JsonExtractDyn(
4113 Box::new(Expr::col("labels")),
4114 Box::new(Expr::val(t("en"))),
4115 ))],
4116 ..Select::from("vocabulary_term")
4117 };
4118 for d in [Dialect::Postgres, Dialect::Sqlite] {
4119 assert_eq!(
4120 q.compile(d).unwrap().0,
4121 "SELECT (labels ->> ?1) FROM vocabulary_term"
4122 );
4123 }
4124 assert!(matches!(
4125 q.compile(Dialect::Mysql),
4126 Err(OrmError::BadExpr(_))
4127 ));
4128 }
4129
4130 #[test]
4131 fn json_concat_merge_is_postgres_only() {
4132 let q = Update {
4134 table: "request".into(),
4135 set: vec![Assignment {
4136 column: "brief_state".into(),
4137 value: Expr::JsonConcat(
4138 Box::new(Expr::col("brief_state")),
4139 Box::new(Expr::val(SqlValue::Json("{\"a\":1}".into()))),
4140 ),
4141 }],
4142 filter: cmp("id", CmpOp::Eq, t("req_1")),
4143 scope: None,
4144 returning: vec![],
4145 };
4146 assert_eq!(
4147 q.compile(Dialect::Postgres).unwrap().0,
4148 "UPDATE request SET brief_state = (brief_state || ?1) WHERE id = ?2"
4149 );
4150 assert!(matches!(
4151 q.compile(Dialect::Sqlite),
4152 Err(OrmError::BadExpr(_))
4153 ));
4154 }
4155
4156 #[test]
4157 fn union_renders_both_bodies_with_shared_params() {
4158 let q = Select {
4160 columns: vec![item(Expr::col("slug"))],
4161 filter: Some(cmp("slug", CmpOp::Eq, t("acme"))),
4162 union: Some(Box::new(Union {
4163 all: false,
4164 query: Select {
4165 columns: vec![item(Expr::col("slug"))],
4166 filter: Some(cmp("slug", CmpOp::Eq, t("acme"))),
4167 ..Select::from("reserved_slug")
4168 },
4169 })),
4170 ..Select::from("pending_signup")
4171 };
4172 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4173 assert_eq!(
4174 sql,
4175 "SELECT slug FROM pending_signup WHERE slug = ?1 \
4176 UNION SELECT slug FROM reserved_slug WHERE slug = ?2"
4177 );
4178 assert_eq!(params, vec![t("acme"), t("acme")]);
4179 }
4180
4181 #[test]
4182 fn insert_from_select_shares_params_and_carries_no_auto_scope() {
4183 let q = Insert {
4185 table: "portfolio_ref".into(),
4186 rows: vec![],
4187 conflict: None,
4188 scope: None,
4189 returning: vec![],
4190 from_select: Some((
4191 vec!["a".into(), "b".into()],
4192 Box::new(Select {
4193 columns: vec![item(Expr::col("x")), item(Expr::col("y"))],
4194 filter: Some(cmp("id", CmpOp::Eq, t("pi_1"))),
4195 ..Select::from("portfolio_item")
4196 }),
4197 )),
4198 };
4199 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
4200 assert_eq!(
4201 sql,
4202 "INSERT INTO portfolio_ref (a, b) SELECT x, y FROM portfolio_item WHERE id = ?1"
4203 );
4204 assert_eq!(params, vec![t("pi_1")]);
4205 }
4206
4207 #[test]
4208 fn related_scalar_and_in_subquery_render() {
4209 let q = Select {
4211 columns: vec![item(Expr::col("id"))],
4212 filter: Some(Predicate::Cmp {
4213 left: Expr::col("id"),
4214 op: CmpOp::Eq,
4215 right: Expr::RelatedScalar {
4216 column: "head_version".into(),
4217 table: "pack".into(),
4218 filter: Box::new(cmp("id", CmpOp::Eq, t("pk_1"))),
4219 },
4220 }),
4221 ..Select::from("pack_version")
4222 };
4223 assert_eq!(
4224 q.compile(Dialect::Sqlite).unwrap().0,
4225 "SELECT id FROM pack_version WHERE id = (SELECT head_version FROM pack WHERE id = ?1)"
4226 );
4227
4228 let q2 = Select {
4230 columns: vec![item(Expr::col("x"))],
4231 filter: Some(Predicate::InSubquery {
4232 expr: Expr::col("doc_id"),
4233 column: "id".into(),
4234 table: "document".into(),
4235 filter: Box::new(cmp("tenant_id", CmpOp::Eq, t("ten_1"))),
4236 negated: false,
4237 }),
4238 ..Select::from("access")
4239 };
4240 assert_eq!(
4241 q2.compile(Dialect::Sqlite).unwrap().0,
4242 "SELECT x FROM access WHERE doc_id IN (SELECT id FROM document WHERE tenant_id = ?1)"
4243 );
4244 }
4245
4246 #[test]
4247 fn now_renders_without_parens() {
4248 let q = Select {
4249 columns: vec![item(Expr::Func(Func::Now, vec![]))],
4250 ..Select::from("t")
4251 };
4252 assert_eq!(
4253 q.compile(Dialect::Sqlite).unwrap().0,
4254 "SELECT current_timestamp FROM t"
4255 );
4256 }
4257
4258 fn json_query() -> Select {
4259 Select {
4260 columns: vec![item(Expr::JsonExtract(
4261 Box::new(Expr::col("metadata")),
4262 vec!["status".into()],
4263 ))],
4264 filter: Some(Predicate::Cmp {
4265 left: Expr::JsonExtract(
4266 Box::new(Expr::col("metadata")),
4267 vec!["a".into(), "b".into()],
4268 ),
4269 op: CmpOp::Eq,
4270 right: Expr::val(t("x")),
4271 }),
4272 ..Select::from("doc")
4273 }
4274 }
4275
4276 #[test]
4277 fn json_extract_sqlite_and_mysql_bind_the_path() {
4278 for d in [Dialect::Sqlite, Dialect::Mysql] {
4279 let (sql, params) = json_query().compile(d).unwrap();
4280 assert_eq!(
4281 sql,
4282 "SELECT json_extract(metadata, ?1) FROM doc WHERE json_extract(metadata, ?2) = ?3"
4283 );
4284 assert_eq!(params, vec![t("$.status"), t("$.a.b"), t("x")]);
4285 }
4286 }
4287
4288 #[test]
4289 fn json_extract_postgres_inlines_the_validated_path() {
4290 let (sql, params) = json_query().compile(Dialect::Postgres).unwrap();
4291 assert_eq!(
4292 sql,
4293 "SELECT (metadata) #>> '{status}' FROM doc WHERE (metadata) #>> '{a,b}' = ?1"
4294 );
4295 assert_eq!(params, vec![t("x")]);
4296 }
4297
4298 #[test]
4299 fn json_extract_key_injection_is_rejected() {
4300 let q = Select {
4301 columns: vec![item(Expr::JsonExtract(
4302 Box::new(Expr::col("m")),
4303 vec!["a'); DROP TABLE t--".into()],
4304 ))],
4305 ..Select::from("doc")
4306 };
4307 assert!(matches!(
4308 q.compile(Dialect::Postgres),
4309 Err(OrmError::InvalidIdentifier(_))
4310 ));
4311 }
4312
4313 fn knn_query() -> Select {
4316 Select {
4318 columns: vec![item(Expr::col("id"))],
4319 order: vec![OrderBy {
4320 expr: Expr::Distance {
4321 left: Box::new(Expr::col("embedding")),
4322 right: Box::new(Expr::VectorLiteral("[0.1, 0.2, 0.3]".into())),
4323 metric: Metric::Cosine,
4324 },
4325 dir: Direction::Asc,
4326 }],
4327 limit: Some(5),
4328 ..Select::from("doc")
4329 }
4330 }
4331
4332 #[test]
4333 fn distance_orders_by_cosine_nearest_neighbour_on_postgres() {
4334 let (sql, params) = knn_query().compile(Dialect::Postgres).unwrap();
4335 assert_eq!(
4336 sql,
4337 "SELECT id FROM doc ORDER BY (embedding <=> ?1::vector) ASC LIMIT 5"
4338 );
4339 assert_eq!(params, vec![t("[0.1,0.2,0.3]")]);
4341 }
4342
4343 #[test]
4344 fn distance_l2_in_select_list_on_postgres() {
4345 let q = Select {
4346 columns: vec![
4347 item(Expr::col("id")),
4348 SelectItem {
4349 expr: Expr::Distance {
4350 left: Box::new(Expr::col("embedding")),
4351 right: Box::new(Expr::VectorLiteral("[-1, 2e0, 3.5]".into())),
4352 metric: Metric::L2,
4353 },
4354 alias: Some("dist".into()),
4355 },
4356 ],
4357 ..Select::from("doc")
4358 };
4359 let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4360 assert_eq!(
4361 sql,
4362 "SELECT id, (embedding <-> ?1::vector) AS dist FROM doc"
4363 );
4364 assert_eq!(params, vec![t("[-1,2e0,3.5]")]);
4365 }
4366
4367 #[test]
4368 fn distance_fails_closed_off_postgres() {
4369 for d in [Dialect::Sqlite, Dialect::Mysql] {
4370 assert!(
4371 matches!(knn_query().compile(d), Err(OrmError::BadExpr(_))),
4372 "vector distance must be rejected on {d:?}"
4373 );
4374 }
4375 }
4376
4377 #[test]
4378 fn vector_literal_fails_closed_off_postgres() {
4379 for d in [Dialect::Sqlite, Dialect::Mysql] {
4380 let q = Select {
4381 columns: vec![item(Expr::VectorLiteral("[1, 2]".into()))],
4382 ..Select::from("doc")
4383 };
4384 assert!(
4385 matches!(q.compile(d), Err(OrmError::BadExpr(_))),
4386 "vector literal must be rejected on {d:?}"
4387 );
4388 }
4389 }
4390
4391 #[test]
4392 fn malformed_vector_literal_is_rejected() {
4393 for bad in [
4396 "1,2",
4397 "[a, b]",
4398 "[]",
4399 "[1, 2",
4400 "[1,,2]",
4401 "[Infinity]",
4402 "[1, NaN]",
4403 ] {
4404 let q = Select {
4405 columns: vec![item(Expr::VectorLiteral(bad.to_string()))],
4406 ..Select::from("doc")
4407 };
4408 assert!(
4409 matches!(q.compile(Dialect::Postgres), Err(OrmError::BadExpr(_))),
4410 "expected {bad:?} to be rejected"
4411 );
4412 }
4413 }
4414
4415 fn related(agg: Agg, arg: RelArg, table: &str, filter: Predicate) -> Expr {
4420 Expr::RelatedAggregate {
4421 agg,
4422 arg,
4423 table: table.into(),
4424 filter: Box::new(filter),
4425 }
4426 }
4427 fn correlate(fk: &str, pk: &str) -> Predicate {
4428 Predicate::Cmp {
4429 left: Expr::col(fk),
4430 op: CmpOp::Eq,
4431 right: Expr::col(pk),
4432 }
4433 }
4434
4435 #[test]
4436 fn related_aggregate_single_correlated_count() {
4437 let q = Select {
4439 columns: vec![
4440 item(Expr::col("id")),
4441 SelectItem {
4442 expr: related(
4443 Agg::Count,
4444 RelArg::Star,
4445 "element",
4446 correlate("element.order_id", "work_order.id"),
4447 ),
4448 alias: Some("element_count".into()),
4449 },
4450 ],
4451 ..Select::from("work_order")
4452 };
4453 let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4454 assert_eq!(
4455 sql,
4456 "SELECT id, (SELECT count(*) FROM element WHERE element.order_id = work_order.id) AS element_count FROM work_order"
4457 );
4458 assert!(params.is_empty());
4459 }
4460
4461 #[test]
4462 fn related_aggregate_two_counts_bind_distinct_params_and_dont_fan_out() {
4463 let with_status = |child: &str, fk: &str, status: &str| {
4466 related(
4467 Agg::Count,
4468 RelArg::Star,
4469 child,
4470 Predicate::And(vec![
4471 correlate(fk, "party.id"),
4472 Predicate::Cmp {
4473 left: Expr::col("status"),
4474 op: CmpOp::Eq,
4475 right: Expr::val(t(status)),
4476 },
4477 ]),
4478 )
4479 };
4480 let q = Select {
4481 columns: vec![
4482 item(with_status("party_role", "party_role.party_id", "active")),
4483 item(with_status(
4484 "party_qualification",
4485 "party_qualification.party_id",
4486 "valid",
4487 )),
4488 ],
4489 ..Select::from("party")
4490 };
4491 let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4492 assert_eq!(
4493 sql,
4494 "SELECT \
4495 (SELECT count(*) FROM party_role WHERE party_role.party_id = party.id AND status = ?1), \
4496 (SELECT count(*) FROM party_qualification WHERE party_qualification.party_id = party.id AND status = ?2) \
4497 FROM party"
4498 );
4499 assert_eq!(params, vec![t("active"), t("valid")]);
4500 }
4501
4502 #[test]
4503 fn related_aggregate_with_temporal_or_filter() {
4504 let q = Select {
4506 columns: vec![SelectItem {
4507 expr: related(
4508 Agg::Count,
4509 RelArg::Star,
4510 "party_qualification",
4511 Predicate::And(vec![
4512 correlate("party_qualification.party_id", "party.id"),
4513 Predicate::Or(vec![
4514 Predicate::Null {
4515 expr: Expr::col("valid_to"),
4516 negated: false,
4517 },
4518 Predicate::Cmp {
4519 left: Expr::col("valid_to"),
4520 op: CmpOp::Gt,
4521 right: Expr::val(t("2026-01-01")),
4522 },
4523 ]),
4524 ]),
4525 ),
4526 alias: Some("active_quals".into()),
4527 }],
4528 ..Select::from("party")
4529 };
4530 let (sql, params) = q.compile(Dialect::Postgres).unwrap();
4531 assert_eq!(
4532 sql,
4533 "SELECT (SELECT count(*) FROM party_qualification WHERE party_qualification.party_id = party.id AND (valid_to IS NULL OR valid_to > ?1)) AS active_quals FROM party"
4534 );
4535 assert_eq!(params, vec![t("2026-01-01")]);
4536 }
4537
4538 #[test]
4539 fn related_aggregate_max_over_a_column_is_portable() {
4540 let q = Select {
4543 columns: vec![SelectItem {
4544 expr: related(
4545 Agg::Max,
4546 RelArg::Column("total_minor".into()),
4547 "line_item",
4548 correlate("line_item.order_id", "order_summary.id"),
4549 ),
4550 alias: Some("max_total".into()),
4551 }],
4552 ..Select::from("order_summary")
4553 };
4554 let (sql, _) = q.compile(Dialect::Sqlite).unwrap();
4555 assert_eq!(
4556 sql,
4557 "SELECT (SELECT max(total_minor) FROM line_item WHERE line_item.order_id = order_summary.id) AS max_total FROM order_summary"
4558 );
4559 }
4560
4561 #[test]
4562 fn related_aggregate_star_is_count_only() {
4563 let q = Select {
4564 columns: vec![item(related(
4565 Agg::Sum,
4566 RelArg::Star,
4567 "t",
4568 correlate("t.fk", "p.id"),
4569 ))],
4570 ..Select::from("p")
4571 };
4572 assert!(matches!(
4573 q.compile(Dialect::Postgres),
4574 Err(OrmError::BadExpr(_))
4575 ));
4576 }
4577
4578 #[test]
4579 fn related_aggregate_table_injection_is_rejected() {
4580 let q = Select {
4581 columns: vec![item(related(
4582 Agg::Count,
4583 RelArg::Star,
4584 "element; DROP TABLE users",
4585 correlate("element.order_id", "p.id"),
4586 ))],
4587 ..Select::from("p")
4588 };
4589 assert!(matches!(
4590 q.compile(Dialect::Postgres),
4591 Err(OrmError::InvalidIdentifier(_))
4592 ));
4593 }
4594
4595 #[test]
4596 fn target_read_conjoins_the_public_subset_and_composes_across_joins() {
4597 use std::collections::BTreeMap;
4598 let keys = BTreeMap::from([
4602 (
4603 "products".to_string(),
4604 ResolvedScope::Column("tenant_id".to_string()),
4605 ),
4606 (
4607 "reviews".to_string(),
4608 ResolvedScope::Column("tenant_id".to_string()),
4609 ),
4610 ]);
4611 let public = BTreeMap::from([
4612 (
4613 "products".to_string(),
4614 vec![
4615 PublicTermSql::Cmp {
4616 column: "published".into(),
4617 op: CmpOp::Eq,
4618 value: SqlValue::Boolean(true),
4619 },
4620 PublicTermSql::Null {
4621 column: "deleted_at".into(),
4622 negated: false,
4623 },
4624 ],
4625 ),
4626 (
4627 "reviews".to_string(),
4628 vec![PublicTermSql::Cmp {
4629 column: "visible".into(),
4630 op: CmpOp::Eq,
4631 value: SqlValue::Boolean(true),
4632 }],
4633 ),
4634 ]);
4635 let mut q = Select {
4636 table_alias: Some("p".into()),
4637 joins: vec![Join {
4638 kind: JoinKind::Left,
4639 table: "reviews".into(),
4640 alias: Some("r".into()),
4641 on: Predicate::Cmp {
4642 left: Expr::col("p.id"),
4643 op: CmpOp::Eq,
4644 right: Expr::col("r.product_id"),
4645 },
4646 }],
4647 ..Select::from("products")
4648 };
4649 q.force_scope(&Scope {
4650 column: "tenant_id".into(),
4651 value: Some(t("B")),
4652 session: None,
4653 mode: ScopeMode::Own,
4654 keys: TableKeys::PerTableTarget {
4655 keys: keys.clone(),
4656 public: public.clone(),
4657 write: std::collections::BTreeSet::new(),
4658 require_public: true,
4659 },
4660 })
4661 .unwrap();
4662 let (sql, _params) = q.compile(Dialect::Sqlite).unwrap();
4663 assert!(sql.contains("p.tenant_id = ?"), "base tenant scope: {sql}");
4665 assert!(sql.contains("p.published = ?"), "base public term: {sql}");
4666 assert!(
4667 sql.contains("p.deleted_at IS NULL"),
4668 "base public null term: {sql}"
4669 );
4670 assert!(
4672 sql.contains("r.tenant_id = ?"),
4673 "joined tenant scope: {sql}"
4674 );
4675 assert!(sql.contains("r.visible = ?"), "joined public term: {sql}");
4676 }
4677
4678 #[test]
4679 fn target_read_of_a_table_with_no_public_subset_is_refused() {
4680 use std::collections::BTreeMap;
4681 let keys = BTreeMap::from([(
4685 "secret_table".to_string(),
4686 ResolvedScope::Column("tenant_id".to_string()),
4687 )]);
4688 let deny = Scope {
4689 column: "tenant_id".into(),
4690 value: Some(t("B")),
4691 session: None,
4692 mode: ScopeMode::Own,
4693 keys: TableKeys::PerTableTarget {
4694 keys,
4695 public: BTreeMap::new(), write: std::collections::BTreeSet::new(),
4697 require_public: true,
4698 },
4699 };
4700 let mut q = Select::from("secret_table");
4701 let err = q
4703 .force_scope(&deny)
4704 .err()
4705 .or_else(|| q.compile(Dialect::Sqlite).err());
4706 assert!(
4707 matches!(&err, Some(OrmError::PublicSubsetUndeclared(t)) if t == "secret_table"),
4708 "expected PublicSubsetUndeclared, got {err:?}"
4709 );
4710 }
4711
4712 #[test]
4713 fn own_read_is_unaffected_by_the_public_injection() {
4714 use std::collections::BTreeMap;
4717 let mut q = Select::from("products");
4718 q.force_scope(&Scope {
4719 column: "tenant_id".into(),
4720 value: Some(t("A")),
4721 session: None,
4722 mode: ScopeMode::Own,
4723 keys: TableKeys::PerTable(BTreeMap::from([(
4724 "products".to_string(),
4725 ResolvedScope::Column("tenant_id".to_string()),
4726 )])),
4727 })
4728 .unwrap();
4729 let (sql, _p) = q.compile(Dialect::Sqlite).unwrap();
4730 assert!(sql.contains("tenant_id = ?"));
4731 assert!(
4732 !sql.contains("published") && !sql.contains("IS NULL"),
4733 "own read must carry no public confinement: {sql}"
4734 );
4735 }
4736
4737 #[test]
4738 fn tenant_or_base_folds_the_null_base_on_reads_but_not_writes() {
4739 use std::collections::BTreeMap;
4744 let scope = Scope {
4745 column: "tenant_id".into(),
4746 value: Some(t("A")),
4747 session: None,
4748 mode: ScopeMode::Own,
4749 keys: TableKeys::PerTable(BTreeMap::from([
4750 (
4751 "pack".to_string(),
4752 ResolvedScope::TenantOrBase {
4753 tenant: "tenant_id".into(),
4754 },
4755 ),
4756 (
4757 "product".to_string(),
4758 ResolvedScope::Column("tenant_id".into()),
4759 ),
4760 ])),
4761 };
4762 let mut pack = Select::from("pack");
4764 pack.force_scope(&scope).unwrap();
4765 let (psql, _p) = pack.compile(Dialect::Sqlite).unwrap();
4766 assert!(
4767 psql.contains("tenant_id = ?")
4768 && psql.contains("tenant_id IS NULL")
4769 && psql.contains(" OR "),
4770 "base-inclusive read folds the NULL base: {psql}"
4771 );
4772 let mut prod = Select::from("product");
4774 prod.force_scope(&scope).unwrap();
4775 let (dsql, _p) = prod.compile(Dialect::Sqlite).unwrap();
4776 assert!(
4777 dsql.contains("tenant_id = ?") && !dsql.contains("IS NULL"),
4778 "a plain tenant table alongside a base-inclusive one stays tenant-only: {dsql}"
4779 );
4780 let mut ins = Insert {
4782 table: "pack".into(),
4783 rows: vec![RowValues {
4784 cells: vec![Assignment {
4785 column: "name".into(),
4786 value: Expr::val(t("p")),
4787 }],
4788 }],
4789 conflict: None,
4790 scope: None,
4791 returning: vec![],
4792 from_select: None,
4793 };
4794 ins.force_scope(Some(&scope), Some(&scope)).unwrap();
4795 let (isql, iparams) = ins.compile(Dialect::Sqlite).unwrap();
4796 assert!(
4797 isql.contains("tenant_id"),
4798 "write stamps the tenant column: {isql}"
4799 );
4800 assert!(
4801 iparams.contains(&t("A")) && !iparams.contains(&SqlValue::Null),
4802 "write stamps tenant = A, never a NULL base row: {iparams:?}"
4803 );
4804 }
4805
4806 #[test]
4807 fn target_read_capability_drops_a_declared_subset_confining_tenant_only() {
4808 use std::collections::BTreeMap;
4814 let keys = BTreeMap::from([
4815 (
4816 "products".to_string(),
4817 ResolvedScope::Column("tenant_id".to_string()),
4818 ),
4819 (
4820 "reviews".to_string(),
4821 ResolvedScope::Column("tenant_id".to_string()),
4822 ),
4823 ]);
4824 let public = BTreeMap::from([
4825 (
4826 "products".to_string(),
4827 vec![
4828 PublicTermSql::Cmp {
4829 column: "published".into(),
4830 op: CmpOp::Eq,
4831 value: SqlValue::Boolean(true),
4832 },
4833 PublicTermSql::Null {
4834 column: "deleted_at".into(),
4835 negated: false,
4836 },
4837 ],
4838 ),
4839 (
4840 "reviews".to_string(),
4841 vec![PublicTermSql::Cmp {
4842 column: "visible".into(),
4843 op: CmpOp::Eq,
4844 value: SqlValue::Boolean(true),
4845 }],
4846 ),
4847 ]);
4848 let cap = Scope {
4849 column: "tenant_id".into(),
4850 value: Some(t("B")),
4851 session: None,
4852 mode: ScopeMode::Own,
4853 keys: TableKeys::PerTableTarget {
4854 keys,
4855 public,
4856 write: std::collections::BTreeSet::new(),
4857 require_public: false, },
4859 };
4860 let mut q = Select {
4861 table_alias: Some("p".into()),
4862 joins: vec![Join {
4863 kind: JoinKind::Inner,
4864 table: "reviews".into(),
4865 alias: Some("r".into()),
4866 on: Predicate::Cmp {
4867 left: Expr::col("r.product_id"),
4868 op: CmpOp::Eq,
4869 right: Expr::col("p.id"),
4870 },
4871 }],
4872 ..Select::from("products")
4873 };
4874 q.force_scope(&cap).unwrap();
4875 let (sql, _p) = q.compile(Dialect::Sqlite).unwrap();
4876 assert!(
4877 sql.contains("p.tenant_id = ?"),
4878 "base tenant scope kept: {sql}"
4879 );
4880 assert!(
4881 sql.contains("r.tenant_id = ?"),
4882 "joined tenant scope kept: {sql}"
4883 );
4884 assert!(
4885 !sql.contains("published") && !sql.contains("deleted_at") && !sql.contains("visible"),
4886 "no declared subset term may appear on the capability axis: {sql}"
4887 );
4888 }
4889
4890 fn target_write_scope(write: &[&str]) -> Scope {
4895 use std::collections::{BTreeMap, BTreeSet};
4896 Scope {
4897 column: "tenant_id".into(),
4898 value: Some(t("tenant_B")),
4899 session: None,
4900 mode: ScopeMode::Own,
4901 keys: TableKeys::PerTableTarget {
4902 keys: BTreeMap::from([(
4903 "products".to_string(),
4904 ResolvedScope::Column("tenant_id".to_string()),
4905 )]),
4906 public: BTreeMap::from([(
4907 "products".to_string(),
4908 vec![
4909 PublicTermSql::Cmp {
4910 column: "published".into(),
4911 op: CmpOp::Eq,
4912 value: SqlValue::Boolean(true),
4913 },
4914 PublicTermSql::Null {
4915 column: "deleted_at".into(),
4916 negated: false,
4917 },
4918 ],
4919 )]),
4920 write: write
4921 .iter()
4922 .map(ToString::to_string)
4923 .collect::<BTreeSet<_>>(),
4924 require_public: true,
4925 },
4926 }
4927 }
4928
4929 fn target_write_scope_cap(write: &[&str]) -> Scope {
4933 use std::collections::{BTreeMap, BTreeSet};
4934 Scope {
4935 column: "tenant_id".into(),
4936 value: Some(t("tenant_B")),
4937 session: None,
4938 mode: ScopeMode::Own,
4939 keys: TableKeys::PerTableTarget {
4940 keys: BTreeMap::from([(
4941 "client_survey".to_string(),
4942 ResolvedScope::Column("tenant_id".to_string()),
4943 )]),
4944 public: BTreeMap::new(), write: write
4946 .iter()
4947 .map(ToString::to_string)
4948 .collect::<BTreeSet<_>>(),
4949 require_public: false,
4950 },
4951 }
4952 }
4953
4954 fn target_insert(cells: Vec<Assignment>) -> Insert {
4955 Insert {
4956 table: "products".into(),
4957 rows: vec![RowValues { cells }],
4958 conflict: None,
4959 scope: None,
4960 returning: vec![],
4961 from_select: None,
4962 }
4963 }
4964
4965 #[test]
4966 fn target_insert_forces_tenant_and_public_and_accepts_only_allowlisted_columns() {
4967 let scope = target_write_scope(&["title"]);
4970 let mut ins = target_insert(vec![Assignment {
4971 column: "title".into(),
4972 value: Expr::val(t("Hello")),
4973 }]);
4974 ins.force_scope(Some(&scope), Some(&scope)).unwrap();
4975 let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
4976 assert!(sql.contains("tenant_id"), "{sql}");
4977 assert!(sql.contains("published"), "{sql}");
4978 assert!(sql.contains("deleted_at"), "{sql}");
4979 assert!(
4980 params.contains(&t("tenant_B")),
4981 "tenant forced to B: {params:?}"
4982 );
4983 assert!(
4984 params.contains(&SqlValue::Boolean(true)),
4985 "published forced true: {params:?}"
4986 );
4987 assert!(
4988 params.contains(&SqlValue::Null),
4989 "deleted_at forced NULL: {params:?}"
4990 );
4991 assert!(params.contains(&t("Hello")), "guest title kept: {params:?}");
4992 }
4993
4994 #[test]
4995 fn target_insert_refuses_a_non_allowlisted_column() {
4996 let scope = target_write_scope(&["title"]);
4998 let mut ins = target_insert(vec![
4999 Assignment {
5000 column: "title".into(),
5001 value: Expr::val(t("x")),
5002 },
5003 Assignment {
5004 column: "price".into(),
5005 value: Expr::val(SqlValue::Integer(9)),
5006 },
5007 ]);
5008 let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5009 assert!(
5010 matches!(err, OrmError::TargetWriteColumnDenied(ref c) if c == "price"),
5011 "{err:?}"
5012 );
5013 }
5014
5015 #[test]
5016 fn target_insert_refuses_setting_the_tenant_or_visibility_column() {
5017 for bad in ["tenant_id", "published", "deleted_at"] {
5020 let scope = target_write_scope(&["title", bad]); let mut ins = target_insert(vec![Assignment {
5022 column: bad.into(),
5023 value: Expr::val(t("x")),
5024 }]);
5025 let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5026 assert!(
5027 matches!(err, OrmError::TargetWriteColumnDenied(ref c) if c == bad),
5028 "{bad}: {err:?}"
5029 );
5030 }
5031 }
5032
5033 #[test]
5034 fn target_insert_with_no_write_grant_is_refused() {
5035 let scope = target_write_scope(&[]); let mut ins = target_insert(vec![Assignment {
5037 column: "title".into(),
5038 value: Expr::val(t("x")),
5039 }]);
5040 let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5041 assert!(
5042 matches!(err, OrmError::TargetWriteNotGranted(ref t) if t == "products"),
5043 "{err:?}"
5044 );
5045 }
5046
5047 #[test]
5048 fn target_write_to_a_tenant_or_session_table_is_refused() {
5049 use std::collections::{BTreeMap, BTreeSet};
5050 let scope = Scope {
5056 column: "tenant_id".into(),
5057 value: Some(t("tenant_B")),
5058 session: None,
5059 mode: ScopeMode::Own,
5060 keys: TableKeys::PerTableTarget {
5061 keys: BTreeMap::from([(
5062 "state_scope".to_string(),
5063 ResolvedScope::TenantOrSession {
5064 tenant: "tenant_id".to_string(),
5065 session: "session_id".to_string(),
5066 },
5067 )]),
5068 public: BTreeMap::new(),
5069 write: BTreeSet::from(["note".to_string()]),
5070 require_public: true,
5071 },
5072 };
5073 let mut ins = Insert {
5075 table: "state_scope".into(),
5076 rows: vec![RowValues {
5077 cells: vec![Assignment {
5078 column: "note".into(),
5079 value: Expr::val(t("x")),
5080 }],
5081 }],
5082 conflict: None,
5083 scope: None,
5084 returning: vec![],
5085 from_select: None,
5086 };
5087 let err = ins.force_scope(Some(&scope), Some(&scope)).unwrap_err();
5088 assert!(
5089 matches!(err, OrmError::TargetWriteToSessionTable(ref x) if x == "state_scope"),
5090 "INSERT should refuse a target write to a TenantOrSession table: {err:?}"
5091 );
5092 let mut upd = Update {
5094 table: "state_scope".into(),
5095 set: vec![Assignment {
5096 column: "note".into(),
5097 value: Expr::val(t("y")),
5098 }],
5099 filter: cmp("id", CmpOp::Eq, t("s1")),
5100 scope: None,
5101 returning: vec![],
5102 };
5103 let err = upd.force_scope(&scope).unwrap_err();
5104 assert!(
5105 matches!(err, OrmError::TargetWriteToSessionTable(ref x) if x == "state_scope"),
5106 "UPDATE should refuse a target write to a TenantOrSession table: {err:?}"
5107 );
5108 }
5109
5110 #[test]
5111 fn target_insert_select_and_upsert_are_refused() {
5112 let scope = target_write_scope(&["title"]);
5113 let mut ins = target_insert(vec![Assignment {
5114 column: "title".into(),
5115 value: Expr::val(t("x")),
5116 }]);
5117 ins.from_select = Some((vec!["title".into()], Box::new(Select::from("products"))));
5118 assert!(matches!(
5119 ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5120 OrmError::TargetWriteUnsupported("INSERT … SELECT")
5121 ));
5122 let mut ins2 = target_insert(vec![Assignment {
5125 column: "title".into(),
5126 value: Expr::val(t("x")),
5127 }]);
5128 ins2.conflict = Some(OnConflict {
5129 conflict_columns: vec!["tenant_id".into(), "id".into()],
5130 update: vec![Assignment {
5131 column: "title".into(),
5132 value: Expr::col("excluded.title"),
5133 }],
5134 });
5135 assert!(matches!(
5136 ins2.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5137 OrmError::TargetWriteUnsupported(
5138 "ON CONFLICT upsert on an anonymous domain/handle/target_or_null target"
5139 )
5140 ));
5141 }
5142
5143 #[test]
5144 fn target_upsert_capability_confines_do_update_and_guards_tenant_b() {
5145 let scope = target_write_scope_cap(&["score", "comment", "project_id", "client_id"]);
5151 let mut ins = Insert {
5152 table: "client_survey".into(),
5153 rows: vec![RowValues {
5154 cells: vec![
5155 Assignment {
5156 column: "project_id".into(),
5157 value: Expr::val(t("p1")),
5158 },
5159 Assignment {
5160 column: "client_id".into(),
5161 value: Expr::val(t("cli_a")),
5162 },
5163 Assignment {
5164 column: "score".into(),
5165 value: Expr::val(SqlValue::Integer(5)),
5166 },
5167 ],
5168 }],
5169 conflict: Some(OnConflict {
5170 conflict_columns: vec!["tenant_id".into(), "project_id".into(), "client_id".into()],
5171 update: vec![Assignment {
5172 column: "score".into(),
5173 value: Expr::col("excluded.score"),
5174 }],
5175 }),
5176 scope: None,
5177 returning: vec![],
5178 from_select: None,
5179 };
5180 ins.force_scope(Some(&scope), Some(&scope)).unwrap();
5181 let (sql, params) = ins.compile(Dialect::Sqlite).unwrap();
5182 assert!(
5183 sql.contains("ON CONFLICT (tenant_id, project_id, client_id) DO UPDATE SET"),
5184 "upsert compiled: {sql}"
5185 );
5186 assert!(
5187 sql.contains("score = excluded.score"),
5188 "SET allowlisted col: {sql}"
5189 );
5190 assert!(
5191 sql.contains("client_survey.tenant_id ="),
5192 "DO UPDATE guarded to tenant = B (qualified): {sql}"
5193 );
5194 assert!(
5195 params.iter().filter(|p| **p == t("tenant_B")).count() >= 2,
5196 "tenant B stamped on INSERT AND bound in the DO-UPDATE guard: {params:?}"
5197 );
5198 }
5199
5200 #[test]
5201 fn target_upsert_capability_requires_tenant_in_conflict_key() {
5202 let scope = target_write_scope_cap(&["score", "project_id", "client_id"]);
5205 let mut ins = Insert {
5206 table: "client_survey".into(),
5207 rows: vec![RowValues {
5208 cells: vec![Assignment {
5209 column: "score".into(),
5210 value: Expr::val(SqlValue::Integer(5)),
5211 }],
5212 }],
5213 conflict: Some(OnConflict {
5214 conflict_columns: vec!["project_id".into(), "client_id".into()], update: vec![Assignment {
5216 column: "score".into(),
5217 value: Expr::col("excluded.score"),
5218 }],
5219 }),
5220 scope: None,
5221 returning: vec![],
5222 from_select: None,
5223 };
5224 assert!(matches!(
5225 ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5226 OrmError::TargetUpsertKeyMissingTenant(t) if t == "client_survey"
5227 ));
5228 let mut qual = Insert {
5231 table: "client_survey".into(),
5232 rows: vec![RowValues {
5233 cells: vec![Assignment {
5234 column: "score".into(),
5235 value: Expr::val(SqlValue::Integer(5)),
5236 }],
5237 }],
5238 conflict: Some(OnConflict {
5239 conflict_columns: vec!["cs.tenant_id".into(), "project_id".into()],
5240 update: vec![Assignment {
5241 column: "score".into(),
5242 value: Expr::col("excluded.score"),
5243 }],
5244 }),
5245 scope: None,
5246 returning: vec![],
5247 from_select: None,
5248 };
5249 assert!(matches!(
5250 qual.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5251 OrmError::TargetWriteColumnDenied(c) if c == "cs.tenant_id"
5252 ));
5253 }
5254
5255 #[test]
5256 fn target_upsert_capability_refuses_a_non_allowlisted_or_tenant_do_update_set() {
5257 let scope = target_write_scope_cap(&["score"]);
5260 let mk = |set_col: &str| Insert {
5261 table: "client_survey".into(),
5262 rows: vec![RowValues {
5263 cells: vec![Assignment {
5264 column: "score".into(),
5265 value: Expr::val(SqlValue::Integer(5)),
5266 }],
5267 }],
5268 conflict: Some(OnConflict {
5269 conflict_columns: vec!["tenant_id".into(), "project_id".into()],
5270 update: vec![Assignment {
5271 column: set_col.into(),
5272 value: Expr::val(SqlValue::Integer(9)),
5273 }],
5274 }),
5275 scope: None,
5276 returning: vec![],
5277 from_select: None,
5278 };
5279 let mut bad_col = mk("secret"); assert!(matches!(
5281 bad_col.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5282 OrmError::TargetWriteColumnDenied(_)
5283 ));
5284 let mut tenant_set = mk("tenant_id"); assert!(matches!(
5286 tenant_set
5287 .force_scope(Some(&scope), Some(&scope))
5288 .unwrap_err(),
5289 OrmError::TargetWriteColumnDenied(_)
5290 ));
5291 }
5292
5293 #[test]
5294 fn target_update_confines_to_the_public_subset_and_enforces_the_allowlist() {
5295 let scope = target_write_scope(&["title"]);
5296 let mut upd = Update {
5297 table: "products".into(),
5298 set: vec![Assignment {
5299 column: "title".into(),
5300 value: Expr::val(t("new")),
5301 }],
5302 filter: cmp("id", CmpOp::Eq, t("p1")),
5303 scope: None,
5304 returning: vec![],
5305 };
5306 upd.force_scope(&scope).unwrap();
5307 let (sql, params) = upd.compile(Dialect::Sqlite).unwrap();
5308 assert!(sql.contains("tenant_id = ?"), "tenant confinement: {sql}");
5310 assert!(sql.contains("published = ?"), "public confinement: {sql}");
5311 assert!(
5312 sql.contains("deleted_at IS NULL"),
5313 "public null confinement: {sql}"
5314 );
5315 assert!(sql.contains("SET title = ?"), "{sql}");
5316 assert!(params.contains(&t("tenant_B")), "{params:?}");
5317 }
5318
5319 #[test]
5320 fn target_update_capability_no_subset_confines_tenant_only_not_refused() {
5321 use std::collections::{BTreeMap, BTreeSet};
5325 let scope = Scope {
5326 column: "tenant_id".into(),
5327 value: Some(t("tenant_B")),
5328 session: None,
5329 mode: ScopeMode::Own,
5330 keys: TableKeys::PerTableTarget {
5331 keys: BTreeMap::from([(
5332 "invoices".to_string(),
5333 ResolvedScope::Column("tenant_id".into()),
5334 )]),
5335 public: BTreeMap::new(), write: BTreeSet::from(["amount".to_string()]),
5337 require_public: false, },
5339 };
5340 let mut upd = Update {
5341 table: "invoices".into(),
5342 set: vec![Assignment {
5343 column: "amount".into(),
5344 value: Expr::val(SqlValue::Integer(5)),
5345 }],
5346 filter: cmp("id", CmpOp::Eq, t("inv1")),
5347 scope: None,
5348 returning: vec![],
5349 };
5350 upd.force_scope(&scope).unwrap();
5351 let (sql, _params) = upd.compile(Dialect::Sqlite).unwrap();
5352 assert!(
5353 sql.contains("tenant_id = ?"),
5354 "tenant=B confinement present: {sql}"
5355 );
5356 assert!(!sql.contains("published"), "no visibility conjunct: {sql}");
5357 assert!(sql.contains("SET amount = ?"), "{sql}");
5358 let mut bad = Update {
5360 table: "invoices".into(),
5361 set: vec![Assignment {
5362 column: "tenant_id".into(),
5363 value: Expr::val(t("evil")),
5364 }],
5365 filter: cmp("id", CmpOp::Eq, t("inv1")),
5366 scope: None,
5367 returning: vec![],
5368 };
5369 assert!(
5370 bad.force_scope(&scope).is_err(),
5371 "tenant column still un-settable"
5372 );
5373 }
5374
5375 #[test]
5376 fn target_update_refuses_a_non_allowlisted_or_visibility_set() {
5377 for bad in ["price", "published", "tenant_id"] {
5378 let scope = target_write_scope(&["title"]);
5379 let mut upd = Update {
5380 table: "products".into(),
5381 set: vec![Assignment {
5382 column: bad.into(),
5383 value: Expr::val(t("x")),
5384 }],
5385 filter: cmp("id", CmpOp::Eq, t("p1")),
5386 scope: None,
5387 returning: vec![],
5388 };
5389 assert!(
5390 matches!(upd.force_scope(&scope).unwrap_err(), OrmError::TargetWriteColumnDenied(ref c) if c == bad),
5391 "{bad}"
5392 );
5393 }
5394 }
5395
5396 #[test]
5397 fn target_write_refuses_a_qualified_column() {
5398 let scope = target_write_scope(&["title"]);
5401 let mut ins = target_insert(vec![Assignment {
5402 column: "published.x".into(),
5403 value: Expr::val(t("x")),
5404 }]);
5405 assert!(matches!(
5406 ins.force_scope(Some(&scope), Some(&scope)).unwrap_err(),
5407 OrmError::TargetWriteColumnDenied(ref c) if c == "published.x"
5408 ));
5409 let mut upd = Update {
5410 table: "products".into(),
5411 set: vec![Assignment {
5412 column: "title.y".into(),
5413 value: Expr::val(t("x")),
5414 }],
5415 filter: cmp("id", CmpOp::Eq, t("p1")),
5416 scope: None,
5417 returning: vec![],
5418 };
5419 assert!(matches!(
5420 upd.force_scope(&scope).unwrap_err(),
5421 OrmError::TargetWriteColumnDenied(ref c) if c == "title.y"
5422 ));
5423 }
5424
5425 #[test]
5426 fn target_delete_is_always_refused() {
5427 let scope = target_write_scope(&["title"]);
5428 let mut del = Delete {
5429 table: "products".into(),
5430 filter: cmp("id", CmpOp::Eq, t("p1")),
5431 scope: None,
5432 returning: vec![],
5433 };
5434 assert!(matches!(
5435 del.force_scope(&scope).unwrap_err(),
5436 OrmError::TargetDeleteRefused(t) if t == "products"
5437 ));
5438 }
5439
5440 #[test]
5441 fn target_promote_is_refused() {
5442 let scope = target_write_scope(&["title"]);
5443 assert!(matches!(
5444 compile_promote(&scope, "products", Dialect::Sqlite).unwrap_err(),
5445 OrmError::TargetWriteUnsupported("promote")
5446 ));
5447 }
5448
5449 fn attach_spec() -> AttachReference {
5452 AttachReference {
5453 child: "favorites".into(),
5454 parent: "products".into(),
5455 ref_column: "id".into(),
5456 ref_value: t("prod_1"),
5457 set: vec![Assignment {
5458 column: "note".into(),
5459 value: Expr::val(t("nice")),
5460 }],
5461 }
5462 }
5463
5464 #[test]
5465 fn attach_reference_own_derives_tenant_from_the_scoped_parent() {
5466 use std::collections::BTreeMap;
5467 let scope = Scope {
5469 column: "tenant_id".into(),
5470 value: Some(t("A")),
5471 session: None,
5472 mode: ScopeMode::Own,
5473 keys: TableKeys::PerTable(BTreeMap::from([
5474 (
5475 "favorites".to_string(),
5476 ResolvedScope::Column("tenant_id".into()),
5477 ),
5478 (
5479 "products".to_string(),
5480 ResolvedScope::Column("tenant_id".into()),
5481 ),
5482 ])),
5483 };
5484 let (sql, params) =
5485 compile_attach_reference(&scope, &attach_spec(), Dialect::Sqlite).unwrap();
5486 assert_eq!(
5489 sql,
5490 "INSERT INTO favorites (note, tenant_id) SELECT ?1, tenant_id FROM products \
5491 WHERE tenant_id = ?2 AND id = ?3"
5492 );
5493 assert_eq!(params, vec![t("nice"), t("A"), t("prod_1")]);
5494 }
5495
5496 #[test]
5497 fn attach_reference_target_confines_parent_to_b_public_and_forces_child_public() {
5498 use std::collections::{BTreeMap, BTreeSet};
5501 let public_terms = vec![PublicTermSql::Cmp {
5502 column: "visible".into(),
5503 op: CmpOp::Eq,
5504 value: SqlValue::Boolean(true),
5505 }];
5506 let scope = Scope {
5507 column: "tenant_id".into(),
5508 value: Some(t("tenant_B")),
5509 session: None,
5510 mode: ScopeMode::Own,
5511 keys: TableKeys::PerTableTarget {
5512 keys: BTreeMap::from([
5513 (
5514 "favorites".to_string(),
5515 ResolvedScope::Column("tenant_id".into()),
5516 ),
5517 (
5518 "products".to_string(),
5519 ResolvedScope::Column("tenant_id".into()),
5520 ),
5521 ]),
5522 public: BTreeMap::from([
5523 ("favorites".to_string(), public_terms.clone()),
5524 (
5525 "products".to_string(),
5526 vec![PublicTermSql::Cmp {
5527 column: "published".into(),
5528 op: CmpOp::Eq,
5529 value: SqlValue::Boolean(true),
5530 }],
5531 ),
5532 ]),
5533 write: BTreeSet::from(["note".to_string()]),
5534 require_public: true,
5535 },
5536 };
5537 let (sql, params) =
5538 compile_attach_reference(&scope, &attach_spec(), Dialect::Sqlite).unwrap();
5539 assert!(
5542 sql.contains("INSERT INTO favorites (note, tenant_id, visible)"),
5543 "{sql}"
5544 );
5545 assert!(
5546 sql.contains("SELECT ?1, tenant_id, ?2 FROM products"),
5547 "{sql}"
5548 );
5549 assert!(
5550 sql.contains("published = ?") && sql.contains("tenant_id = ?"),
5551 "parent confined: {sql}"
5552 );
5553 assert!(sql.contains("AND id = ?"), "ref selector present: {sql}");
5554 assert!(
5555 params.contains(&t("tenant_B")),
5556 "parent confined to B: {params:?}"
5557 );
5558 assert!(
5559 params.contains(&SqlValue::Boolean(true)),
5560 "child visible forced + parent published: {params:?}"
5561 );
5562 }
5563
5564 #[test]
5565 fn attach_reference_target_refuses_a_non_allowlisted_or_visibility_set() {
5566 use std::collections::{BTreeMap, BTreeSet};
5567 let scope = Scope {
5568 column: "tenant_id".into(),
5569 value: Some(t("tenant_B")),
5570 session: None,
5571 mode: ScopeMode::Own,
5572 keys: TableKeys::PerTableTarget {
5573 keys: BTreeMap::from([
5574 (
5575 "favorites".to_string(),
5576 ResolvedScope::Column("tenant_id".into()),
5577 ),
5578 (
5579 "products".to_string(),
5580 ResolvedScope::Column("tenant_id".into()),
5581 ),
5582 ]),
5583 public: BTreeMap::from([
5584 (
5585 "favorites".to_string(),
5586 vec![PublicTermSql::Cmp {
5587 column: "visible".into(),
5588 op: CmpOp::Eq,
5589 value: SqlValue::Boolean(true),
5590 }],
5591 ),
5592 (
5593 "products".to_string(),
5594 vec![PublicTermSql::Cmp {
5595 column: "published".into(),
5596 op: CmpOp::Eq,
5597 value: SqlValue::Boolean(true),
5598 }],
5599 ),
5600 ]),
5601 write: BTreeSet::from(["note".to_string()]),
5602 require_public: true,
5603 },
5604 };
5605 for bad in ["price", "visible", "tenant_id"] {
5607 let spec = AttachReference {
5608 set: vec![Assignment {
5609 column: bad.into(),
5610 value: Expr::val(t("x")),
5611 }],
5612 ..attach_spec()
5613 };
5614 assert!(
5615 matches!(
5616 compile_attach_reference(&scope, &spec, Dialect::Sqlite).unwrap_err(),
5617 OrmError::TargetWriteColumnDenied(ref c) if c == bad
5618 ),
5619 "{bad}"
5620 );
5621 }
5622 }
5623
5624 #[test]
5625 fn attach_reference_own_refuses_the_guest_naming_the_tenant_column() {
5626 use std::collections::BTreeMap;
5627 let scope = Scope {
5630 column: "tenant_id".into(),
5631 value: Some(t("A")),
5632 session: None,
5633 mode: ScopeMode::Own,
5634 keys: TableKeys::PerTable(BTreeMap::from([
5635 (
5636 "favorites".to_string(),
5637 ResolvedScope::Column("tenant_id".into()),
5638 ),
5639 (
5640 "products".to_string(),
5641 ResolvedScope::Column("tenant_id".into()),
5642 ),
5643 ])),
5644 };
5645 let spec = AttachReference {
5646 set: vec![Assignment {
5647 column: "tenant_id".into(),
5648 value: Expr::val(t("VICTIM")),
5649 }],
5650 ..attach_spec()
5651 };
5652 assert!(matches!(
5653 compile_attach_reference(&scope, &spec, Dialect::Sqlite).unwrap_err(),
5654 OrmError::TargetWriteColumnDenied(ref c) if c == "tenant_id"
5655 ));
5656 }
5657
5658 #[test]
5659 fn attach_reference_refuses_a_non_column_table() {
5660 use std::collections::BTreeMap;
5661 let scope = Scope {
5663 column: "tenant_id".into(),
5664 value: Some(t("A")),
5665 session: None,
5666 mode: ScopeMode::Own,
5667 keys: TableKeys::PerTable(BTreeMap::from([
5668 (
5669 "favorites".to_string(),
5670 ResolvedScope::Column("tenant_id".into()),
5671 ),
5672 ("countries".to_string(), ResolvedScope::Unscoped),
5673 ])),
5674 };
5675 let spec = AttachReference {
5676 parent: "countries".into(),
5677 ..attach_spec()
5678 };
5679 assert!(compile_attach_reference(&scope, &spec, Dialect::Sqlite).is_err());
5680 }
5681}