@@ -3,7 +3,10 @@ use crate::{
33 schema:: { Column , Index , Schema } ,
44 translate:: {
55 collate:: get_collseq_from_expr,
6- expr:: { as_binary_components, comparison_affinity, walk_expr_mut, WalkControl } ,
6+ expr:: {
7+ as_binary_components, comparison_affinity, get_expr_affinity, unwrap_parens,
8+ walk_expr_mut, WalkControl ,
9+ } ,
710 expression_index:: normalize_expr_for_index_matching,
811 plan:: { JoinOrderMember , JoinedTable , NonFromClauseSubquery , TableReferences , WhereTerm } ,
912 planner:: { break_predicate_at_and_boundaries, table_mask_from_expr, TableMask , ROWID_STRS } ,
@@ -70,6 +73,18 @@ pub struct Constraint {
7073 /// Whether this constraint references the implicit rowid (tables without an INTEGER PRIMARY KEY alias).
7174 /// When true and `table_col_pos` is None, this constraint targets the rowid pseudo-column.
7275 pub is_rowid : bool ,
76+ /// The constraint's resolved comparison affinity, as defined by SQLite's
77+ /// `comparisonAffinity` in `expr.c`. Cached at construction time so the
78+ /// `sqlite3IndexAffinityOk` check can run without re-resolving the
79+ /// WhereTerm at every index-selection callsite.
80+ ///
81+ /// `None` for forms whose comparison affinity has no single resolved value:
82+ /// FTS MATCH and virtual-table push-downs (operators outside SQLite's
83+ /// comparison set), and row-value IN (`(a,b) IN (...)`, which SQLite
84+ /// handles per-LHS-column via `sqlite3VectorFieldSubexpr` — Turso does
85+ /// not yet plumb per-column affinity into the index-selection path, so
86+ /// such constraints fall through to scans).
87+ pub comparison_affinity : Option < Affinity > ,
7388}
7489
7590#[ derive( Debug , Clone , Copy , PartialEq ) ]
@@ -118,8 +133,7 @@ impl Constraint {
118133 panic ! ( "Expected a valid binary expression" ) ;
119134 } ;
120135 let mut affinity = Affinity :: Blob ;
121- if op. as_ast_operator ( ) . is_some_and ( |op| op. is_comparison ( ) ) && self . table_col_pos . is_some ( )
122- {
136+ if op. as_ast_operator ( ) . is_some_and ( |op| op. is_comparison ( ) ) {
123137 affinity = comparison_affinity ( lhs, rhs, referenced_tables, None ) ;
124138 }
125139
@@ -160,6 +174,35 @@ impl Constraint {
160174 rhs
161175 }
162176 }
177+
178+ /// Returns true when an index column with affinity `idx_aff` can satisfy
179+ /// this constraint per SQLite's `sqlite3IndexAffinityOk`. Constraints
180+ /// whose form has no SQLite-defined comparison affinity (FTS MATCH,
181+ /// virtual-table push-downs, row-value IN) carry `None` and bypass the
182+ /// check — those paths handle types themselves.
183+ pub fn satisfies_index_affinity ( & self , idx_aff : Affinity ) -> bool {
184+ match self . comparison_affinity {
185+ Some ( comparison_aff) => idx_aff. index_affinity_ok ( comparison_aff) ,
186+ None => true ,
187+ }
188+ }
189+
190+ /// Whether this constraint can drive an index seek on its target column.
191+ /// Composes the `usable`/`table_col_pos` gates with the affinity check
192+ /// against the column at `table_col_pos` in `columns` (set `is_strict`
193+ /// only for STRICT tables; subqueries pass `false`).
194+ pub fn can_drive_index_seek ( & self , columns : & [ Column ] , is_strict : bool ) -> bool {
195+ if !self . usable {
196+ return false ;
197+ }
198+ let Some ( pos) = self . table_col_pos else {
199+ return false ;
200+ } ;
201+ let col = columns. get ( pos) . unwrap_or_else ( || {
202+ unreachable ! ( "constraint table_col_pos {pos} out of bounds for {columns:?}" )
203+ } ) ;
204+ self . satisfies_index_affinity ( col. affinity_with_strict ( is_strict) )
205+ }
163206}
164207
165208#[ derive( Debug , Clone ) ]
@@ -449,6 +492,13 @@ pub fn constraints_from_where_clause(
449492
450493 // Try to extract as binary expression first
451494 if let Some ( ( lhs, operator, rhs) ) = as_binary_components ( & term. expr ) ? {
495+ // Resolve the comparison affinity once per term per SQLite's
496+ // `comparisonAffinity` (see `Constraint::comparison_affinity`)
497+ // and propagate it to every constraint derived from this term.
498+ let cmp_aff = operator
499+ . as_ast_operator ( )
500+ . filter ( |op| op. is_comparison ( ) )
501+ . map ( |_| comparison_affinity ( lhs, rhs, Some ( table_references) , None ) ) ;
452502 // If either the LHS or RHS of the constraint is a column from the table, add the constraint.
453503 match lhs {
454504 ast:: Expr :: Column { table, column, .. } => {
@@ -472,6 +522,7 @@ pub fn constraints_from_where_clause(
472522 ) ,
473523 usable : true ,
474524 is_rowid : false ,
525+ comparison_affinity : cmp_aff,
475526 } ) ;
476527 }
477528 }
@@ -500,6 +551,7 @@ pub fn constraints_from_where_clause(
500551 ) ,
501552 usable : true ,
502553 is_rowid : true ,
554+ comparison_affinity : cmp_aff,
503555 } ) ;
504556 }
505557 }
@@ -537,6 +589,7 @@ pub fn constraints_from_where_clause(
537589 selectivity,
538590 usable : true ,
539591 is_rowid : false ,
592+ comparison_affinity : cmp_aff,
540593 } ) ;
541594 }
542595 _ => { }
@@ -563,6 +616,7 @@ pub fn constraints_from_where_clause(
563616 ) ,
564617 usable : true ,
565618 is_rowid : false ,
619+ comparison_affinity : cmp_aff,
566620 } ) ;
567621 }
568622 }
@@ -591,6 +645,7 @@ pub fn constraints_from_where_clause(
591645 ) ,
592646 usable : true ,
593647 is_rowid : true ,
648+ comparison_affinity : cmp_aff,
594649 } ) ;
595650 }
596651 }
@@ -628,6 +683,7 @@ pub fn constraints_from_where_clause(
628683 selectivity,
629684 usable : true ,
630685 is_rowid : false ,
686+ comparison_affinity : cmp_aff,
631687 } ) ;
632688 }
633689 _ => { }
@@ -658,6 +714,9 @@ pub fn constraints_from_where_clause(
658714 . unwrap_or ( params. rows_per_table_fallback as u64 )
659715 as f64 ;
660716 let selectivity = estimate_in_selectivity ( estimated_values, row_count, * not) ;
717+ // SQLite's `comparisonAffinity` for IN-list (`x IN (lit, ...)`)
718+ // is the LHS column's affinity; the RHS literals are not folded.
719+ let cmp_aff = Some ( get_expr_affinity ( lhs, Some ( table_references) , None ) ) ;
661720
662721 match lhs. as_ref ( ) {
663722 ast:: Expr :: Column { table, column, .. }
@@ -677,6 +736,7 @@ pub fn constraints_from_where_clause(
677736 selectivity,
678737 usable : false , // IN uses a separate seek path, not the range-seek model
679738 is_rowid,
739+ comparison_affinity : cmp_aff,
680740 } ) ;
681741 }
682742 ast:: Expr :: RowId { table, .. } if * table == table_reference. internal_id => {
@@ -693,6 +753,7 @@ pub fn constraints_from_where_clause(
693753 selectivity,
694754 usable : false ,
695755 is_rowid : true ,
756+ comparison_affinity : cmp_aff,
696757 } ) ;
697758 }
698759 _ => { }
@@ -704,7 +765,7 @@ pub fn constraints_from_where_clause(
704765 subquery_id,
705766 lhs : Some ( lhs_expr) ,
706767 not_in,
707- query_type : ast:: SubqueryType :: In { .. } ,
768+ query_type : ast:: SubqueryType :: In { affinity_str , .. } ,
708769 } = & term. expr
709770 {
710771 // Find the subquery to check if it's correlated
@@ -723,6 +784,19 @@ pub fn constraints_from_where_clause(
723784 . unwrap_or ( params. rows_per_table_fallback as u64 )
724785 as f64 ;
725786 let selectivity = estimate_in_selectivity ( estimated_values, row_count, * not_in) ;
787+ // SQLite's `comparisonAffinity` for IN-subquery combines the
788+ // LHS column affinity with each result column via
789+ // `sqlite3CompareAffinity` — that result is already cached on
790+ // `SubqueryType::In::affinity_str`. For a single-LHS-column
791+ // IN it is the first character; row-value IN has no single
792+ // resolved affinity and is left as `None`.
793+ let is_row_value = matches ! (
794+ unwrap_parens( lhs_expr. as_ref( ) ) . ok( ) ,
795+ Some ( ast:: Expr :: Parenthesized ( exprs) ) if exprs. len( ) != 1
796+ ) ;
797+ let cmp_aff = ( !is_row_value)
798+ . then ( || affinity_str. chars ( ) . next ( ) . map ( Affinity :: from_char) )
799+ . flatten ( ) ;
726800
727801 match lhs_expr. as_ref ( ) {
728802 ast:: Expr :: Column { table, column, .. }
@@ -742,6 +816,7 @@ pub fn constraints_from_where_clause(
742816 selectivity,
743817 usable : false , // IN uses a separate seek path (consider_in_list_seek)
744818 is_rowid,
819+ comparison_affinity : cmp_aff,
745820 } ) ;
746821 }
747822 ast:: Expr :: RowId { table, .. } if * table == table_reference. internal_id => {
@@ -758,6 +833,7 @@ pub fn constraints_from_where_clause(
758833 selectivity,
759834 usable : false ,
760835 is_rowid : true ,
836+ comparison_affinity : cmp_aff,
761837 } ) ;
762838 }
763839 _ => { }
@@ -862,6 +938,12 @@ pub fn constraints_from_where_clause(
862938 {
863939 continue ;
864940 }
941+ // See [`Constraint::satisfies_index_affinity`].
942+ let idx_col_aff = constrained_column
943+ . affinity_with_strict ( table_reference. table . is_strict ( ) ) ;
944+ if !constraint. satisfies_index_affinity ( idx_col_aff) {
945+ continue ;
946+ }
865947 }
866948 if let Some ( index_candidate) = cs. candidates . iter_mut ( ) . find_map ( |candidate| {
867949 if candidate. index . as_ref ( ) . is_some_and ( |i| {
@@ -1731,6 +1813,7 @@ pub(crate) fn analyze_binary_term_for_index(
17311813 selectivity,
17321814 usable : true ,
17331815 is_rowid,
1816+ comparison_affinity : Some ( affinity) ,
17341817 } ;
17351818
17361819 Some ( AnalyzedTerm {
0 commit comments