From 3fd91637c6d2055d5bc42d2d954a066373db4e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:12:18 +0000 Subject: [PATCH] Show result columns in select-list order Rows are maps, so the TUI and CLI used to put the columns in alphabetical order (the TUI put id first). `select name, count(*), id` came back as count(*), id, name. The planner now records the select list's output columns on the plan (Plan.columns), after removing duplicates. Grouped queries record their output names. Both result views use that order: the TUI table, the TUI row inspector, and the CLI's Owl table through its sort_columns option. A selected field missing from a row shows as null in the CLI instead of dropping its column. `select *` has no select list, so it keeps the old order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko --- lib/efsql/cli.ex | 29 +++++++++++++++++++++-------- lib/efsql/physical.ex | 6 +++++- lib/efsql/planner.ex | 10 ++++++++-- lib/efsql/tui/app.ex | 24 +++++++++++++++++++----- lib/efsql/tui/view.ex | 2 +- test/planner_test.exs | 12 ++++++++++++ test/tui_app_test.exs | 28 ++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 17 deletions(-) diff --git a/lib/efsql/cli.ex b/lib/efsql/cli.ex index 3c25577..a3495b4 100644 --- a/lib/efsql/cli.ex +++ b/lib/efsql/cli.ex @@ -162,7 +162,7 @@ defmodule Efsql.Cli do {call, rows, tenants} = Efsql.qall(sql, [], state.tenants) if state.debug, do: print_debug(call) - print_table(rows, display_limit) + print_table(rows, display_limit, call.columns) {tenants} rescue e -> @@ -221,29 +221,35 @@ defmodule Efsql.Cli do end end - defp print_table([], _limit) do + defp print_table([], _limit, _columns) do Owl.IO.puts(Owl.Data.tag("(0 rows)", :light_black)) end - defp print_table(rows, :all) do - print_rows(rows, false) + defp print_table(rows, :all, columns) do + print_rows(rows, false, columns) end - defp print_table(rows, limit) do + defp print_table(rows, limit, columns) do {display_rows, more?} = if length(rows) > limit, do: {Enum.take(rows, limit), true}, else: {rows, false} - print_rows(display_rows, more?) + print_rows(display_rows, more?, columns) end - defp print_rows(rows, more?) do + # Columns in select-list order; `select *` (no columns) sorts them. + defp print_rows(rows, more?, columns) do rows |> Enum.map(fn row -> + row = if columns, do: Map.new(columns, &{&1, Map.get(row, &1)}), else: row Map.new(row, fn {k, v} -> {to_string(k), format_value(v)} end) end) - |> Owl.Table.new(border_style: :solid_rounded, padding_x: 1) + |> Owl.Table.new( + border_style: :solid_rounded, + padding_x: 1, + sort_columns: column_sorter(columns) + ) |> Owl.IO.puts() n = length(rows) @@ -251,6 +257,13 @@ defmodule Efsql.Cli do Owl.IO.puts(Owl.Data.tag(label, :light_black)) end + defp column_sorter(nil), do: :asc + + defp column_sorter(columns) do + position = columns |> Enum.map(&to_string/1) |> Enum.with_index() |> Map.new() + &(Map.fetch!(position, &1) <= Map.fetch!(position, &2)) + end + defp format_value(nil), do: Owl.Data.tag("null", :light_black) defp format_value(v) when is_binary(v), do: v diff --git a/lib/efsql/physical.ex b/lib/efsql/physical.ex index 1b71d12..9964c05 100644 --- a/lib/efsql/physical.ex +++ b/lib/efsql/physical.ex @@ -23,9 +23,13 @@ defmodule Efsql.Physical do * `{:sort, [{:asc | :desc, field}]}` — NULLs last ascending, first descending * `{:limit, n}` * `{:project, [field]}` — trim rows to the requested fields + + A plan also carries `columns`, the result columns in select-list order, + so a result table can show them as written; nil for `select *`, whose + fields are whatever the rows hold. """ defmodule Plan do - defstruct access: nil, ops: [] + defstruct access: nil, ops: [], columns: nil end end diff --git a/lib/efsql/planner.ex b/lib/efsql/planner.ex index 8777ef7..6c1c2e0 100644 --- a/lib/efsql/planner.ex +++ b/lib/efsql/planner.ex @@ -61,10 +61,16 @@ defmodule Efsql.Planner do {:project, logical.projection} ) - %Plan{plan | ops: plan.ops ++ ops} + %Plan{plan | ops: plan.ops ++ ops, columns: columns(logical.projection)} end - def plan(%Logical.Select{} = logical, options) do + def plan(%Logical.Select{} = logical, options), + do: %Plan{plan_rows(logical, options) | columns: columns(logical.projection)} + + defp columns(:star), do: nil + defp columns(fields), do: Enum.uniq(fields) + + defp plan_rows(%Logical.Select{} = logical, options) do %Logical.Select{predicates: preds, order: sort, projection: projection} = logical star? = projection == :star {pks, ins, pushables, residuals} = classify(preds) diff --git a/lib/efsql/tui/app.ex b/lib/efsql/tui/app.ex index 60accdf..28447d0 100644 --- a/lib/efsql/tui/app.ex +++ b/lib/efsql/tui/app.ex @@ -691,9 +691,19 @@ defmodule Efsql.Tui.App do defp ivalue_key(%Model{ifield_cursor: ix, size: {_rows, cols}}), do: {ix, cols} + @doc """ + The inspected row's fields: in select-list order when the query has one, + else sorted. + """ + def inspector_fields(%Model{irow: row, plan: %Efsql.Physical.Plan{columns: [_ | _] = columns}}) do + listed = Enum.filter(columns, &Map.has_key?(row, &1)) + listed ++ Enum.sort(Map.keys(row) -- listed) + end + + def inspector_fields(%Model{irow: row}), do: row |> Map.keys() |> Enum.sort() + defp ivalue_lines(%Model{irow: row, size: {_rows, cols}} = model) do - fields = row |> Map.keys() |> Enum.sort() - selected = Enum.at(fields, model.ifield_cursor) + selected = Enum.at(inspector_fields(model), model.ifield_cursor) row |> Map.get(selected) @@ -751,7 +761,7 @@ defmodule Efsql.Tui.App do end defp done(model, :query, {:ok, {plan, rows, tenants, elapsed}}) do - columns = columns(rows) + columns = columns(plan, rows) cells = render_cells(rows, columns) model = %{ @@ -775,9 +785,13 @@ defmodule Efsql.Tui.App do defp done(model, _tag, _result), do: {%{model | busy: nil}, []} - defp columns([]), do: [] + defp columns(_plan, []), do: [] + + # In select-list order; `select *` has no list, so its fields are sorted + # with id first. + defp columns(%Efsql.Physical.Plan{columns: [_ | _] = columns}, _rows), do: columns - defp columns(rows) do + defp columns(_plan, rows) do keys = rows |> Enum.flat_map(&Map.keys/1) |> Enum.uniq() |> Enum.sort() if :id in keys, do: [:id | List.delete(keys, :id)], else: keys end diff --git a/lib/efsql/tui/view.ex b/lib/efsql/tui/view.ex index 9a8ba0f..6f64923 100644 --- a/lib/efsql/tui/view.ex +++ b/lib/efsql/tui/view.ex @@ -365,7 +365,7 @@ defmodule Efsql.Tui.View do # -- inspector -- defp inspector(%Model{irow: row} = model, height, cols) do - fields = row |> Map.keys() |> Enum.sort() + fields = App.inspector_fields(model) selected = Enum.at(fields, model.ifield_cursor) list = diff --git a/test/planner_test.exs b/test/planner_test.exs index e5e7913..60b3bb5 100644 --- a/test/planner_test.exs +++ b/test/planner_test.exs @@ -117,4 +117,16 @@ defmodule EfsqlTest.Integration.Planner do assert {:index_scan, %Ecto.Query{wheres: [%{expr: {:==, [], [_, "Alice"]}}]}, _opts} = plan.access end + + test "the plan carries the select list's column order", context do + tenant_id = context[:tenant_id] + + assert plan("select notes, name, id, name from #{tenant_id}.users;").columns == + [:notes, :name, :id] + + assert plan("select * from #{tenant_id}.users;").columns == nil + + grouped = "select count(*) as n, notes from #{tenant_id}.users group by notes order by n;" + assert plan(grouped).columns == [:n, :notes] + end end diff --git a/test/tui_app_test.exs b/test/tui_app_test.exs index a431422..168dc33 100644 --- a/test/tui_app_test.exs +++ b/test/tui_app_test.exs @@ -174,6 +174,34 @@ defmodule Efsql.Tui.AppTest do assert model.mode == :query end + test "result columns follow the select list" do + model = %{activated() | mode: :query} + rows = [%{id: "0001", name: "Alice", "count(*)": 3}] + + plan = %Efsql.Physical.Plan{ + access: {:pk_range, nil, nil, nil, []}, + ops: [], + columns: [:name, :"count(*)", :id] + } + + {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + assert model.columns == [:name, :"count(*)", :id] + assert frame_text(model) =~ ~r/name\s+count\(\*\)\s+id/ + + # the inspector lists fields in the same order + {model, _} = feed(model, [{:key, :tab}, {:key, :enter}]) + assert App.inspector_fields(model) == [:name, :"count(*)", :id] + end + + test "select * has no select list, so id comes first and the rest sort" do + model = %{activated() | mode: :query} + rows = [%{name: "Alice", id: "0001", age: 30}] + + plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} + {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + assert model.columns == [:id, :age, :name] + end + test "uuid primary keys are not truncated in results" do model = %{activated() | mode: :query} uuid = "00ab2aa8-2bba-4102-bf8c-ced5d3142f8a"