mirror of
https://github.com/aljazceru/turso.git
synced 2026-01-15 06:04:19 +01:00
Merge 'Fix seek not applying correct affinity to seek expr' from Pedro Muniz
Depends on #3923 . To have similar semantics to how `op_compare` works, we need to apply an affinity to the values referenced in the `SeekKey` that is used for seeking. This means keeping some affinity metadata for the `WhereTerms` in the optimization phase, then before seeking, we emit an affinity conversion. Had to dig deep in the sqlite code to understand this better. Unfortunately, we cannot have just one compare function to rule them all here, as we have a specialized/optimized compare code to handle records that have not yet been deserialized. Closes #3707 Reviewed-by: Jussi Saurio <jussi.saurio@gmail.com> Closes #3925
This commit is contained in:
@@ -2198,7 +2198,6 @@ impl BTreeCursor {
|
||||
SeekOp::LE { eq_only: false } => cmp.is_le(),
|
||||
SeekOp::LT => cmp.is_lt(),
|
||||
};
|
||||
|
||||
(cmp, found)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::{
|
||||
},
|
||||
types::SeekOp,
|
||||
vdbe::{
|
||||
affinity,
|
||||
builder::{CursorKey, CursorType, ProgramBuilder},
|
||||
insn::{CmpInsFlags, IdxInsertFlags, Insn},
|
||||
BranchOffset, CursorID,
|
||||
@@ -1375,6 +1376,24 @@ fn emit_seek(
|
||||
}
|
||||
}
|
||||
let num_regs = seek_def.size(&seek_def.start);
|
||||
|
||||
if is_index {
|
||||
let affinities: String = seek_def
|
||||
.iter_affinity(&seek_def.start)
|
||||
.map(|affinity| affinity.aff_mask())
|
||||
.collect();
|
||||
if affinities.chars().any(|c| c != affinity::SQLITE_AFF_NONE) {
|
||||
program.emit_insn(Insn::Affinity {
|
||||
start_reg,
|
||||
count: std::num::NonZeroUsize::new(num_regs).unwrap(),
|
||||
affinities: seek_def
|
||||
.iter_affinity(&seek_def.start)
|
||||
.map(|affinity| affinity.aff_mask())
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match seek_def.start.op {
|
||||
SeekOp::GE { eq_only } => program.emit_insn(Insn::SeekGE {
|
||||
is_index,
|
||||
|
||||
@@ -8,11 +8,12 @@ use crate::{
|
||||
schema::{Column, Index},
|
||||
translate::{
|
||||
collate::get_collseq_from_expr,
|
||||
expr::as_binary_components,
|
||||
expr::{as_binary_components, comparison_affinity},
|
||||
plan::{JoinOrderMember, NonFromClauseSubquery, TableReferences, WhereTerm},
|
||||
planner::{table_mask_from_expr, TableMask},
|
||||
},
|
||||
util::exprs_are_equivalent,
|
||||
vdbe::affinity::Affinity,
|
||||
Result,
|
||||
};
|
||||
use turso_ext::{ConstraintInfo, ConstraintOp};
|
||||
@@ -68,16 +69,31 @@ pub enum BinaryExprSide {
|
||||
|
||||
impl Constraint {
|
||||
/// Get the constraining expression and operator, e.g. ('>=', '2+3') from 't.x >= 2+3'
|
||||
pub fn get_constraining_expr(&self, where_clause: &[WhereTerm]) -> (ast::Operator, ast::Expr) {
|
||||
pub fn get_constraining_expr(
|
||||
&self,
|
||||
where_clause: &[WhereTerm],
|
||||
referenced_tables: Option<&TableReferences>,
|
||||
) -> (ast::Operator, ast::Expr, Affinity) {
|
||||
let (idx, side) = self.where_clause_pos;
|
||||
let where_term = &where_clause[idx];
|
||||
let Ok(Some((lhs, _, rhs))) = as_binary_components(&where_term.expr) else {
|
||||
let Ok(Some((lhs, op, rhs))) = as_binary_components(&where_term.expr) else {
|
||||
panic!("Expected a valid binary expression");
|
||||
};
|
||||
let mut affinity = Affinity::Blob;
|
||||
if op.is_comparison() {
|
||||
affinity = comparison_affinity(lhs, rhs, referenced_tables);
|
||||
}
|
||||
|
||||
if side == BinaryExprSide::Lhs {
|
||||
(self.operator, lhs.clone())
|
||||
if affinity.expr_needs_no_affinity_change(lhs) {
|
||||
affinity = Affinity::Blob;
|
||||
}
|
||||
(self.operator, lhs.clone(), affinity)
|
||||
} else {
|
||||
(self.operator, rhs.clone())
|
||||
if affinity.expr_needs_no_affinity_change(rhs) {
|
||||
affinity = Affinity::Blob;
|
||||
}
|
||||
(self.operator, rhs.clone(), affinity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,13 +432,13 @@ pub struct RangeConstraintRef {
|
||||
/// Represent seek range which can be used in query planning to emit range scan over table or index
|
||||
pub struct SeekRangeConstraint {
|
||||
pub sort_order: SortOrder,
|
||||
pub eq: Option<(ast::Operator, ast::Expr)>,
|
||||
pub lower_bound: Option<(ast::Operator, ast::Expr)>,
|
||||
pub upper_bound: Option<(ast::Operator, ast::Expr)>,
|
||||
pub eq: Option<(ast::Operator, ast::Expr, Affinity)>,
|
||||
pub lower_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
|
||||
pub upper_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
|
||||
}
|
||||
|
||||
impl SeekRangeConstraint {
|
||||
pub fn new_eq(sort_order: SortOrder, eq: (ast::Operator, ast::Expr)) -> Self {
|
||||
pub fn new_eq(sort_order: SortOrder, eq: (ast::Operator, ast::Expr, Affinity)) -> Self {
|
||||
Self {
|
||||
sort_order,
|
||||
eq: Some(eq),
|
||||
@@ -432,8 +448,8 @@ impl SeekRangeConstraint {
|
||||
}
|
||||
pub fn new_range(
|
||||
sort_order: SortOrder,
|
||||
lower_bound: Option<(ast::Operator, ast::Expr)>,
|
||||
upper_bound: Option<(ast::Operator, ast::Expr)>,
|
||||
lower_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
|
||||
upper_bound: Option<(ast::Operator, ast::Expr, Affinity)>,
|
||||
) -> Self {
|
||||
assert!(lower_bound.is_some() || upper_bound.is_some());
|
||||
Self {
|
||||
@@ -451,19 +467,20 @@ impl RangeConstraintRef {
|
||||
&self,
|
||||
constraints: &[Constraint],
|
||||
where_clause: &[WhereTerm],
|
||||
referenced_tables: Option<&TableReferences>,
|
||||
) -> SeekRangeConstraint {
|
||||
if let Some(eq) = self.eq {
|
||||
return SeekRangeConstraint::new_eq(
|
||||
self.sort_order,
|
||||
constraints[eq].get_constraining_expr(where_clause),
|
||||
constraints[eq].get_constraining_expr(where_clause, referenced_tables),
|
||||
);
|
||||
}
|
||||
SeekRangeConstraint::new_range(
|
||||
self.sort_order,
|
||||
self.lower_bound
|
||||
.map(|x| constraints[x].get_constraining_expr(where_clause)),
|
||||
.map(|x| constraints[x].get_constraining_expr(where_clause, referenced_tables)),
|
||||
self.upper_bound
|
||||
.map(|x| constraints[x].get_constraining_expr(where_clause)),
|
||||
.map(|x| constraints[x].get_constraining_expr(where_clause, referenced_tables)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ use crate::{
|
||||
util::{
|
||||
exprs_are_equivalent, simple_bind_expr, try_capture_parameters, try_substitute_parameters,
|
||||
},
|
||||
vdbe::builder::{CursorKey, CursorType, ProgramBuilder},
|
||||
vdbe::{
|
||||
affinity::Affinity,
|
||||
builder::{CursorKey, CursorType, ProgramBuilder},
|
||||
},
|
||||
LimboError, Result,
|
||||
};
|
||||
|
||||
@@ -611,8 +614,6 @@ fn optimize_table_access(
|
||||
best_ordered_plan,
|
||||
} = best_join_order_result;
|
||||
|
||||
let joined_tables = table_references.joined_tables_mut();
|
||||
|
||||
// See if best_ordered_plan is better than the overall best_plan if we add a sorting penalty
|
||||
// to the unordered plan's cost.
|
||||
let best_plan = if let Some(best_ordered_plan) = best_ordered_plan {
|
||||
@@ -635,7 +636,7 @@ fn optimize_table_access(
|
||||
let satisfies_order_target = plan_satisfies_order_target(
|
||||
&best_plan,
|
||||
&access_methods_arena,
|
||||
joined_tables,
|
||||
table_references.joined_tables_mut(),
|
||||
&order_target,
|
||||
);
|
||||
if satisfies_order_target {
|
||||
@@ -662,9 +663,9 @@ fn optimize_table_access(
|
||||
let best_join_order: Vec<JoinOrderMember> = best_table_numbers
|
||||
.into_iter()
|
||||
.map(|table_number| JoinOrderMember {
|
||||
table_id: joined_tables[table_number].internal_id,
|
||||
table_id: table_references.joined_tables_mut()[table_number].internal_id,
|
||||
original_idx: table_number,
|
||||
is_outer: joined_tables[table_number]
|
||||
is_outer: table_references.joined_tables_mut()[table_number]
|
||||
.join_info
|
||||
.as_ref()
|
||||
.is_some_and(|join_info| join_info.outer),
|
||||
@@ -675,7 +676,6 @@ fn optimize_table_access(
|
||||
for (i, join_order_member) in best_join_order.iter().enumerate() {
|
||||
let table_idx = join_order_member.original_idx;
|
||||
let access_method = &access_methods_arena.borrow()[best_access_methods[i]];
|
||||
|
||||
match &access_method.params {
|
||||
AccessMethodParams::BTreeTable {
|
||||
iter_dir,
|
||||
@@ -692,10 +692,11 @@ fn optimize_table_access(
|
||||
};
|
||||
|
||||
if !try_to_build_ephemeral_index {
|
||||
joined_tables[table_idx].op = Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// This branch means we have a full table scan for a non-outermost table.
|
||||
@@ -704,10 +705,11 @@ fn optimize_table_access(
|
||||
.iter()
|
||||
.find(|c| c.table_id == join_order_member.table_id);
|
||||
let Some(table_constraints) = table_constraints else {
|
||||
joined_tables[table_idx].op = Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let usable_constraints = table_constraints
|
||||
@@ -731,26 +733,31 @@ fn optimize_table_access(
|
||||
&best_join_order[..=i],
|
||||
);
|
||||
if usable_constraint_refs.is_empty() {
|
||||
joined_tables[table_idx].op = Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Scan(Scan::BTreeTable {
|
||||
iter_dir: *iter_dir,
|
||||
index: index.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let ephemeral_index =
|
||||
ephemeral_index_build(&joined_tables[table_idx], &usable_constraint_refs);
|
||||
let ephemeral_index = ephemeral_index_build(
|
||||
&table_references.joined_tables_mut()[table_idx],
|
||||
&usable_constraint_refs,
|
||||
);
|
||||
let ephemeral_index = Arc::new(ephemeral_index);
|
||||
joined_tables[table_idx].op = Operation::Search(Search::Seek {
|
||||
index: Some(ephemeral_index),
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&table_constraints.constraints,
|
||||
&usable_constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
)?,
|
||||
});
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Search(Search::Seek {
|
||||
index: Some(ephemeral_index),
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&table_constraints.constraints,
|
||||
&usable_constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
Some(table_references),
|
||||
)?,
|
||||
});
|
||||
} else {
|
||||
let is_outer_join = joined_tables[table_idx]
|
||||
let is_outer_join = table_references.joined_tables_mut()[table_idx]
|
||||
.join_info
|
||||
.as_ref()
|
||||
.is_some_and(|join_info| join_info.outer);
|
||||
@@ -780,38 +787,42 @@ fn optimize_table_access(
|
||||
}
|
||||
}
|
||||
if let Some(index) = &index {
|
||||
joined_tables[table_idx].op = Operation::Search(Search::Seek {
|
||||
index: Some(index.clone()),
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&constraints_per_table[table_idx].constraints,
|
||||
constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
)?,
|
||||
});
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Search(Search::Seek {
|
||||
index: Some(index.clone()),
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&constraints_per_table[table_idx].constraints,
|
||||
constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
Some(table_references),
|
||||
)?,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
constraint_refs.len() == 1,
|
||||
"expected exactly one constraint for rowid seek, got {constraint_refs:?}"
|
||||
);
|
||||
joined_tables[table_idx].op = if let Some(eq) = constraint_refs[0].eq {
|
||||
Operation::Search(Search::RowidEq {
|
||||
cmp_expr: constraints_per_table[table_idx].constraints[eq]
|
||||
.get_constraining_expr(where_clause)
|
||||
.1,
|
||||
})
|
||||
} else {
|
||||
Operation::Search(Search::Seek {
|
||||
index: None,
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&constraints_per_table[table_idx].constraints,
|
||||
constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
)?,
|
||||
})
|
||||
};
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
if let Some(eq) = constraint_refs[0].eq {
|
||||
Operation::Search(Search::RowidEq {
|
||||
cmp_expr: constraints_per_table[table_idx].constraints[eq]
|
||||
.get_constraining_expr(where_clause, Some(table_references))
|
||||
.1,
|
||||
})
|
||||
} else {
|
||||
Operation::Search(Search::Seek {
|
||||
index: None,
|
||||
seek_def: build_seek_def_from_constraints(
|
||||
&constraints_per_table[table_idx].constraints,
|
||||
constraint_refs,
|
||||
*iter_dir,
|
||||
where_clause,
|
||||
Some(table_references),
|
||||
)?,
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
AccessMethodParams::VirtualTable {
|
||||
@@ -820,17 +831,19 @@ fn optimize_table_access(
|
||||
constraints,
|
||||
constraint_usages,
|
||||
} => {
|
||||
joined_tables[table_idx].op = build_vtab_scan_op(
|
||||
table_references.joined_tables_mut()[table_idx].op = build_vtab_scan_op(
|
||||
where_clause,
|
||||
&constraints_per_table[table_idx],
|
||||
idx_num,
|
||||
idx_str,
|
||||
constraints,
|
||||
constraint_usages,
|
||||
Some(table_references),
|
||||
)?;
|
||||
}
|
||||
AccessMethodParams::Subquery => {
|
||||
joined_tables[table_idx].op = Operation::Scan(Scan::Subquery);
|
||||
table_references.joined_tables_mut()[table_idx].op =
|
||||
Operation::Scan(Scan::Subquery);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -845,6 +858,7 @@ fn build_vtab_scan_op(
|
||||
idx_str: &Option<String>,
|
||||
vtab_constraints: &[ConstraintInfo],
|
||||
constraint_usages: &[ConstraintUsage],
|
||||
referenced_tables: Option<&TableReferences>,
|
||||
) -> Result<Operation> {
|
||||
if constraint_usages.len() != vtab_constraints.len() {
|
||||
return Err(LimboError::ExtensionError(format!(
|
||||
@@ -882,7 +896,7 @@ fn build_vtab_scan_op(
|
||||
if usage.omit {
|
||||
where_clause[constraint.where_clause_pos.0].consumed = true;
|
||||
}
|
||||
let (_, expr) = constraint.get_constraining_expr(where_clause);
|
||||
let (_, expr, _) = constraint.get_constraining_expr(where_clause, referenced_tables);
|
||||
constraints[zero_based_argv_index] = Some(expr);
|
||||
arg_count += 1;
|
||||
}
|
||||
@@ -1306,6 +1320,7 @@ pub fn build_seek_def_from_constraints(
|
||||
constraint_refs: &[RangeConstraintRef],
|
||||
iter_dir: IterationDirection,
|
||||
where_clause: &[WhereTerm],
|
||||
referenced_tables: Option<&TableReferences>,
|
||||
) -> Result<SeekDef> {
|
||||
assert!(
|
||||
!constraint_refs.is_empty(),
|
||||
@@ -1314,7 +1329,7 @@ pub fn build_seek_def_from_constraints(
|
||||
// Extract the key values and operators
|
||||
let key = constraint_refs
|
||||
.iter()
|
||||
.map(|cref| cref.as_seek_range_constraint(constraints, where_clause))
|
||||
.map(|cref| cref.as_seek_range_constraint(constraints, where_clause, referenced_tables))
|
||||
.collect();
|
||||
|
||||
let seek_def = build_seek_def(iter_dir, key)?;
|
||||
@@ -1365,10 +1380,12 @@ fn build_seek_def(
|
||||
start: SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: start_op,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
end: SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: end_op,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1392,46 +1409,52 @@ fn build_seek_def(
|
||||
let start = match last.lower_bound {
|
||||
// Forwards, Asc, GT: (x=10 AND y>20)
|
||||
// Start key: start from the first GT(x:10, y:20)
|
||||
Some((ast::Operator::Greater, bound)) => SeekKey {
|
||||
Some((ast::Operator::Greater, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GT,
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, GE: (x=10 AND y>=20)
|
||||
// Start key: start from the first GE(x:10, y:20)
|
||||
Some((ast::Operator::GreaterEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::GreaterEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, None, (x=10 AND y<30)
|
||||
// Start key: start from the first GE(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
let end = match last.upper_bound {
|
||||
// Forwards, Asc, LT, (x=10 AND y<30)
|
||||
// End key: end at first GE(x:10, y:30)
|
||||
Some((ast::Operator::Less, bound)) => SeekKey {
|
||||
Some((ast::Operator::Less, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, LE, (x=10 AND y<=30)
|
||||
// End key: end at first GT(x:10, y:30)
|
||||
Some((ast::Operator::LessEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::LessEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GT,
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, None, (x=10 AND y>20)
|
||||
// End key: end at first GT(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::GT,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
@@ -1441,46 +1464,52 @@ fn build_seek_def(
|
||||
let start = match last.upper_bound {
|
||||
// Forwards, Desc, LT: (x=10 AND y<30)
|
||||
// Start key: start from the first GT(x:10, y:30)
|
||||
Some((ast::Operator::Less, bound)) => SeekKey {
|
||||
Some((ast::Operator::Less, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GT,
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Desc, LE: (x=10 AND y<=30)
|
||||
// Start key: start from the first GE(x:10, y:30)
|
||||
Some((ast::Operator::LessEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::LessEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Desc, None: (x=10 AND y>20)
|
||||
// Start key: start from the first GE(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
let end = match last.lower_bound {
|
||||
// Forwards, Asc, GT, (x=10 AND y>20)
|
||||
// End key: end at first GE(x:10, y:20)
|
||||
Some((ast::Operator::Greater, bound)) => SeekKey {
|
||||
Some((ast::Operator::Greater, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, GE, (x=10 AND y>=20)
|
||||
// End key: end at first GT(x:10, y:20)
|
||||
Some((ast::Operator::GreaterEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::GreaterEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::GT,
|
||||
affinity,
|
||||
},
|
||||
// Forwards, Asc, None, (x=10 AND y<30)
|
||||
// End key: end at first GT(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::GT,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
@@ -1500,46 +1529,52 @@ fn build_seek_def(
|
||||
let start = match last.upper_bound {
|
||||
// Backwards, Asc, LT: (x=10 AND y<30)
|
||||
// Start key: start from the first LT(x:10, y:30)
|
||||
Some((ast::Operator::Less, bound)) => SeekKey {
|
||||
Some((ast::Operator::Less, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LT,
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Asc, LT: (x=10 AND y<=30)
|
||||
// Start key: start from the first LE(x:10, y:30)
|
||||
Some((ast::Operator::LessEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::LessEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Asc, None: (x=10 AND y>20)
|
||||
// Start key: start from the first LE(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op)
|
||||
}
|
||||
};
|
||||
let end = match last.lower_bound {
|
||||
// Backwards, Asc, GT, (x=10 AND y>20)
|
||||
// End key: end at first LE(x:10, y:20)
|
||||
Some((ast::Operator::Greater, bound)) => SeekKey {
|
||||
Some((ast::Operator::Greater, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Asc, GT, (x=10 AND y>=20)
|
||||
// End key: end at first LT(x:10, y:20)
|
||||
Some((ast::Operator::GreaterEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::GreaterEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LT,
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Asc, None, (x=10 AND y<30)
|
||||
// End key: end at first LT(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::LT,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
@@ -1549,46 +1584,52 @@ fn build_seek_def(
|
||||
let start = match last.lower_bound {
|
||||
// Backwards, Desc, LT: (x=10 AND y>20)
|
||||
// Start key: start from the first LT(x:10, y:20)
|
||||
Some((ast::Operator::Greater, bound)) => SeekKey {
|
||||
Some((ast::Operator::Greater, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LT,
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Desc, LE: (x=10 AND y>=20)
|
||||
// Start key: start from the first LE(x:10, y:20)
|
||||
Some((ast::Operator::GreaterEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::GreaterEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Desc, LE: (x=10 AND y<30)
|
||||
// Start key: start from the first LE(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
let end = match last.upper_bound {
|
||||
// Backwards, Desc, LT, (x=10 AND y<30)
|
||||
// End key: end at first LE(x:10, y:30)
|
||||
Some((ast::Operator::Less, bound)) => SeekKey {
|
||||
Some((ast::Operator::Less, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LE { eq_only: false },
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Desc, LT, (x=10 AND y<=30)
|
||||
// End key: end at first LT(x:10, y:30)
|
||||
Some((ast::Operator::LessEquals, bound)) => SeekKey {
|
||||
Some((ast::Operator::LessEquals, bound, affinity)) => SeekKey {
|
||||
last_component: SeekKeyComponent::Expr(bound),
|
||||
op: SeekOp::LT,
|
||||
affinity,
|
||||
},
|
||||
// Backwards, Desc, LT, (x=10 AND y>20)
|
||||
// End key: end at first LT(x:10)
|
||||
None => SeekKey {
|
||||
last_component: SeekKeyComponent::None,
|
||||
op: SeekOp::LT,
|
||||
affinity: Affinity::Blob,
|
||||
},
|
||||
Some((op, _)) => {
|
||||
Some((op, _, _)) => {
|
||||
crate::bail_parse_error!("build_seek_def: invalid operator: {:?}", op,)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{cmp::Ordering, collections::HashMap, sync::Arc};
|
||||
use std::{cmp::Ordering, collections::HashMap, marker::PhantomData, sync::Arc};
|
||||
use turso_parser::ast::{
|
||||
self, FrameBound, FrameClause, FrameExclude, FrameMode, SortOrder, SubqueryType,
|
||||
};
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
optimizer::constraints::SeekRangeConstraint,
|
||||
},
|
||||
vdbe::{
|
||||
affinity::Affinity,
|
||||
builder::{CursorKey, CursorType, ProgramBuilder},
|
||||
insn::{IdxInsertFlags, Insn},
|
||||
BranchOffset, CursorID,
|
||||
@@ -1128,13 +1129,14 @@ pub struct SeekDef {
|
||||
pub iter_dir: IterationDirection,
|
||||
}
|
||||
|
||||
pub struct SeekDefKeyIterator<'a> {
|
||||
pub struct SeekDefKeyIterator<'a, T> {
|
||||
seek_def: &'a SeekDef,
|
||||
seek_key: &'a SeekKey,
|
||||
pos: usize,
|
||||
_t: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for SeekDefKeyIterator<'a> {
|
||||
impl<'a> Iterator for SeekDefKeyIterator<'a, SeekKeyComponent<&'a ast::Expr>> {
|
||||
type Item = SeekKeyComponent<&'a ast::Expr>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
@@ -1155,6 +1157,25 @@ impl<'a> Iterator for SeekDefKeyIterator<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for SeekDefKeyIterator<'a, Affinity> {
|
||||
type Item = Affinity;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let result = if self.pos < self.seek_def.prefix.len() {
|
||||
Some(self.seek_def.prefix[self.pos].eq.as_ref().unwrap().2)
|
||||
} else if self.pos == self.seek_def.prefix.len() {
|
||||
match &self.seek_key.last_component {
|
||||
SeekKeyComponent::Expr(..) => Some(self.seek_key.affinity),
|
||||
SeekKeyComponent::None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.pos += 1;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl SeekDef {
|
||||
/// returns amount of values in the given seek key
|
||||
/// - so, for SELECT * FROM t WHERE x = 10 AND y = 20 AND y >= 30 there will be 3 values (10, 20, 30)
|
||||
@@ -1166,11 +1187,25 @@ impl SeekDef {
|
||||
}
|
||||
}
|
||||
/// iterate over value expressions in the given seek key
|
||||
pub fn iter<'a>(&'a self, key: &'a SeekKey) -> SeekDefKeyIterator<'a> {
|
||||
pub fn iter<'a>(
|
||||
&'a self,
|
||||
key: &'a SeekKey,
|
||||
) -> SeekDefKeyIterator<'a, SeekKeyComponent<&'a ast::Expr>> {
|
||||
SeekDefKeyIterator {
|
||||
seek_def: self,
|
||||
seek_key: key,
|
||||
pos: 0,
|
||||
_t: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
/// iterate over affinity in the given seek key
|
||||
pub fn iter_affinity<'a>(&'a self, key: &'a SeekKey) -> SeekDefKeyIterator<'a, Affinity> {
|
||||
SeekDefKeyIterator {
|
||||
seek_def: self,
|
||||
seek_key: key,
|
||||
pos: 0,
|
||||
_t: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1196,6 +1231,9 @@ pub struct SeekKey {
|
||||
|
||||
/// The comparison operator to use when seeking.
|
||||
pub op: SeekOp,
|
||||
|
||||
/// Affinity of the comparison
|
||||
pub affinity: Affinity,
|
||||
}
|
||||
|
||||
/// Represents the type of table scan performed during query execution.
|
||||
|
||||
@@ -1226,4 +1226,246 @@ do_execsql_test_on_specific_db {:memory:} collate-compound-15 {
|
||||
SELECT 'test' COLLATE NOCASE
|
||||
INTERSECT
|
||||
SELECT 'TEST' COLLATE NOCASE;
|
||||
} {test}
|
||||
} {test}
|
||||
|
||||
# Queries that use an idx and have to do an affinity conversion
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-1 {
|
||||
CREATE TABLE t(a TEXT);
|
||||
INSERT INTO t VALUES ('10'), ('2'), ('02'), ('2a');
|
||||
CREATE INDEX idx ON t(a);
|
||||
SELECT * FROM t WHERE a >= 2;
|
||||
} {2
|
||||
2a}
|
||||
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-2 {
|
||||
CREATE TABLE t(a TEXT);
|
||||
INSERT INTO t VALUES ('10'), ('2'), ('02'), ('2a');
|
||||
CREATE INDEX idx ON t(a);
|
||||
SELECT * FROM t WHERE a == 2;
|
||||
} {2}
|
||||
|
||||
# Test suite for SQLite affinity conversion in WHERE clauses
|
||||
|
||||
# ============================================
|
||||
# TEST 1: TEXT column with INTEGER value
|
||||
# Should emit OP_Affinity to convert 2 → '2'
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-4 {
|
||||
CREATE TABLE t1(a TEXT);
|
||||
INSERT INTO t1 VALUES ('10'), ('2'), ('02'), ('2a');
|
||||
CREATE INDEX idx1 ON t1(a);
|
||||
SELECT * FROM t1 WHERE a >= 2;
|
||||
} {2 2a}
|
||||
|
||||
# ============================================
|
||||
# TEST 2: TEXT column with INTEGER equality
|
||||
# Should emit OP_Affinity for equality comparison
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-5 {
|
||||
CREATE TABLE t2(name TEXT);
|
||||
INSERT INTO t2 VALUES ('100'), ('20'), ('abc'), ('2');
|
||||
CREATE INDEX idx2 ON t2(name);
|
||||
SELECT * FROM t2 WHERE name = 100;
|
||||
} {100}
|
||||
|
||||
# ============================================
|
||||
# TEST 3: INTEGER column with convertible string
|
||||
# Should emit OP_Affinity with INTEGER affinity
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-6 {
|
||||
CREATE TABLE t3(value INTEGER);
|
||||
INSERT INTO t3 VALUES (100), (20), (5), (200);
|
||||
CREATE INDEX idx3 ON t3(value);
|
||||
SELECT * FROM t3 WHERE value >= '100';
|
||||
} {100 200}
|
||||
|
||||
# ============================================
|
||||
# TEST 4: INTEGER column with non-convertible string
|
||||
# String 'abc' cannot convert to integer
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-7 {
|
||||
CREATE TABLE t4(value INTEGER);
|
||||
INSERT INTO t4 VALUES (100), (20), (5);
|
||||
CREATE INDEX idx4 ON t4(value);
|
||||
SELECT * FROM t4 WHERE value >= 'abc';
|
||||
} {}
|
||||
|
||||
# ============================================
|
||||
# TEST 5: NUMERIC column with integer
|
||||
# Should emit OP_Affinity with NUMERIC affinity
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-8 {
|
||||
CREATE TABLE t5(score NUMERIC);
|
||||
INSERT INTO t5 VALUES (100), (20.5), ('30'), (45);
|
||||
CREATE INDEX idx5 ON t5(score);
|
||||
SELECT * FROM t5 WHERE score >= 50;
|
||||
} {100}
|
||||
|
||||
# ============================================
|
||||
# TEST 6: REAL column with integer
|
||||
# Should emit OP_Affinity to convert to REAL
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-9 {
|
||||
CREATE TABLE t6(price REAL);
|
||||
INSERT INTO t6 VALUES (99.99), (19.99), (50.00), (25.50);
|
||||
CREATE INDEX idx6 ON t6(price);
|
||||
SELECT * FROM t6 WHERE price < 50;
|
||||
} {19.99 25.5}
|
||||
|
||||
# ============================================
|
||||
# TEST 7: TEXT column with REAL value
|
||||
# Should emit OP_Affinity to convert 20.5 → '20.5'
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-10 {
|
||||
CREATE TABLE t7(name TEXT);
|
||||
INSERT INTO t7 VALUES ('100'), ('20.5'), ('abc'), ('30');
|
||||
CREATE INDEX idx7 ON t7(name);
|
||||
SELECT * FROM t7 WHERE name = 20.5;
|
||||
} {20.5}
|
||||
|
||||
# TODO: Program does not emit correct opcodes to handle this IN query yet
|
||||
# ============================================
|
||||
# TEST 8: TEXT column with IN clause
|
||||
# Should emit OP_Affinity for batch conversion
|
||||
# ============================================
|
||||
# do_execsql_test_on_specific_db {:memory:} affinity-conversion-11 {
|
||||
# CREATE TABLE t8(name TEXT);
|
||||
# INSERT INTO t8 VALUES ('1'), ('2'), ('3'), ('4'), ('abc');
|
||||
# CREATE INDEX idx8 ON t8(name);
|
||||
# SELECT * FROM t8 WHERE name IN (1, 2, 3);
|
||||
#} {1 2 3}
|
||||
|
||||
# ============================================
|
||||
# TEST 9: Compound index with mixed types
|
||||
# Should emit OP_Affinity with multi-char affinity string
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-12 {
|
||||
CREATE TABLE t9(a TEXT, b INTEGER, c NUMERIC);
|
||||
INSERT INTO t9 VALUES ('100', 200, 300);
|
||||
INSERT INTO t9 VALUES ('50', 100, 150);
|
||||
INSERT INTO t9 VALUES ('200', 300, 400);
|
||||
CREATE INDEX idx9 ON t9(a, b, c);
|
||||
SELECT * FROM t9 WHERE a = 100 AND b = '200' AND c >= 300;
|
||||
} {100|200|300}
|
||||
|
||||
# ============================================
|
||||
# TEST 10: INTEGER PRIMARY KEY range (NO affinity)
|
||||
# Contrast: should NOT emit OP_Affinity
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-13 {
|
||||
CREATE TABLE t10(x INTEGER PRIMARY KEY);
|
||||
INSERT INTO t10 VALUES (1), (2), (100), (200);
|
||||
SELECT * FROM t10 WHERE x < '100';
|
||||
} {1 2}
|
||||
|
||||
# TODO: INDEXED BY not supported yet
|
||||
# ============================================
|
||||
# TEST 11: Same query but forcing index usage
|
||||
# Should emit OP_Affinity (takes indexed path)
|
||||
# ============================================
|
||||
# do_execsql_test_on_specific_db {:memory:} affinity-conversion-14 {
|
||||
# CREATE TABLE t11(x INTEGER PRIMARY KEY);
|
||||
# INSERT INTO t11 VALUES (1), (2), (100), (200);
|
||||
# CREATE INDEX idx11 ON t11(x);
|
||||
# SELECT * FROM t11 INDEXED BY idx11 WHERE x < '100';
|
||||
# } {1 2}
|
||||
|
||||
# ============================================
|
||||
# TEST 12: TEXT column with string that looks numeric
|
||||
# Should apply TEXT affinity and use lexicographic order
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-15 {
|
||||
CREATE TABLE t12(name TEXT);
|
||||
INSERT INTO t12 VALUES ('1'), ('10'), ('2'), ('20');
|
||||
CREATE INDEX idx12 ON t12(name);
|
||||
SELECT * FROM t12 WHERE name >= '2' ORDER BY name;
|
||||
} {2 20}
|
||||
|
||||
# ============================================
|
||||
# TEST 13: INTEGER column with float string
|
||||
# Should convert '50.5' to 50 (INTEGER affinity)
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-16 {
|
||||
CREATE TABLE t13(value INTEGER);
|
||||
INSERT INTO t13 VALUES (50), (51), (100);
|
||||
CREATE INDEX idx13 ON t13(value);
|
||||
SELECT * FROM t13 WHERE value >= '50.5';
|
||||
} {51 100}
|
||||
|
||||
# ============================================
|
||||
# TEST 14: NUMERIC with text that converts
|
||||
# Should apply NUMERIC affinity
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-17 {
|
||||
CREATE TABLE t14(score NUMERIC);
|
||||
INSERT INTO t14 VALUES (10), (20), (30), (40);
|
||||
CREATE INDEX idx14 ON t14(score);
|
||||
SELECT * FROM t14 WHERE score BETWEEN '15' AND '35';
|
||||
} {20 30}
|
||||
|
||||
# ============================================
|
||||
# TEST 15: Multiple columns, only one needs conversion
|
||||
# Should emit affinity string with mixed affinities
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-18 {
|
||||
CREATE TABLE t15(a INTEGER, b TEXT);
|
||||
INSERT INTO t15 VALUES (1, '100'), (2, '200'), (3, '300');
|
||||
CREATE INDEX idx15 ON t15(a, b);
|
||||
SELECT * FROM t15 WHERE a = '2' AND b = 200;
|
||||
} {2|200}
|
||||
|
||||
# ============================================
|
||||
# TEST 16: BLOB column (should not convert)
|
||||
# BLOB affinity doesn't perform conversions
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-19 {
|
||||
CREATE TABLE t16(data BLOB);
|
||||
INSERT INTO t16 VALUES (X'48656c6c6f'), (X'576f726c64');
|
||||
CREATE INDEX idx16 ON t16(data);
|
||||
SELECT typeof(data) FROM t16 WHERE data >= X'48';
|
||||
} {blob blob}
|
||||
|
||||
# ============================================
|
||||
# TEST 17: Negative numbers with TEXT affinity
|
||||
# Should convert -5 to '-5' for comparison
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-20 {
|
||||
CREATE TABLE t17(name TEXT);
|
||||
INSERT INTO t17 VALUES ('-10'), ('-5'), ('0'), ('5');
|
||||
CREATE INDEX idx17 ON t17(name);
|
||||
SELECT * FROM t17 WHERE name >= -5;
|
||||
} {-5 0 5}
|
||||
|
||||
# ============================================
|
||||
# TEST 18: Zero with different types
|
||||
# Tests affinity with special value zero
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-21 {
|
||||
CREATE TABLE t18(value NUMERIC);
|
||||
INSERT INTO t18 VALUES (0), (0.0), ('0'), (1), (-1);
|
||||
CREATE INDEX idx18 ON t18(value);
|
||||
SELECT * FROM t18 WHERE value = 0;
|
||||
} {0 0 0}
|
||||
|
||||
# ============================================
|
||||
# TEST 19: Large numbers requiring conversion
|
||||
# Tests affinity (B) with large integer values
|
||||
# ============================================
|
||||
do_execsql_test_on_specific_db {:memory:} affinity-conversion-22 {
|
||||
CREATE TABLE t19(val TEXT);
|
||||
INSERT INTO t19 VALUES ('1000000'), ('999999'), ('1000001');
|
||||
CREATE INDEX idx19 ON t19(val);
|
||||
SELECT * FROM t19 WHERE val = 1000000;
|
||||
} {1000000}
|
||||
|
||||
# TODO: cannot use expressions yet in CREATE INDEX
|
||||
# ============================================
|
||||
# TEST 20: Mixed case with expression index
|
||||
# Expression index with affinity conversion
|
||||
# ============================================
|
||||
# do_execsql_test_on_specific_db {:memory:} affinity-conversion-23 {
|
||||
# CREATE TABLE t20(name TEXT);
|
||||
# INSERT INTO t20 VALUES ('ABC'), ('abc'), ('123');
|
||||
# CREATE INDEX idx20 ON t20(lower(name));
|
||||
# SELECT * FROM t20 WHERE lower(name) = 'abc';
|
||||
# } {ABC abc}
|
||||
|
||||
Reference in New Issue
Block a user