Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ All notable changes to sqlite-rs. Format follows [Keep a Changelog](https://keep
*different*, already-on-disk row's stale entries) are unaffected —
they have no such register run to reuse (#663).

- `UPDATE ... WHERE col >/>=/</<= lit`/`BETWEEN` against a leading-indexed
column fell back to a full `Rewind`/`Next` scan with a per-row
`compile_cond` filter — unlike `SELECT`'s equivalent fast path, it
never used the index at all. `try_compile_range_row_seek` (a
`SELECT`-agnostic variant of the existing range-seek builders) is now
wired into `UPDATE` codegen: a read-only `IdxNext` walk records
matched rowids into an in-memory ephemeral table, then a second pass
replays them against the table cursor to do the actual update (the
index cursor doing the range walk has no save/restore protection
against the same scan's own index-maintenance writes, unlike
`TableCursor`'s snapshotted frames, so the update can't happen inline
during the walk). Measured ~28% faster on `update_filtered_range`
(#666). `DELETE`'s equivalent fast path was prototyped but not
shipped — for a highly selective predicate on a small table, the same
two-pass materialization regressed rather than helped, since it
needs an unavoidable random-order re-seek per matched row; blocked on
cursor save/restore, a separate follow-up.

## [0.18.8] - 2026-08-29

### Fixed
Expand Down
1 change: 1 addition & 0 deletions src/codegen/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ pub(crate) use entry::{
};
pub(crate) use joins::compile_select_joined_scan;
pub(crate) use limit_scan::{is_rowid_reference, top_level_equality_operands};
pub(crate) use range_scan::try_compile_range_row_seek;

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
Expand Down
151 changes: 151 additions & 0 deletions src/codegen/select/range_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,157 @@ where
Ok(true)
}

/// #666: `UPDATE`/`DELETE`'s row-scan equivalent of
/// [`try_compile_forward_comparison_seek`]/[`try_compile_between_seek`],
/// stripped of `SELECT`-only concerns (`DISTINCT`, LIMIT/OFFSET,
/// projection) — recognizes the same two WHERE shapes (`col >/>=/</<=
/// lit` and `col BETWEEN lo AND hi` against a leading-indexed column)
/// and emits the same `SeekIndexGE`/`IdxCompareGT`/`IdxNext` walk, but
/// calls `sink` once per matching *index* entry instead of fetching the
/// full table row itself — `sink` is responsible for turning that into
/// a table-row action (typically `IdxRowid` + `SeekRowid` onto
/// `row_skip` on a miss, then the caller's own per-row body). Returns
/// `Ok(false)` — `em`/`reg` untouched — for any unrecognized shape,
/// exactly like its `SELECT` counterparts.
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_compile_range_row_seek<F>(
em: &mut Emitter,
reg: &mut RegAlloc,
where_expr: &Expr,
schema: &TableSchema,
scope: &Scope,
index_cursor: i32,
end_label: Label,
sink: &mut F,
) -> Result<bool, CodegenError>
where
F: FnMut(&mut Emitter, &mut RegAlloc, i32, Label) -> Result<(), CodegenError>,
{
if let ExprKind::Between {
expr,
lo,
hi,
negated: false,
} = &where_expr.kind
{
let Some(col_name) = where_col(expr) else {
return Ok(false);
};
if !is_supported_operand(lo) || !is_supported_operand(hi) {
return Ok(false);
}
let Some(index_position) = find_leading_index(schema, col_name) else {
return Ok(false);
};
let Some(index) = schema.indexes.get(index_position) else {
return Ok(false);
};
let affinity = column_affinity(schema, col_name);
if !operand_matches_column_affinity(lo, affinity)
|| !operand_matches_column_affinity(hi, affinity)
{
return Ok(false);
}
let leading_collation = index
.columns
.first()
.map_or(Collation::Binary, |c| c.collation);

open_index_cursor(em, index, index_cursor)?;
let lo_reg = compile_value(em, reg, scope, lo)?;
let hi_reg = compile_value(em, reg, scope, hi)?;

let seek_addr = em.emit(Instruction::with_p4(
Opcode::SeekIndexGE,
index_cursor,
0,
lo_reg,
P4::SeekKey(vec![leading_collation]),
));
em.patch_p2(seek_addr, end_label);

let loop_start = em.new_label();
em.place(loop_start);

let stop_addr = em.emit(Instruction::with_p4(
Opcode::IdxCompareGT,
index_cursor,
0,
hi_reg,
P4::SeekKey(vec![leading_collation]),
));
em.patch_p2(stop_addr, end_label);

let row_skip = em.new_label();
sink(em, reg, index_cursor, row_skip)?;
em.place(row_skip);
let next_addr = em.emit(Instruction::new(Opcode::IdxNext, index_cursor, 0, 0));
em.patch_p2(next_addr, loop_start);
return Ok(true);
}

let Some((col_name, operand, inclusive)) = as_forward_comparison(where_expr) else {
return Ok(false);
};
if !is_supported_operand(operand) {
return Ok(false);
}
let Some(index_position) = find_leading_index(schema, col_name) else {
return Ok(false);
};
let Some(index) = schema.indexes.get(index_position) else {
return Ok(false);
};
let affinity = column_affinity(schema, col_name);
if !operand_matches_column_affinity(operand, affinity) {
return Ok(false);
}
let leading_collation = index
.columns
.first()
.map_or(Collation::Binary, |c| c.collation);

open_index_cursor(em, index, index_cursor)?;
let bound_reg = compile_value(em, reg, scope, operand)?;

let seek_addr = em.emit(Instruction::with_p4(
Opcode::SeekIndexGE,
index_cursor,
0,
bound_reg,
P4::SeekKey(vec![leading_collation]),
));
em.patch_p2(seek_addr, end_label);

if !inclusive {
let skip_start = em.new_label();
em.place(skip_start);
let past_bound = em.new_label();
let gt_addr = em.emit(Instruction::with_p4(
Opcode::IdxCompareGT,
index_cursor,
0,
bound_reg,
P4::SeekKey(vec![leading_collation]),
));
em.patch_p2(gt_addr, past_bound);
let skip_next_addr = em.emit(Instruction::new(Opcode::IdxNext, index_cursor, 0, 0));
em.patch_p2(skip_next_addr, skip_start);
let exhausted_addr = em.emit(Instruction::new(Opcode::Goto, 0, 0, 0));
em.patch_p2(exhausted_addr, end_label);
em.place(past_bound);
}

let loop_start = em.new_label();
em.place(loop_start);
let row_skip = em.new_label();
sink(em, reg, index_cursor, row_skip)?;
em.place(row_skip);
let next_addr = em.emit(Instruction::new(Opcode::IdxNext, index_cursor, 0, 0));
em.patch_p2(next_addr, loop_start);
Ok(true)
}

/// The maximum Unicode scalar value, `char::MAX` (U+10FFFF) — see this
/// module's doc comment for why appending it to a literal prefix gives a
/// safe strict upper bound for `LIKE 'prefix%'`/`GLOB 'prefix*'`.
Expand Down
Loading
Loading