Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ All notable changes to sqlite-rs. Format follows [Keep a Changelog](https://keep
gives them, with no new opcode or synthesized rows (ADR-0046,
supersedes ADR-0029's problem statement for this one table) (#707).

- `.headers on`'s column labels for a join or a compound `SELECT`
fell back to positional `column1`/`column2` placeholders instead of
deriving real names. A joined `FROM` now expands `*`/`table.*`
against each source table in order (`output_column_names_joined`),
and a compound takes its names from the leftmost arm — matching
what `.headers on` prints in stock `sqlite3` for a two/three-table
join, `UNION`/`UNION ALL`, a subquery in `FROM`, an aliased or
table-qualified reference, and duplicate names across a join (#709).

## [0.18.10] - 2026-08-31

### Fixed
Expand Down
56 changes: 41 additions & 15 deletions src/bin/sqlite-rs/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ use std::rc::Rc;

use sqlite_rs::btree::TableCursor;
use sqlite_rs::codegen::{
compile_statement, leading_keywords, output_column_names, resolve_from_table_schema,
compile_statement, leading_keywords, output_column_names, output_column_names_joined,
resolve_from_table_schema,
};
use sqlite_rs::dump;
use sqlite_rs::parser::{ends_with_semicolon, parse_select, split_statements, ParseOutcome};
Expand Down Expand Up @@ -398,30 +399,55 @@ fn run_one_statement(
}
}

/// `.headers on`'s column labels for `select`'s result set: for a
/// single-table, non-compound `SELECT` this is
/// `.headers on`'s column labels for `select`'s result set:
/// [`output_column_names`]'s "alias, else bare column name, else
/// `columnN`" rule against the resolved `FROM` table; anything the
/// codegen pipeline resolves less directly (no `FROM`, a join, or a
/// compound) falls back to positional `column1..columnN` labels — a
/// scope-cut noted in the issue's write-up rather than plumbing this
/// REPL's header derivation through the full join/compound resolver.
/// `columnN`" rule, applied against whichever table(s) `select`'s own
/// `FROM` clause resolves to — a single table, or (#709)
/// [`output_column_names_joined`] against every joined table in order
/// for `*`/`table.*` expansion. A compound `SELECT` takes its names
/// from the leftmost arm (`select` itself, never `select.compound`),
/// matching the oracle and the same rule `compile_select_compound`
/// already uses internally to resolve a compound's own trailing
/// `ORDER BY`. A `FROM`-less `select` (or one whose `FROM` doesn't
/// resolve — dead code today, `run_query`/the REPL would already have
/// rejected an unresolvable table before headers are ever derived)
/// falls back to positional `column1..columnN` labels.
fn derive_headers(
select: &sqlite_rs::parser::ast::Select,
schemas: &[sqlite_rs::schema::TableSchema],
) -> Vec<String> {
let single_table = select.compound.is_empty()
&& select
.from
.as_ref()
.is_some_and(|from| from.joins.is_empty());
if single_table {
if let Some(from) = &select.from {
if let Some(from) = &select.from {
if from.joins.is_empty() {
if let Ok(schema) = resolve_from_table_schema(&from.first, schemas) {
return output_column_names(select, &schema);
}
} else if let Ok(tables) = joined_tables(from, schemas) {
return output_column_names_joined(select, &tables);
}
}
let count = select.columns.len().max(1);
(1..=count).map(|i| format!("column{i}")).collect()
}

/// Resolves every table in `from` (leftmost plus each `JOIN`) to its
/// `(alias, schema)` pair, in `FROM`-clause order — the shape
/// [`output_column_names_joined`] needs for `table.*` qualifier
/// matching.
fn joined_tables(
from: &sqlite_rs::parser::ast::FromClause,
schemas: &[sqlite_rs::schema::TableSchema],
) -> Result<Vec<(Option<String>, sqlite_rs::schema::TableSchema)>, sqlite_rs::codegen::CodegenError>
{
let mut out = Vec::with_capacity(from.joins.len().saturating_add(1));
out.push((
from.first.alias.clone(),
resolve_from_table_schema(&from.first, schemas)?,
));
for join in &from.joins {
out.push((
join.table.alias.clone(),
resolve_from_table_schema(&join.table, schemas)?,
));
}
Ok(out)
}
4 changes: 2 additions & 2 deletions src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ pub use dispatch::{compile_statement, leading_keywords, DispatchError};
pub use pragma::compile_pragma;
pub use select::{
compile_select, compile_select_compound, compile_select_joined, compile_select_with_catalog,
compile_select_with_catalog_and_stats, explain_query_plan, output_column_names, CodegenError,
EqpRow,
compile_select_with_catalog_and_stats, explain_query_plan, output_column_names,
output_column_names_joined, CodegenError, EqpRow,
};
pub use stmt::delete::{compile_delete, compile_delete_with_catalog};
pub use stmt::insert::compile_insert;
Expand Down
2 changes: 1 addition & 1 deletion src/codegen/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub use entry::{
};
pub use eqp::{explain_query_plan, EqpRow};
pub use joins::compile_select_joined;
pub use order_by::output_column_names;
pub use order_by::{output_column_names, output_column_names_joined};

pub(crate) use aggregate::{
compile_grouped_scan, select_has_aggregate, try_compile_index_only_count,
Expand Down
54 changes: 54 additions & 0 deletions src/codegen/select/order_by.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,60 @@ pub fn output_column_names(select: &Select, schema: &TableSchema) -> Vec<String>
.collect()
}

/// #709: like [`output_column_names`], but for a `FROM` clause with
/// joins, where a `*`/`table.*` expansion must draw columns from the
/// right per-table schema instead of a single one. `tables` is
/// `(alias, schema)` pairs in the same order the joined tables appear
/// in the `FROM` clause (leftmost first); `table.*`/`table.col`
/// qualifier matching uses alias-or-name, mirroring
/// `TableBinding::matches_qualifier`.
///
/// A bare column reference's name doesn't depend on which table it
/// came from (`ExprKind::Column`'s `name` is already
/// qualifier-independent — `SELECT a.x` carries `name: "x"` same as an
/// unqualified `x`), so an ordinary `ResultColumn::Expr` needs no
/// per-table lookup at all; only `Star`/`TableStar` expansion does.
/// Same "alias, else bare column name, else `columnN`" fallback order
/// as the single-table rule, and the same known gap: an unaliased
/// *computed* expression (`a + 1`) falls back to positional `columnN`
/// here rather than the oracle's own expression-text rendering — no
/// expression-to-SQL-text printer exists in this crate yet, so that
/// part of SQLite's rule is unimplemented for both the single-table
/// and joined paths alike, not a regression introduced here.
pub fn output_column_names_joined(
select: &Select,
tables: &[(Option<String>, TableSchema)],
) -> Vec<String> {
let mut out = Vec::new();
for col in &select.columns {
match col {
ResultColumn::Star => {
for (_, schema) in tables {
out.extend(schema.columns.iter().cloned());
}
}
ResultColumn::TableStar { table } => {
if let Some((_, schema)) = tables.iter().find(|(alias, schema)| {
alias
.as_deref()
.map(|a| a.eq_ignore_ascii_case(table))
.unwrap_or_else(|| schema.name.eq_ignore_ascii_case(table))
}) {
out.extend(schema.columns.iter().cloned());
}
}
ResultColumn::Expr { expr, alias } => {
let name = alias.clone().unwrap_or_else(|| match &expr.kind {
ExprKind::Column { name, .. } => name.clone(),
_ => format!("column{}", out.len().saturating_add(1)),
});
out.push(name);
}
}
}
out
}

pub(super) fn order_by_entries(select: &Select, schema: &TableSchema) -> Vec<OrderByEntry> {
let mut out = Vec::new();
for col in &select.columns {
Expand Down
1 change: 1 addition & 0 deletions tests/corpus/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ mod partial_sort_test;
mod plan_parity_test;
mod regen_test;
mod repl_test;
mod result_column_names_test;
mod schema_test;
mod skip_scan_test;
mod sql_corpus_test;
Expand Down
Loading