From 243b10c9e72e8500310eccb9f928e782549c7bab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:10:43 +0000 Subject: [PATCH 1/3] Simplify: unify tenant opening, settings, fetching and result columns A pass over the recent work for duplication and dead code. - Dead code: Efsql.hello/0, Efsql.stream/1, sql_to_logical/2 and Planner.to_ecto_query/1 had no callers. resolve_tenant/2 is private. - Tenant opening: Efsql.open_tenant/4 is now the one read-only opener, used by queries, the fan-out and the TUI navigator (which had its own copy). It starts a non-default storage id's tenant cache and, with check_exists, checks existence in that storage id. The CLI's storage_id.tenant.table path used to check the default storage id and never started the cache. - Efsql.Settings: one module for \set (limit, tenant_batch): parsing, validation, messages, help and query options. The CLI, the TUI and the TUI session each had their own. - Executor: every access node goes through one start-then-await path (Repo.all_range and friends are await(async_*) anyway), a NULL field failing a comparison, LIKE or IN is one helper, filter uses matches?/2, and sort/2 is public. - Aggregate orders its groups with Executor.sort/2 instead of its own NULL-aware comparator. Its equality key moves to Types.equality_key/1, next to the compare/2 it mirrors. - Render.columns/2 is the result column order for both the TUI and the CLI. For select *, the CLI now also puts id first. - Planner.indexes/2 is the one index-metadata read (Discover used its own), and Logical.take_first/2 replaces the matching helpers in Planner and Rewrite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko --- lib/efsql.ex | 73 +++++++++++++++++----------------------- lib/efsql/aggregate.ex | 35 ++----------------- lib/efsql/cli.ex | 58 ++++++++++++------------------- lib/efsql/discover.ex | 4 +-- lib/efsql/executor.ex | 68 +++++++++++++------------------------ lib/efsql/logical.ex | 11 ++++++ lib/efsql/planner.ex | 33 ++++-------------- lib/efsql/render.ex | 13 +++++++ lib/efsql/rewrite.ex | 13 +++---- lib/efsql/settings.ex | 60 +++++++++++++++++++++++++++++++++ lib/efsql/tui/app.ex | 71 +++++++------------------------------- lib/efsql/tui/session.ex | 2 +- lib/efsql/types.ex | 11 ++++++ test/render_test.exs | 16 +++++++++ test/rewrite_test.exs | 10 ++++++ test/settings_test.exs | 47 ++++++++++++++++++++++++++ test/tui_app_test.exs | 6 ++-- test/types_test.exs | 17 ++++++++++ 18 files changed, 291 insertions(+), 257 deletions(-) create mode 100644 lib/efsql/settings.ex create mode 100644 test/settings_test.exs diff --git a/lib/efsql.ex b/lib/efsql.ex index 109ca7e..f4da315 100644 --- a/lib/efsql.ex +++ b/lib/efsql.ex @@ -5,25 +5,15 @@ defmodule Efsql do A statement flows through the textbook pipeline: SQL text - |> Efsql.Parser.to_logical() # parse tree -> Efsql.Logical.Select + |> Efsql.Parser.sql_to_logical() # parse tree -> Efsql.Logical.Select |> resolve tenant |> Efsql.Rewrite.normalize() # rewrite passes |> Efsql.Planner.plan() # access-path selection -> Efsql.Physical.Plan |> Efsql.Executor.run() # adapter pull + operator pipeline - """ - - import Ecto.Query - - def hello() do - tenant = EctoFoundationDB.Tenant.open!(Efsql.Repo, "localhost") - - query = from(s in "secrets", select: [id: s.id, iv: s.iv]) - r1 = Efsql.Repo.all(query, prefix: tenant) - - r2 = all("select id, iv from localhost.secrets;") - {r1, r2} - end + A query across tenants (`*.table`) is planned by `Efsql.Fanout` instead, + which plans each tenant's read with `Efsql.Planner`. + """ def all(sql, options \\ []) do {_, result, _tenants} = qall(sql, options) @@ -52,27 +42,12 @@ defmodule Efsql do {plan, Efsql.Executor.run(plan), tenants} end - def stream(sql) do - {logical, _tenants} = sql_to_logical(sql) - query = logical |> Efsql.Rewrite.normalize() |> Efsql.Planner.to_ecto_query() - {query, Efsql.Repo.stream(query)} - end - - def sql_to_logical(sql, tenants \\ %{}) do - logical = %Efsql.Logical.Select{} = Efsql.Parser.sql_to_logical(sql) - resolve_tenant(logical, tenants) - end - - def resolve_tenant(%Efsql.Logical.Select{prefix: {:all_tenants, _}}, _tenants) do - raise Efsql.Exception.Unsupported, "a query across tenants can't be streamed" - end - # Already resolved, e.g. to the TUI session's active tenant. - def resolve_tenant(%Efsql.Logical.Select{prefix: nil, tenant: tenant} = logical, tenants) - when tenant != nil, - do: {logical, tenants} + defp resolve_tenant(%Efsql.Logical.Select{prefix: nil, tenant: tenant} = logical, tenants) + when tenant != nil, + do: {logical, tenants} - def resolve_tenant(%Efsql.Logical.Select{} = logical, tenants) do + defp resolve_tenant(%Efsql.Logical.Select{} = logical, tenants) do {tenant_name, storage_id} = case logical.prefix do {storage_id, tenant_name} -> {tenant_name, storage_id} @@ -80,30 +55,42 @@ defmodule Efsql do tenant_name -> {tenant_name, nil} end - if not Map.has_key?(tenants, {tenant_name, storage_id}) and - not EctoFoundationDB.Tenant.exists?(Efsql.Repo, tenant_name) do - raise Efsql.Exception.Unsupported, "Tenant '#{tenant_name}' does not exist" - end - - {tenant, tenants} = open_tenant(tenants, tenant_name, storage_id) + {tenant, tenants} = open_tenant(tenants, tenant_name, storage_id, check_exists: true) {%Efsql.Logical.Select{logical | tenant: tenant}, tenants} end @doc """ Opens a tenant through the session's cache of open tenants, keyed by name and storage id (nil for the Repo's own). + + Read-only: the tenant is opened with `migrate: false`, and + `Tenant.open/3` only opens (never creates), so efsql never writes to the + database it is exploring. A storage id other than the Repo's gets its + tenant cache started first. With `check_exists: true`, a tenant that + doesn't exist in that storage id is an `Efsql.Exception.Unsupported` + error; tenants just listed from the storage id skip the check. """ - def open_tenant(tenants, tenant_name, storage_id) do + def open_tenant(tenants, tenant_name, storage_id, options \\ []) do case Map.fetch(tenants, {tenant_name, storage_id}) do {:ok, tenant} -> {tenant, tenants} :error -> + if storage_id, do: Efsql.Discover.ensure_storage_cache(storage_id) + config = Efsql.Repo.config() + config = if storage_id, do: Keyword.put(config, :storage_id, storage_id), else: config + + if Keyword.get(options, :check_exists, false) and + not EctoFoundationDB.Tenant.Backend.exists?( + Ecto.Adapters.FoundationDB.db(Efsql.Repo), + tenant_name, + config + ) do + raise Efsql.Exception.Unsupported, "Tenant '#{tenant_name}' does not exist" + end + open_opts = if storage_id, do: [storage_id: storage_id], else: [] - # migrate: false keeps this read-only. Tenant.open/3 already requires - # the tenant to exist (only open!/3 creates), so with the migration - # step skipped efsql never writes to the database it is exploring. tenant = EctoFoundationDB.Tenant.open(Efsql.Repo, tenant_name, [migrate: false] ++ open_opts) diff --git a/lib/efsql/aggregate.ex b/lib/efsql/aggregate.ex index 3ced7db..23b17b4 100644 --- a/lib/efsql/aggregate.ex +++ b/lib/efsql/aggregate.ex @@ -25,7 +25,7 @@ defmodule Efsql.Aggregate do @spec run([map], [atom], [aggregate]) :: [map] def run(rows, group_by, aggregates) do rows - |> Enum.group_by(fn row -> Enum.map(group_by, &key(Map.get(row, &1))) end) + |> Enum.group_by(fn row -> Enum.map(group_by, &Types.equality_key(Map.get(row, &1))) end) |> ensure_one_group(group_by) |> Enum.map(fn {_key, rows} -> first = List.first(rows, %{}) @@ -35,20 +35,13 @@ defmodule Efsql.Aggregate do Map.put(acc, name, compute(function, arg, rows)) end) end) - |> Enum.sort(&(compare_groups(&1, &2, group_by) != :gt)) + |> Efsql.Executor.sort(Enum.map(group_by, &{:asc, &1})) end # A whole-table aggregate answers even when no row matched. defp ensure_one_group(groups, []) when groups == %{}, do: [{[], []}] defp ensure_one_group(groups, _group_by), do: Enum.to_list(groups) - # A term that is equal for values Types.compare/2 calls equal. - defp key(%Decimal{} = d), do: Decimal.normalize(d) - defp key(%NaiveDateTime{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} - defp key(%DateTime{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} - defp key(%Time{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} - defp key(value), do: value - # -- aggregates -- defp compute(:count, :star, rows), do: length(rows) @@ -105,28 +98,4 @@ defmodule Efsql.Aggregate do defp decimal(%Decimal{} = d), do: d defp decimal(n) when is_integer(n), do: Decimal.new(n) defp decimal(n) when is_float(n), do: Decimal.from_float(n) - - # -- group order -- - - # By key, NULLs last, like an ascending ORDER BY. - defp compare_groups(_a, _b, []), do: :eq - - defp compare_groups(a, b, [field | rest]) do - case {Map.get(a, field), Map.get(b, field)} do - {nil, nil} -> - compare_groups(a, b, rest) - - {nil, _} -> - :gt - - {_, nil} -> - :lt - - {va, vb} -> - case Types.compare(va, vb) do - :eq -> compare_groups(a, b, rest) - cmp -> cmp - end - end - end end diff --git a/lib/efsql/cli.ex b/lib/efsql/cli.ex index ddaab66..c3e3b14 100644 --- a/lib/efsql/cli.ex +++ b/lib/efsql/cli.ex @@ -1,5 +1,5 @@ defmodule Efsql.Cli do - defstruct args: [], history: [], debug: false, tenants: %{}, limit: 15, tenant_batch: nil + defstruct args: [], history: [], debug: false, tenants: %{}, settings: %Efsql.Settings{} use GenServer @@ -92,9 +92,7 @@ defmodule Efsql.Cli do """ Meta-commands: \\tenants [storage_id] list tenants (optionally for a specific storage id) - \\set limit N set the default row limit (currently #{state.limit}) - \\set tenant_batch N|off read *.table queries N tenants per transaction - (currently #{state.tenant_batch || "off"}) + #{settings_help(state.settings)} \\? show this help """, :light_black @@ -140,48 +138,33 @@ defmodule Efsql.Cli do state end - defp handle_input("\\set limit " <> rest, state = %__MODULE__{}) do - case Integer.parse(String.trim(rest)) do - {n, ""} when n > 0 -> - Owl.IO.puts(Owl.Data.tag("limit set to #{n}", :light_black)) - %__MODULE__{state | limit: n} + defp handle_input("\\set " <> rest, state = %__MODULE__{}) do + case Efsql.Settings.set(state.settings, rest) do + {:ok, settings, message} -> + Owl.IO.puts(Owl.Data.tag(message, :light_black)) + %__MODULE__{state | settings: settings} - _ -> - print_error("Usage: \\set limit ") - state - end - end - - defp handle_input("\\set tenant_batch " <> rest, state = %__MODULE__{}) do - case {String.trim(rest), Integer.parse(String.trim(rest))} do - {"off", _} -> - Owl.IO.puts(Owl.Data.tag("tenant_batch off: one transaction", :light_black)) - %__MODULE__{state | tenant_batch: nil} - - {_, {n, ""}} when n > 0 -> - Owl.IO.puts(Owl.Data.tag("tenant_batch set to #{n}", :light_black)) - %__MODULE__{state | tenant_batch: n} - - _ -> - print_error("Usage: \\set tenant_batch | off") + {:error, usage} -> + print_error(usage) state end end defp handle_input(data, state = %__MODULE__{}) do - limit_sql = "limit #{state.limit + 1}" + limit = state.settings.limit + limit_sql = "limit #{limit + 1}" {tenants} = try do {sql, display_limit} = if String.match?(data, ~r/\blimit\b/i), do: {data, :all}, - else: {String.replace(data, ~r/;\s*$/, " #{limit_sql};"), state.limit} + else: {String.replace(data, ~r/;\s*$/, " #{limit_sql};"), limit} - options = if state.tenant_batch, do: [tenant_batch: state.tenant_batch], else: [] + options = Efsql.Settings.query_options(state.settings) {call, rows, tenants} = Efsql.qall(sql, options, state.tenants) if state.debug, do: print_debug(call) - print_table(rows, display_limit, call.columns) + print_table(rows, display_limit, Efsql.Render.columns(call, rows)) print_snapshots(Efsql.Fanout.transactions(call)) {tenants} rescue @@ -258,12 +241,10 @@ defmodule Efsql.Cli do print_rows(display_rows, more?, columns) end - # 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) + Map.new(columns, &{to_string(&1), format_value(Map.get(row, &1))}) end) |> Owl.Table.new( border_style: :solid_rounded, @@ -277,14 +258,19 @@ defmodule Efsql.Cli do Owl.IO.puts(Owl.Data.tag(label, :light_black)) end + defp settings_help(settings) do + Enum.map_join(Efsql.Settings.describe(settings), "\n", fn {command, what} -> + " " <> String.pad_trailing(command, 22) <> " " <> what + end) + end + defp print_snapshots(1), do: :ok defp print_snapshots(n) do Owl.IO.puts(Owl.Data.tag("(read in #{n} transactions)", :yellow)) end - defp column_sorter(nil), do: :asc - + # Owl sorts a table's columns; keep them in the result's order. 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)) diff --git a/lib/efsql/discover.ex b/lib/efsql/discover.ex index 20a0be3..5d7b7b8 100644 --- a/lib/efsql/discover.ex +++ b/lib/efsql/discover.ex @@ -171,9 +171,7 @@ defmodule Efsql.Discover do end def indexes(tenant, source) do - EctoFoundationDB.Layer.Metadata.transactional(tenant, source, fn _tx, metadata -> - for idx <- metadata.indexes, do: %{name: idx[:id], fields: idx[:fields]} - end) + for idx <- Planner.indexes(tenant, source), do: %{name: idx[:id], fields: idx[:fields]} end # -- schema cache -- diff --git a/lib/efsql/executor.ex b/lib/efsql/executor.ex index ff36a6d..dfb1227 100644 --- a/lib/efsql/executor.ex +++ b/lib/efsql/executor.ex @@ -73,18 +73,6 @@ defmodule Efsql.Executor do # -- access nodes -- - defp fetch({:pk_range, query, id_start, id_end, options}) do - Efsql.Repo.all_range(query, id_start, id_end, options) - end - - defp fetch({:index_scan, query, options}) do - Efsql.Repo.all(query, options) - end - - defp fetch({:all_from_source, query, options}) do - Efsql.Repo.all_from_source(query, options) - end - # One transaction per batch, up to `concurrency` at a time. defp fetch({:batches, batches, concurrency}), do: batches |> map_concurrently(concurrency, &fetch/1) |> Enum.concat() @@ -96,12 +84,9 @@ defmodule Efsql.Executor do Ecto.Adapters.FoundationDB.transactional(db, fn -> read_tenants(tenant_plans) end) end - defp fetch({:union, nodes}) do - nodes - |> Enum.map(&async_fetch/1) - |> Efsql.Repo.await() - |> List.flatten() - end + # Every other node, a union included, is one or more adapter reads: + # start them all, then await them together, so they are pipelined. + defp fetch(access), do: access |> start() |> Efsql.Repo.await() |> List.flatten() # Runs inside the transaction. A read too big for one transaction fails # with transaction_too_old, which erlfdb's transactional loop would retry, @@ -160,17 +145,13 @@ defmodule Efsql.Executor do # -- operators -- - defp apply_op({:filter, predicates}, rows) do - Enum.filter(rows, fn row -> Enum.all?(predicates, &eval(&1, row)) end) - end + defp apply_op({:filter, predicates}, rows), do: Enum.filter(rows, &matches?(&1, predicates)) defp apply_op({:aggregate, group_by, aggregates}, rows) do Efsql.Aggregate.run(rows, group_by, aggregates) end - defp apply_op({:sort, sort}, rows) do - Enum.sort(rows, fn a, b -> compare(a, b, sort) != :gt end) - end + defp apply_op({:sort, order}, rows), do: sort(rows, order) defp apply_op({:limit, n}, rows) do Enum.take(rows, n) @@ -182,38 +163,31 @@ defmodule Efsql.Executor do # -- predicate evaluation -- - defp eval({:cmp, op, field, param}, row) when op in @cmp_ops do - case Map.get(row, field) do - nil -> false - value -> Types.compare(value, param) in Map.fetch!(@cmp_results, op) - end - end + defp eval({:cmp, op, field, param}, row) when op in @cmp_ops, + do: on_value(row, field, &(Types.compare(&1, param) in Map.fetch!(@cmp_results, op))) defp eval({:range, field, {lower_op, lower}, {upper_op, upper}}, row) do eval({:cmp, lower_op, field, lower}, row) and eval({:cmp, upper_op, field, upper}, row) end - defp eval({:in, field, values}, row) do - case Map.get(row, field) do - nil -> false - value -> Enum.any?(values, &(Types.compare(value, &1) == :eq)) - end - end + defp eval({:in, field, values}, row), + do: + on_value(row, field, fn value -> Enum.any?(values, &(Types.compare(value, &1) == :eq)) end) defp eval({:is_null, field}, row), do: Map.get(row, field) == nil defp eval({:not_null, field}, row), do: Map.get(row, field) != nil - defp eval({:like, field, pattern}, row) do - case Map.get(row, field) do - nil -> false - value -> Regex.match?(like_regex(pattern), value) - end - end + defp eval({:like, field, pattern}, row), + do: on_value(row, field, &Regex.match?(like_regex(pattern), &1)) + + defp eval({:not_like, field, pattern}, row), + do: on_value(row, field, &(not Regex.match?(like_regex(pattern), &1))) - defp eval({:not_like, field, pattern}, row) do + # A NULL (nil or absent) field matches no comparison, LIKE or IN. + defp on_value(row, field, fun) do case Map.get(row, field) do nil -> false - value -> not Regex.match?(like_regex(pattern), value) + value -> fun.(value) end end @@ -229,6 +203,12 @@ defmodule Efsql.Executor do # -- sorting -- + @doc """ + Sorts rows by `[{:asc | :desc, field}]`: NULLs last ascending and first + descending, as PostgreSQL does, and values by `Efsql.Types.compare/2`. + """ + def sort(rows, order), do: Enum.sort(rows, fn a, b -> compare(a, b, order) != :gt end) + defp compare(_a, _b, []), do: :eq defp compare(a, b, [{dir, field} | rest]) do diff --git a/lib/efsql/logical.ex b/lib/efsql/logical.ex index 474227e..58e8b84 100644 --- a/lib/efsql/logical.ex +++ b/lib/efsql/logical.ex @@ -47,6 +47,17 @@ defmodule Efsql.Logical do aggregates: [] end + @doc """ + Takes the first predicate `fun` accepts out of `predicates`: + `{predicate, rest}`, or `{nil, predicates}` when none does. + """ + def take_first(predicates, fun) do + case Enum.split_while(predicates, &(not fun.(&1))) do + {_before, []} -> {nil, predicates} + {before, [match | rest]} -> {match, before ++ rest} + end + end + def predicate_field({:cmp, _op, field, _value}), do: field def predicate_field({:range, field, _lower, _upper}), do: field def predicate_field({:like, field, _pattern}), do: field diff --git a/lib/efsql/planner.ex b/lib/efsql/planner.ex index a18923b..574e9a0 100644 --- a/lib/efsql/planner.ex +++ b/lib/efsql/planner.ex @@ -130,23 +130,6 @@ defmodule Efsql.Planner do end end - @doc """ - A plain Ecto query with every predicate as a where clause, for callers - that hand the query to the adapter unplanned (e.g. `Repo.stream`). - """ - def to_ecto_query(%Logical.Select{group_by: group_by}) when is_list(group_by), - do: raise(Unsupported, "GROUP BY and aggregates can't be streamed") - - def to_ecto_query(%Logical.Select{} = logical) do - take = if logical.projection == :star, do: nil, else: logical.projection - - base_query(logical) - |> put_select(take) - |> put_wheres(logical.predicates) - |> put_order(logical.order) - |> put_limit(logical.limit) - end - # -- SELECT planning -- defp plan_select(logical, options, pushables, residuals, indexes, star?) do @@ -244,20 +227,18 @@ defmodule Efsql.Planner do end end - defp take_pred(preds, field, pred_fun) do - case Enum.split_while(preds, fn p -> - not (Logical.predicate_field(p) == field and pred_fun.(p)) - end) do - {_before, []} -> {nil, preds} - {before, [match | rest]} -> {match, before ++ rest} - end - end + defp take_pred(preds, field, pred_fun), + do: Logical.take_first(preds, &(Logical.predicate_field(&1) == field and pred_fun.(&1))) defp range?({:range, _field, _lower, _upper}), do: true defp range?({:cmp, op, _field, _value}) when op in ~w[> >= < <=]a, do: true defp range?(_), do: false - defp load_indexes(%Logical.Select{tenant: tenant, source: source}) do + defp load_indexes(%Logical.Select{tenant: tenant, source: source}), + do: indexes(tenant, source) + + @doc "A source's indexes, from the tenant's metadata, each with its `:id` and `:fields`." + def indexes(tenant, source) do Metadata.transactional(tenant, source, fn _tx, metadata -> metadata.indexes end) end diff --git a/lib/efsql/render.ex b/lib/efsql/render.ex index 79a1116..f55cb16 100644 --- a/lib/efsql/render.ex +++ b/lib/efsql/render.ex @@ -277,6 +277,19 @@ defmodule Efsql.Render do end end + @doc """ + A result's columns, in order: the select list's, from the plan, or for + `select *` (which has none) the rows' fields, `id` first and the rest + sorted. + """ + def columns(_plan, []), do: [] + def columns(%Efsql.Physical.Plan{columns: [_ | _] = columns}, _rows), do: columns + + def 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 + @doc "Short name for a value's type, for schema discovery displays." def type_of(nil), do: :null def type_of(v) when is_boolean(v), do: :boolean diff --git a/lib/efsql/rewrite.ex b/lib/efsql/rewrite.ex index 28b58c9..7313db7 100644 --- a/lib/efsql/rewrite.ex +++ b/lib/efsql/rewrite.ex @@ -28,7 +28,7 @@ defmodule Efsql.Rewrite do defp merge_ranges([{:cmp, op, field, value} | rest], acc) when op in @lower_ops do case take_bound(rest, field, @upper_ops) do - nil -> + {nil, _} -> merge_ranges(rest, [{:cmp, op, field, value} | acc]) {{:cmp, op2, _f, value2}, rest2} -> @@ -38,7 +38,7 @@ defmodule Efsql.Rewrite do defp merge_ranges([{:cmp, op, field, value} | rest], acc) when op in @upper_ops do case take_bound(rest, field, @lower_ops) do - nil -> + {nil, _} -> merge_ranges(rest, [{:cmp, op, field, value} | acc]) {{:cmp, op2, _f, value2}, rest2} -> @@ -49,15 +49,10 @@ defmodule Efsql.Rewrite do defp merge_ranges([pred | rest], acc), do: merge_ranges(rest, [pred | acc]) defp take_bound(preds, field, ops) do - match? = fn + Logical.take_first(preds, fn {:cmp, op, f, _v} -> op in ops and f == field _ -> false - end - - case Enum.split_while(preds, &(not match?.(&1))) do - {_before, []} -> nil - {before, [match | rest]} -> {match, before ++ rest} - end + end) end # `field like 'prefix%'` is exactly a key range. A pattern with a wildcard diff --git a/lib/efsql/settings.ex b/lib/efsql/settings.ex new file mode 100644 index 0000000..12f940f --- /dev/null +++ b/lib/efsql/settings.ex @@ -0,0 +1,60 @@ +defmodule Efsql.Settings do + @moduledoc """ + The session settings `\\set` changes, shared by the line CLI and the TUI: + + * `limit` - the default row limit (15) + * `tenant_batch` - how many tenants a `*.table` query reads per + transaction; `off` (the default) reads them all in one + """ + + defstruct limit: 15, tenant_batch: nil + + @type t :: %__MODULE__{limit: pos_integer(), tenant_batch: pos_integer() | nil} + + @doc """ + Applies the text after `\\set `: `{:ok, settings, message}`, or + `{:error, usage}` for an unknown setting or a bad value. + """ + @spec set(t(), String.t()) :: {:ok, t(), String.t()} | {:error, String.t()} + def set(%__MODULE__{} = settings, text) do + case String.split(String.trim(text), ~r/\s+/, parts: 2) do + ["limit", value] -> + with {:ok, n} <- positive(value, "\\set limit N") do + {:ok, %__MODULE__{settings | limit: n}, "limit set to #{n}"} + end + + ["tenant_batch", "off"] -> + {:ok, %__MODULE__{settings | tenant_batch: nil}, "tenant_batch off: one transaction"} + + ["tenant_batch", value] -> + with {:ok, n} <- positive(value, "\\set tenant_batch N|off") do + {:ok, %__MODULE__{settings | tenant_batch: n}, "tenant_batch set to #{n}"} + end + + _ -> + {:error, "usage: \\set limit N, or \\set tenant_batch N|off"} + end + end + + @doc "Each setting and its current value, for help text." + @spec describe(t()) :: [{String.t(), String.t()}] + def describe(%__MODULE__{} = settings) do + [ + {"\\set limit N", "default row limit (#{settings.limit})"}, + {"\\set tenant_batch N|off", + "tenants per transaction for *.table (#{settings.tenant_batch || "off"})"} + ] + end + + @doc "The options `Efsql.qall/3` takes for these settings." + @spec query_options(t()) :: keyword() + def query_options(%__MODULE__{tenant_batch: nil}), do: [] + def query_options(%__MODULE__{tenant_batch: n}), do: [tenant_batch: n] + + defp positive(value, usage) do + case Integer.parse(value) do + {n, ""} when n > 0 -> {:ok, n} + _ -> {:error, "usage: #{usage}"} + end + end +end diff --git a/lib/efsql/tui/app.ex b/lib/efsql/tui/app.ex index 42b1f95..8e875b2 100644 --- a/lib/efsql/tui/app.ex +++ b/lib/efsql/tui/app.ex @@ -26,9 +26,7 @@ defmodule Efsql.Tui.App do mode: :navigator, flash: nil, busy: nil, - limit: 15, - # tenants per transaction for *.table queries; nil is one transaction - tenant_batch: nil, + settings: %Efsql.Settings{}, cluster_file: nil, # navigator nav_path: [], @@ -234,21 +232,7 @@ defmodule Efsql.Tui.App do defp activate(model, storage_id, tenant_id) do fun = fn -> - Discover.ensure_storage_cache(storage_id) - db = Ecto.Adapters.FoundationDB.db(Efsql.Repo) - config = Keyword.put(Efsql.Repo.config(), :storage_id, storage_id) - - unless EctoFoundationDB.Tenant.Backend.exists?(db, tenant_id, config) do - raise "Tenant '#{tenant_id}' does not exist" - end - - # migrate: false — see Efsql.resolve_tenant/2; the TUI never writes. - tenant = - EctoFoundationDB.Tenant.open(Efsql.Repo, tenant_id, - storage_id: storage_id, - migrate: false - ) - + {tenant, _tenants} = Efsql.open_tenant(%{}, tenant_id, storage_id, check_exists: true) {storage_id, tenant_id, tenant} end @@ -286,7 +270,7 @@ defmodule Efsql.Tui.App do with source when source != nil <- current_source(model), %Discover.Schema{fields: fields} <- model.schemas[source], %{name: field} <- Enum.at(fields, model.field_cursor) do - input = "select #{field} from #{source} limit #{model.limit};" + input = "select #{field} from #{source} limit #{model.settings.limit};" {%{model | mode: :query, input: input, qcursor: String.length(input), qfocus: :input}, []} else _ -> {model, []} @@ -436,11 +420,8 @@ defmodule Efsql.Tui.App do "\\plan" -> {%{model | show_plan?: not model.show_plan?, input: "", qcursor: 0}, []} - "\\set limit " <> n -> - set_limit(model, n) - - "\\set tenant_batch " <> n -> - set_tenant_batch(model, n) + "\\set " <> text -> + set(model, text) sql -> run_query(model, sql) @@ -449,29 +430,12 @@ defmodule Efsql.Tui.App do defp query(model, _msg), do: {model, []} - defp set_limit(model, n) do - case Integer.parse(String.trim(n)) do - {n, ""} when n > 0 -> - {%{model | limit: n, input: "", qcursor: 0, flash: {:info, "limit set to #{n}"}}, []} - - _ -> - {%{model | flash: {:error, "usage: \\set limit N"}, input: "", qcursor: 0}, []} - end - end - - defp set_tenant_batch(model, n) do - case {String.trim(n), Integer.parse(String.trim(n))} do - {"off", _} -> - {%{model | tenant_batch: nil, input: "", qcursor: 0, flash: {:info, "tenant_batch off"}}, - []} + defp set(model, text) do + model = %{model | input: "", qcursor: 0} - {_, {n, ""}} when n > 0 -> - flash = {:info, "tenant_batch set to #{n}"} - {%{model | tenant_batch: n, input: "", qcursor: 0, flash: flash}, []} - - _ -> - flash = {:error, "usage: \\set tenant_batch N|off"} - {%{model | flash: flash, input: "", qcursor: 0}, []} + case Efsql.Settings.set(model.settings, text) do + {:ok, settings, message} -> {%{model | settings: settings, flash: {:info, message}}, []} + {:error, usage} -> {%{model | flash: {:error, usage}}, []} end end @@ -482,7 +446,7 @@ defmodule Efsql.Tui.App do tenant: model.tenant, tenants: model.tenants, storage_id: model.storage_id, - tenant_batch: model.tenant_batch + settings: model.settings } fun = fn -> @@ -788,7 +752,7 @@ defmodule Efsql.Tui.App do end defp done(model, :query, {:ok, {plan, rows, tenants, elapsed}}) do - columns = columns(plan, rows) + columns = Efsql.Render.columns(plan, rows) cells = render_cells(rows, columns) model = %{ @@ -812,17 +776,6 @@ defmodule Efsql.Tui.App do defp done(model, _tag, _result), do: {%{model | busy: nil}, []} - 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(_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 - # Cells are rendered once here rather than per frame: a frame is painted on # every key press, and inspecting every visible value each time is what # made the editor lag on wide or large rows. diff --git a/lib/efsql/tui/session.ex b/lib/efsql/tui/session.ex index 95e0960..ec62dba 100644 --- a/lib/efsql/tui/session.ex +++ b/lib/efsql/tui/session.ex @@ -16,7 +16,7 @@ defmodule Efsql.Tui.Session do |> Efsql.run_logical(options(session), session.tenants) end - defp options(%{tenant_batch: n}) when is_integer(n), do: [tenant_batch: n] + defp options(%{settings: settings}), do: Efsql.Settings.query_options(settings) defp options(_session), do: [] defp in_session(%Logical.Select{prefix: {:all_tenants, nil}} = logical, session), diff --git a/lib/efsql/types.ex b/lib/efsql/types.ex index e1ea03b..7078238 100644 --- a/lib/efsql/types.ex +++ b/lib/efsql/types.ex @@ -102,6 +102,17 @@ defmodule Efsql.Types do end end + @doc """ + A term that is equal exactly when `compare/2` calls two values equal, + for grouping: a `Decimal` 1.0 and 1.00 have the same key, as do + datetimes stored at different precisions. + """ + def equality_key(%Decimal{} = d), do: Decimal.normalize(d) + def equality_key(%NaiveDateTime{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} + def equality_key(%DateTime{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} + def equality_key(%Time{microsecond: {us, _}} = t), do: %{t | microsecond: {us, 6}} + def equality_key(value), do: value + @doc """ Orders two field values, `:lt`, `:eq` or `:gt`. Dates and times compare chronologically (term order would compare their struct fields, and term diff --git a/test/render_test.exs b/test/render_test.exs index 30e8cde..9efe7fa 100644 --- a/test/render_test.exs +++ b/test/render_test.exs @@ -132,4 +132,20 @@ defmodule Efsql.RenderTest do assert String.length(Render.cell(uri, 30)) <= 30 assert Render.full(uri) =~ "scheme:" end + + describe "columns" do + test "follow the select list" do + plan = %Efsql.Physical.Plan{columns: [:name, :id]} + assert Render.columns(plan, [%{id: 1, name: "a"}]) == [:name, :id] + end + + test "for select *, the rows' fields with id first" do + rows = [%{name: "a", id: 1}, %{id: 2, age: 3}] + assert Render.columns(%Efsql.Physical.Plan{}, rows) == [:id, :age, :name] + end + + test "no rows, no columns" do + assert Render.columns(%Efsql.Physical.Plan{columns: [:a]}, []) == [] + end + end end diff --git a/test/rewrite_test.exs b/test/rewrite_test.exs index 4f7913b..e0a39b5 100644 --- a/test/rewrite_test.exs +++ b/test/rewrite_test.exs @@ -74,4 +74,14 @@ defmodule Efsql.RewriteTest do assert [{:in, :name, ["a", "b"]}] = normalize([{:in, :name, ["a", "b"]}]) end end + + test "Logical.take_first takes the first match out, keeping the rest in order" do + preds = [{:is_null, :a}, {:cmp, :>, :b, 1}, {:cmp, :>, :c, 2}] + gt? = &match?({:cmp, :>, _, _}, &1) + + assert Efsql.Logical.take_first(preds, gt?) == + {{:cmp, :>, :b, 1}, [{:is_null, :a}, {:cmp, :>, :c, 2}]} + + assert Efsql.Logical.take_first(preds, fn _ -> false end) == {nil, preds} + end end diff --git a/test/settings_test.exs b/test/settings_test.exs new file mode 100644 index 0000000..c652f8e --- /dev/null +++ b/test/settings_test.exs @@ -0,0 +1,47 @@ +defmodule Efsql.SettingsTest do + use ExUnit.Case, async: true + + alias Efsql.Settings + + test "defaults" do + assert %Settings{limit: 15, tenant_batch: nil} = %Settings{} + assert Settings.query_options(%Settings{}) == [] + end + + test "limit takes a positive integer" do + assert {:ok, %Settings{limit: 20}, "limit set to 20"} = Settings.set(%Settings{}, "limit 20") + assert {:ok, %Settings{limit: 3}, _} = Settings.set(%Settings{}, " limit 3 ") + + for bad <- ["limit 0", "limit -1", "limit x", "limit 2.5", "limit"] do + assert {:error, "usage: " <> _} = Settings.set(%Settings{}, bad), bad + end + end + + test "tenant_batch takes a positive integer or off, and becomes a query option" do + assert {:ok, settings, "tenant_batch set to 25"} = + Settings.set(%Settings{}, "tenant_batch 25") + + assert Settings.query_options(settings) == [tenant_batch: 25] + + assert {:ok, settings, _} = Settings.set(settings, "tenant_batch off") + assert settings.tenant_batch == nil + assert Settings.query_options(settings) == [] + + assert {:error, "usage: \\set tenant_batch N|off"} = + Settings.set(%Settings{}, "tenant_batch 0") + end + + test "an unknown setting says what there is" do + assert {:error, message} = Settings.set(%Settings{}, "colour blue") + assert message =~ "\\set limit N" + assert message =~ "\\set tenant_batch N|off" + end + + test "describe shows the current values" do + assert [{"\\set limit N", limit}, {"\\set tenant_batch N|off", batch}] = + Settings.describe(%Settings{limit: 7, tenant_batch: 4}) + + assert limit =~ "(7)" + assert batch =~ "(4)" + end +end diff --git a/test/tui_app_test.exs b/test/tui_app_test.exs index ccbd976..6e18c47 100644 --- a/test/tui_app_test.exs +++ b/test/tui_app_test.exs @@ -206,14 +206,14 @@ defmodule Efsql.Tui.AppTest do model = %{activated() | mode: :query} {model, _} = feed(model, chars("\\set tenant_batch 25") ++ [{:key, :enter}]) - assert model.tenant_batch == 25 + assert model.settings.tenant_batch == 25 assert model.flash == {:info, "tenant_batch set to 25"} {model, _} = feed(model, chars("\\set tenant_batch off") ++ [{:key, :enter}]) - assert model.tenant_batch == nil + assert model.settings.tenant_batch == nil {model, _} = feed(model, chars("\\set tenant_batch 0") ++ [{:key, :enter}]) - assert model.tenant_batch == nil + assert model.settings.tenant_batch == nil assert {:error, "usage: " <> _} = model.flash end diff --git a/test/types_test.exs b/test/types_test.exs index 64b33b6..5c683e0 100644 --- a/test/types_test.exs +++ b/test/types_test.exs @@ -65,4 +65,21 @@ defmodule Efsql.TypesTest do assert Types.index_key(:active) == :active end end + + test "equality_key is equal exactly when compare/2 says equal" do + pairs = [ + {Decimal.new("1.0"), Decimal.new("1.00")}, + {~N[2024-01-01 00:00:00], ~N[2024-01-01 00:00:00.000000]}, + {~U[2024-01-01 00:00:00Z], ~U[2024-01-01 00:00:00.000Z]}, + {~T[09:30:00], ~T[09:30:00.000000]} + ] + + for {a, b} <- pairs do + assert Types.compare(a, b) == :eq + assert Types.equality_key(a) == Types.equality_key(b) + end + + refute Types.equality_key(Decimal.new("1.0")) == Types.equality_key(Decimal.new("1.1")) + assert Types.equality_key("x") == "x" + end end From d79b9d3482cbd9f23ff78e6bf08292e2d64caee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:22:50 +0000 Subject: [PATCH 2/3] Add Efsql.Session and Efsql.Result The CLI and the TUI each kept what a query runs in (the cache of open tenants, the settings, and in the TUI the active tenant and storage id) and each took apart a bare {plan, rows, tenants}, then asked Render and Fanout for the columns and the transaction count. Efsql.Session holds that state. Session.run/3 resolves the statement's scope (a named tenant, the active tenant, or every tenant of the active or named storage id), applies the settings as query options, runs it and times it. It returns an Efsql.Result (rows, columns in order, the transaction count, the plan, elapsed ms) and the session with its grown tenant cache. Session.activate/3 is the navigator's "use this tenant". The CLI state and the TUI model each hold one session, and the TUI model holds the last Result instead of separate rows/columns/plan/elapsed_ms fields. Efsql.Tui.Session is gone. Efsql.qall/3 stays as the sessionless entry point, now built on Session.run/3. In the CLI, a table without a tenant now gets the TUI's "no active tenant" message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko --- lib/efsql.ex | 12 ++++- lib/efsql/cli.ex | 31 +++++++------ lib/efsql/physical.ex | 1 + lib/efsql/result.ex | 31 +++++++++++++ lib/efsql/session.ex | 86 ++++++++++++++++++++++++++++++++++++ lib/efsql/settings.ex | 2 +- lib/efsql/tui/app.ex | 95 +++++++++++++++++----------------------- lib/efsql/tui/session.ex | 34 -------------- lib/efsql/tui/view.ex | 57 ++++++++++-------------- test/discover_test.exs | 8 ++-- test/session_test.exs | 26 +++++++++++ test/tui_app_test.exs | 66 +++++++++++++++++++++------- test/tui_width_test.exs | 7 ++- 13 files changed, 293 insertions(+), 163 deletions(-) create mode 100644 lib/efsql/result.ex create mode 100644 lib/efsql/session.ex delete mode 100644 lib/efsql/tui/session.ex create mode 100644 test/session_test.exs diff --git a/lib/efsql.ex b/lib/efsql.ex index f4da315..cfb22a5 100644 --- a/lib/efsql.ex +++ b/lib/efsql.ex @@ -13,6 +13,10 @@ defmodule Efsql do A query across tenants (`*.table`) is planned by `Efsql.Fanout` instead, which plans each tenant's read with `Efsql.Planner`. + + The CLI and the TUI run statements in an `Efsql.Session` (open tenants, + settings, the active tenant) and show the `Efsql.Result` it returns. + `qall/3` and `all/2` run one outside any session. """ def all(sql, options \\ []) do @@ -20,8 +24,14 @@ defmodule Efsql do result end + @doc """ + Runs one statement outside any session, reusing and returning the + `tenants` cache: `{plan, rows, tenants}`. See `Efsql.Session` for the + CLI's and TUI's way in. + """ def qall(sql, options \\ [], tenants \\ %{}) do - sql |> Efsql.Parser.sql_to_logical() |> run_logical(options, tenants) + {result, session} = Efsql.Session.run(%Efsql.Session{tenants: tenants}, sql, options) + {result.plan, result.rows, session.tenants} end @doc """ diff --git a/lib/efsql/cli.ex b/lib/efsql/cli.ex index c3e3b14..fa1b4ef 100644 --- a/lib/efsql/cli.ex +++ b/lib/efsql/cli.ex @@ -1,5 +1,5 @@ defmodule Efsql.Cli do - defstruct args: [], history: [], debug: false, tenants: %{}, settings: %Efsql.Settings{} + defstruct args: [], history: [], debug: false, session: %Efsql.Session{} use GenServer @@ -92,7 +92,7 @@ defmodule Efsql.Cli do """ Meta-commands: \\tenants [storage_id] list tenants (optionally for a specific storage id) - #{settings_help(state.settings)} + #{settings_help(state.session.settings)} \\? show this help """, :light_black @@ -139,10 +139,10 @@ defmodule Efsql.Cli do end defp handle_input("\\set " <> rest, state = %__MODULE__{}) do - case Efsql.Settings.set(state.settings, rest) do + case Efsql.Settings.set(state.session.settings, rest) do {:ok, settings, message} -> Owl.IO.puts(Owl.Data.tag(message, :light_black)) - %__MODULE__{state | settings: settings} + %__MODULE__{state | session: %{state.session | settings: settings}} {:error, usage} -> print_error(usage) @@ -151,29 +151,28 @@ defmodule Efsql.Cli do end defp handle_input(data, state = %__MODULE__{}) do - limit = state.settings.limit + limit = state.session.settings.limit limit_sql = "limit #{limit + 1}" - {tenants} = + session = try do {sql, display_limit} = if String.match?(data, ~r/\blimit\b/i), do: {data, :all}, else: {String.replace(data, ~r/;\s*$/, " #{limit_sql};"), limit} - options = Efsql.Settings.query_options(state.settings) - {call, rows, tenants} = Efsql.qall(sql, options, state.tenants) - if state.debug, do: print_debug(call) - print_table(rows, display_limit, Efsql.Render.columns(call, rows)) - print_snapshots(Efsql.Fanout.transactions(call)) - {tenants} + {result, session} = Efsql.Session.run(state.session, sql) + if state.debug, do: print_debug(result.plan) + print_table(result.rows, display_limit, result.columns) + print_transactions(result.transactions) + session rescue e -> print_error(e) - {state.tenants} + state.session end - %__MODULE__{state | history: [data | state.history], tenants: tenants} + %__MODULE__{state | history: [data | state.history], session: session} end def init_ecto_foundationdb!(args) do @@ -264,9 +263,9 @@ defmodule Efsql.Cli do end) end - defp print_snapshots(1), do: :ok + defp print_transactions(1), do: :ok - defp print_snapshots(n) do + defp print_transactions(n) do Owl.IO.puts(Owl.Data.tag("(read in #{n} transactions)", :yellow)) end diff --git a/lib/efsql/physical.ex b/lib/efsql/physical.ex index 9964c05..02e6829 100644 --- a/lib/efsql/physical.ex +++ b/lib/efsql/physical.ex @@ -31,5 +31,6 @@ defmodule Efsql.Physical do defmodule Plan do defstruct access: nil, ops: [], columns: nil + @type t :: %__MODULE__{access: tuple(), ops: [tuple()], columns: [atom()] | nil} end end diff --git a/lib/efsql/result.ex b/lib/efsql/result.ex new file mode 100644 index 0000000..8baadc1 --- /dev/null +++ b/lib/efsql/result.ex @@ -0,0 +1,31 @@ +defmodule Efsql.Result do + @moduledoc """ + What a query returns, ready to show: its rows, their columns in order + (see `Efsql.Render.columns/2`), how many transactions they were read in + (more than one only for a query across tenants read in batches), the + plan that produced them, and how long it took. + """ + + alias Efsql.Physical.Plan + + defstruct rows: [], columns: [], transactions: 1, plan: nil, elapsed_ms: 0 + + @type t :: %__MODULE__{ + rows: [map()], + columns: [atom()], + transactions: pos_integer(), + plan: Plan.t(), + elapsed_ms: non_neg_integer() + } + + @spec new(Plan.t(), [map()], non_neg_integer()) :: t() + def new(%Plan{} = plan, rows, elapsed_ms \\ 0) do + %__MODULE__{ + rows: rows, + columns: Efsql.Render.columns(plan, rows), + transactions: Efsql.Fanout.transactions(plan), + plan: plan, + elapsed_ms: elapsed_ms + } + end +end diff --git a/lib/efsql/session.ex b/lib/efsql/session.ex new file mode 100644 index 0000000..66b7671 --- /dev/null +++ b/lib/efsql/session.ex @@ -0,0 +1,86 @@ +defmodule Efsql.Session do + @moduledoc """ + What a query runs in, for the line CLI and the TUI alike: the tenants + opened so far (a cache, keyed by name and storage id), the `\\set` + settings, and the active tenant and storage id, if any. + + A statement's scope comes from its table name and the session: + + * `tenant.table` or `storage_id.tenant.table` - that tenant + * `table` - the active tenant (see `activate/3`) + * `*.table` - every tenant of the active storage id, or of the Repo's + when there is none; `storage_id.*.table` names one + + `run/3` returns an `Efsql.Result` and the session, whose tenant cache may + have grown. + """ + + alias Efsql.Exception.Unsupported + alias Efsql.Logical + alias Efsql.Result + alias Efsql.Settings + + defstruct tenants: %{}, + settings: %Settings{}, + tenant: nil, + tenant_name: nil, + storage_id: nil + + @type t :: %__MODULE__{ + tenants: map(), + settings: Settings.t(), + tenant: term() | nil, + tenant_name: String.t() | nil, + storage_id: String.t() | nil + } + + @doc """ + Runs one statement. `options` go to the query on top of the settings' + (see `Efsql.Fanout.plan/3` for the ones a query across tenants takes). + """ + @spec run(t(), String.t(), keyword()) :: {Result.t(), t()} + def run(%__MODULE__{} = session, sql, options \\ []) do + started = System.monotonic_time(:millisecond) + options = Keyword.merge(Settings.query_options(session.settings), options) + + {plan, rows, tenants} = + sql + |> Efsql.Parser.sql_to_logical() + |> scope(session) + |> Efsql.run_logical(options, session.tenants) + + elapsed = System.monotonic_time(:millisecond) - started + {Result.new(plan, rows, elapsed), %__MODULE__{session | tenants: tenants}} + end + + @doc """ + Makes a tenant the active one, for statements that don't name one. The + tenant must exist in the storage id. + """ + @spec activate(t(), String.t() | nil, String.t()) :: t() + def activate(%__MODULE__{} = session, storage_id, tenant_name) do + {tenant, tenants} = + Efsql.open_tenant(session.tenants, tenant_name, storage_id, check_exists: true) + + %__MODULE__{ + session + | tenant: tenant, + tenant_name: tenant_name, + storage_id: storage_id, + tenants: tenants + } + end + + defp scope(%Logical.Select{prefix: {:all_tenants, nil}} = logical, session), + do: %Logical.Select{logical | prefix: {:all_tenants, session.storage_id}} + + defp scope(%Logical.Select{prefix: nil}, %__MODULE__{tenant: nil}) do + raise Unsupported, + "no active tenant — qualify the table (tenant.table) or pick a tenant in the navigator" + end + + defp scope(%Logical.Select{prefix: nil} = logical, session), + do: %Logical.Select{logical | tenant: session.tenant} + + defp scope(logical, _session), do: logical +end diff --git a/lib/efsql/settings.ex b/lib/efsql/settings.ex index 12f940f..5c20b3f 100644 --- a/lib/efsql/settings.ex +++ b/lib/efsql/settings.ex @@ -46,7 +46,7 @@ defmodule Efsql.Settings do ] end - @doc "The options `Efsql.qall/3` takes for these settings." + @doc "The query options for these settings (see `Efsql.Session.run/3`)." @spec query_options(t()) :: keyword() def query_options(%__MODULE__{tenant_batch: nil}), do: [] def query_options(%__MODULE__{tenant_batch: n}), do: [tenant_batch: n] diff --git a/lib/efsql/tui/app.ex b/lib/efsql/tui/app.ex index 8e875b2..6cc0191 100644 --- a/lib/efsql/tui/app.ex +++ b/lib/efsql/tui/app.ex @@ -26,7 +26,6 @@ defmodule Efsql.Tui.App do mode: :navigator, flash: nil, busy: nil, - settings: %Efsql.Settings{}, cluster_file: nil, # navigator nav_path: [], @@ -36,11 +35,8 @@ defmodule Efsql.Tui.App do # entries matching the filter, recomputed only when either # changes (see `refilter/1`), so a held arrow key stays O(1) nav_visible: [], - # session - storage_id: nil, - tenant_id: nil, - tenant: nil, - tenants: %{}, + # the active tenant, open tenants and settings (see Efsql.Session) + session: %Efsql.Session{}, # schema browser sources: nil, src_cursor: 0, @@ -53,16 +49,14 @@ defmodule Efsql.Tui.App do history: [], hist_ix: nil, saved_input: "", - rows: nil, - columns: [], + # the last query's Efsql.Result, nil before the first + result: nil, # rows rendered once per query result (see `done/3`): a list of # cell strings per row, and each column's natural width cells: [], col_widths: [], col_align: [], - plan: nil, qerror: nil, - elapsed_ms: nil, row_cursor: 0, row_scroll: 0, qfocus: :input, @@ -177,7 +171,8 @@ defmodule Efsql.Tui.App do {model, [load_nav(model)]} end - defp navigator(%Model{tenant: tenant} = model, {:key, :esc}) when tenant != nil do + defp navigator(%Model{session: %Efsql.Session{tenant: tenant}} = model, {:key, :esc}) + when tenant != nil do {%{model | mode: :schema}, []} end @@ -231,10 +226,8 @@ defmodule Efsql.Tui.App do end defp activate(model, storage_id, tenant_id) do - fun = fn -> - {tenant, _tenants} = Efsql.open_tenant(%{}, tenant_id, storage_id, check_exists: true) - {storage_id, tenant_id, tenant} - end + session = model.session + fun = fn -> Efsql.Session.activate(session, storage_id, tenant_id) end {%{model | busy: "opening #{tenant_id}"}, [{:task, :activate, fun}]} end @@ -270,7 +263,7 @@ defmodule Efsql.Tui.App do with source when source != nil <- current_source(model), %Discover.Schema{fields: fields} <- model.schemas[source], %{name: field} <- Enum.at(fields, model.field_cursor) do - input = "select #{field} from #{source} limit #{model.settings.limit};" + input = "select #{field} from #{source} limit #{model.session.settings.limit};" {%{model | mode: :query, input: input, qcursor: String.length(input), qfocus: :input}, []} else _ -> {model, []} @@ -280,7 +273,7 @@ defmodule Efsql.Tui.App do defp schema(model, {:char, "q"}), do: {%{model | mode: :query}, []} defp schema(model, {:char, "t"}), do: {%{model | mode: :navigator}, []} - defp schema(%Model{tenant: t} = model, {:key, :esc}) when t != nil, + defp schema(%Model{session: %Efsql.Session{tenant: t}} = model, {:key, :esc}) when t != nil, do: {%{model | mode: :navigator}, []} defp schema(model, {:char, "r"}) do @@ -338,7 +331,7 @@ defmodule Efsql.Tui.App do def current_source(_), do: nil defp sample_task(model, source) do - tenant = model.tenant + tenant = model.session.tenant key = schema_cache_key(model, source) {:task, {:schema, source}, @@ -350,7 +343,7 @@ defmodule Efsql.Tui.App do end defp schema_cache_key(model, source) do - {model.cluster_file, model.storage_id, model.tenant_id, source} + {model.cluster_file, model.session.storage_id, model.session.tenant_name, source} end defp put_schema(model, source, schema) do @@ -398,13 +391,13 @@ defmodule Efsql.Tui.App do defp query(model, {:key, :up}), do: {history(model, 1), []} defp query(model, {:key, :down}), do: {history(model, -1), []} - defp query(%Model{input: "", rows: rows} = model, {:key, :tab}) when is_list(rows) do + defp query(%Model{input: "", result: %Efsql.Result{}} = model, {:key, :tab}) do {%{model | qfocus: :results}, []} end defp query(model, {:key, :tab}), do: {complete(model), []} - defp query(%Model{tenant: t} = model, {:key, :esc}) when t != nil, + defp query(%Model{session: %Efsql.Session{tenant: t}} = model, {:key, :esc}) when t != nil, do: {%{model | mode: :schema}, []} defp query(model, {:key, :esc}), do: {%{model | mode: :navigator}, []} @@ -433,27 +426,21 @@ defmodule Efsql.Tui.App do defp set(model, text) do model = %{model | input: "", qcursor: 0} - case Efsql.Settings.set(model.settings, text) do - {:ok, settings, message} -> {%{model | settings: settings, flash: {:info, message}}, []} - {:error, usage} -> {%{model | flash: {:error, usage}}, []} + case Efsql.Settings.set(model.session.settings, text) do + {:ok, settings, message} -> + session = %{model.session | settings: settings} + {%{model | session: session, flash: {:info, message}}, []} + + {:error, usage} -> + {%{model | flash: {:error, usage}}, []} end end defp run_query(model, sql) do sql = if String.ends_with?(sql, ";"), do: sql, else: sql <> ";" - session = %{ - tenant: model.tenant, - tenants: model.tenants, - storage_id: model.storage_id, - settings: model.settings - } - - fun = fn -> - started = System.monotonic_time(:millisecond) - {plan, rows, tenants} = Efsql.Tui.Session.qall(sql, session) - {plan, rows, tenants, System.monotonic_time(:millisecond) - started} - end + session = model.session + fun = fn -> Efsql.Session.run(session, sql) end model = %{ model @@ -557,8 +544,8 @@ defmodule Efsql.Tui.App do {move_row(model, if(c == "j", do: 1, else: -1)), []} end - defp results(%Model{rows: rows} = model, {:key, :enter}) do - case Enum.at(rows || [], model.row_cursor) do + defp results(model, {:key, :enter}) do + case Enum.at(result_rows(model), model.row_cursor) do nil -> {model, []} @@ -580,10 +567,13 @@ defmodule Efsql.Tui.App do defp results(model, _msg), do: {model, []} defp move_row(model, delta) do - count = length(model.rows || []) + count = length(result_rows(model)) %{model | row_cursor: clamp(model.row_cursor + delta, count)} end + defp result_rows(%Model{result: %Efsql.Result{rows: rows}}), do: rows + defp result_rows(_model), do: [] + # -- inspector -- defp inspector(model, {:key, key}) when key in [:esc, :enter] do @@ -686,7 +676,10 @@ defmodule Efsql.Tui.App do 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 + def inspector_fields(%Model{ + irow: row, + result: %Efsql.Result{plan: %Efsql.Physical.Plan{columns: [_ | _] = columns}} + }) do listed = Enum.filter(columns, &Map.has_key?(row, &1)) listed ++ Enum.sort(Map.keys(row) -- listed) end @@ -723,20 +716,17 @@ defmodule Efsql.Tui.App do {refilter(%{model | busy: nil, nav_entries: Enum.sort(entries)}), []} end - defp done(model, :activate, {:ok, {storage_id, tenant_id, tenant}}) do + defp done(model, :activate, {:ok, %Efsql.Session{tenant: tenant} = session}) do model = %{ model | busy: "listing sources", - storage_id: storage_id, - tenant_id: tenant_id, - tenant: tenant, + session: session, mode: :schema, sources: nil, src_cursor: 0, schemas: %{}, focus: :sources, - rows: nil, - plan: nil, + result: nil, qerror: nil } @@ -751,24 +741,21 @@ defmodule Efsql.Tui.App do {%{put_schema(model, source, schema) | busy: nil}, []} end - defp done(model, :query, {:ok, {plan, rows, tenants, elapsed}}) do - columns = Efsql.Render.columns(plan, rows) + defp done(model, :query, {:ok, {%Efsql.Result{} = result, %Efsql.Session{} = session}}) do + %Efsql.Result{rows: rows, columns: columns} = result cells = render_cells(rows, columns) model = %{ model | busy: nil, - rows: rows, - columns: columns, + result: result, + session: session, cells: cells, col_widths: col_widths(columns, cells), col_align: col_aligns(rows, columns), - plan: plan, qerror: nil, - elapsed_ms: elapsed, row_cursor: 0, - row_scroll: 0, - tenants: tenants + row_scroll: 0 } {model, []} diff --git a/lib/efsql/tui/session.ex b/lib/efsql/tui/session.ex deleted file mode 100644 index ec62dba..0000000 --- a/lib/efsql/tui/session.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Efsql.Tui.Session do - @moduledoc """ - Session-aware query execution: statements without a tenant qualifier run - against the session's active tenant, so `select id from users;` works - once a tenant is activated in the Navigator, and `*.users` reads every - tenant of the active storage id. Tenant-qualified statements behave - exactly as in the line CLI. - """ - - alias Efsql.Logical - - def qall(sql, session) do - sql - |> Efsql.Parser.sql_to_logical() - |> in_session(session) - |> Efsql.run_logical(options(session), session.tenants) - end - - defp options(%{settings: settings}), do: Efsql.Settings.query_options(settings) - defp options(_session), do: [] - - defp in_session(%Logical.Select{prefix: {:all_tenants, nil}} = logical, session), - do: %Logical.Select{logical | prefix: {:all_tenants, session[:storage_id]}} - - defp in_session(%Logical.Select{prefix: nil}, %{tenant: nil}) do - raise Efsql.Exception.Unsupported, - "no active tenant — qualify the table (tenant.table) or pick a tenant in the navigator" - end - - defp in_session(%Logical.Select{prefix: nil} = logical, %{tenant: tenant}), - do: %Logical.Select{logical | tenant: tenant} - - defp in_session(logical, _session), do: logical -end diff --git a/lib/efsql/tui/view.ex b/lib/efsql/tui/view.ex index 033dd37..48ad04d 100644 --- a/lib/efsql/tui/view.ex +++ b/lib/efsql/tui/view.ex @@ -57,9 +57,9 @@ defmodule Efsql.Tui.View do defp title_bar(model, _cols) do session = - case model.tenant_id do + case model.session.tenant_name do nil -> "no tenant" - tenant_id -> "#{model.storage_id} / #{tenant_id}" + tenant_name -> "#{model.session.storage_id} / #{tenant_name}" end [ @@ -263,22 +263,18 @@ defmodule Efsql.Tui.View do [] end + show_plan? = model.show_plan? + plan_lines = - if model.show_plan? and model.plan do - [ - [ - {:dim, - Render.truncate(" plan: " <> inspect(model.plan.access, width: :infinity), cols)} - ] - ] ++ + case model.result do + %Efsql.Result{plan: plan} when show_plan? -> [ - [ - {:dim, - Render.truncate(" ops: " <> inspect(model.plan.ops, width: :infinity), cols)} - ] + [{:dim, Render.truncate(" plan: " <> inspect(plan.access, width: :infinity), cols)}], + [{:dim, Render.truncate(" ops: " <> inspect(plan.ops, width: :infinity), cols)}] ] - else - [] + + _ -> + [] end used = 1 + length(completion) + length(plan_lines) @@ -287,19 +283,19 @@ defmodule Efsql.Tui.View do {[input_line] ++ completion ++ plan_lines ++ [[]] ++ results, cursor} end - defp results_lines(%Model{rows: nil}, _height, _cols) do + defp results_lines(%Model{result: nil}, _height, _cols) do [[{:dim, " no results yet — run a statement, or Esc for the schema browser"}]] end - defp results_lines(%Model{rows: []} = model, _height, _cols) do - [[{:dim, " (0 rows, #{model.elapsed_ms} ms)" <> snapshots_note(model)}]] + defp results_lines(%Model{result: %Efsql.Result{rows: []} = result}, _height, _cols) do + [[{:dim, " (0 rows, #{result.elapsed_ms} ms)" <> transactions_note(result)}]] end - defp results_lines(model, height, cols) do + defp results_lines(%Model{result: result} = model, height, cols) do {widths, column_map} = Columns.layout(model.col_widths, cols) aligns = Enum.map(column_map, &align_of(model, &1)) visible = max(height - 2, 1) - count = length(model.rows) + count = length(result.rows) start = if model.row_cursor >= visible, do: model.row_cursor - visible + 1, else: 0 names = Enum.map(column_map, &header_of(model, &1)) @@ -317,25 +313,20 @@ defmodule Efsql.Tui.View do footer = [ {:dim, - " (#{count} rows, #{model.elapsed_ms} ms)" <> - snapshots_note(model) <> hidden_note(model, column_map) <> browse_hint(model, count)} + " (#{count} rows, #{result.elapsed_ms} ms)" <> + transactions_note(result) <> + hidden_note(result, column_map) <> browse_hint(model, count)} ] [header] ++ rows ++ [footer] end # A query across tenants read in batches says how many transactions it took. - defp snapshots_note(%Model{plan: %Efsql.Physical.Plan{} = plan}) do - case Efsql.Fanout.transactions(plan) do - 1 -> "" - n -> " · #{n} transactions" - end - end - - defp snapshots_note(_model), do: "" + defp transactions_note(%Efsql.Result{transactions: 1}), do: "" + defp transactions_note(%Efsql.Result{transactions: n}), do: " · #{n} transactions" defp header_of(_model, :split), do: "…" - defp header_of(model, {:col, ix}), do: model.columns |> Enum.at(ix) |> to_string() + defp header_of(model, {:col, ix}), do: model.result.columns |> Enum.at(ix) |> to_string() defp align_of(_model, :split), do: :left defp align_of(model, {:col, ix}), do: Enum.at(model.col_align, ix, :left) @@ -351,8 +342,8 @@ defmodule Efsql.Tui.View do # Dropped columns are reported the way DuckDB reports them: a bare `…` # column otherwise reads as truncated data rather than a layout decision. - defp hidden_note(model, column_map) do - total = length(model.columns) + defp hidden_note(result, column_map) do + total = length(result.columns) shown = Enum.count(column_map, &match?({:col, _}, &1)) if shown < total, do: " — #{total} columns (#{shown} shown)", else: "" diff --git a/test/discover_test.exs b/test/discover_test.exs index 86a64e5..9f48998 100644 --- a/test/discover_test.exs +++ b/test/discover_test.exs @@ -38,10 +38,10 @@ defmodule EfsqlTest.Integration.Discover do test "session qall runs unqualified statements against the active tenant", context do tenant = context[:tenant] - assert {_plan, [%{name: "Alice"}], _tenants} = - Efsql.Tui.Session.qall( - "select id, name from users where name = 'Alice';", - %{tenant: tenant, tenants: %{}} + assert {%Efsql.Result{rows: [%{name: "Alice"}]}, %Efsql.Session{}} = + Efsql.Session.run( + %Efsql.Session{tenant: tenant}, + "select id, name from users where name = 'Alice';" ) end end diff --git a/test/session_test.exs b/test/session_test.exs new file mode 100644 index 0000000..fd76949 --- /dev/null +++ b/test/session_test.exs @@ -0,0 +1,26 @@ +defmodule Efsql.SessionTest do + use ExUnit.Case, async: true + + alias Efsql.Exception.Unsupported + alias Efsql.Physical.Plan + alias Efsql.Result + alias Efsql.Session + + test "a table without a tenant needs an active one" do + assert_raise Unsupported, ~r/no active tenant/, fn -> + Session.run(%Session{}, "select id from users;") + end + end + + test "a result knows its columns and how many transactions it took" do + rows = [%{id: 1, name: "a"}] + + assert %Result{rows: ^rows, columns: [:name, :id], transactions: 1, elapsed_ms: 5} = + Result.new(%Plan{access: {:fan_out, []}, columns: [:name, :id]}, rows, 5) + + batches = {:batches, [{:fan_out, []}, {:fan_out, []}, {:fan_out, []}], 2} + + assert %Result{columns: [:id, :name], transactions: 3} = + Result.new(%Plan{access: batches}, rows) + end +end diff --git a/test/tui_app_test.exs b/test/tui_app_test.exs index 6e18c47..c5120e3 100644 --- a/test/tui_app_test.exs +++ b/test/tui_app_test.exs @@ -52,7 +52,13 @@ defmodule Efsql.Tui.AppTest do {:key, :enter}, {:done, :nav_entries, {:ok, ["demo", "staging"]}}, {:key, :enter}, - {:done, :activate, {:ok, {"Ecto.Adapters.FoundationDB", "demo", :fake_tenant}}}, + {:done, :activate, + {:ok, + %Efsql.Session{ + storage_id: "Ecto.Adapters.FoundationDB", + tenant_name: "demo", + tenant: :fake_tenant + }}}, {:done, :sources, {:ok, ["orders", "users"]}} ]) @@ -144,7 +150,9 @@ defmodule Efsql.Tui.AppTest do assert model.input == "" plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} - {model, _} = feed(model, [{:done, :query, {:ok, {plan, [], %{}, 1}}}]) + + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, [], 1), model.session}}}]) {model, _} = feed(model, [{:key, :up}]) assert model.input == "select id from users where name;" @@ -155,7 +163,9 @@ defmodule Efsql.Tui.AppTest do rows = for i <- 1..3, do: %{id: "000#{i}", name: "User #{i}", notes: nil} plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} - {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 7}}}]) + + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 7), model.session}}}]) text = frame_text(model) assert text =~ "(3 rows, 7 ms)" @@ -184,8 +194,10 @@ defmodule Efsql.Tui.AppTest do columns: [:name, :"count(*)", :id] } - {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) - assert model.columns == [:name, :"count(*)", :id] + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) + + assert model.result.columns == [:name, :"count(*)", :id] assert frame_text(model) =~ ~r/name\s+count\(\*\)\s+id/ # the inspector lists fields in the same order @@ -198,22 +210,25 @@ defmodule Efsql.Tui.AppTest do 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] + + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) + + assert model.result.columns == [:id, :age, :name] end test "\\set tenant_batch sets and clears the tenants per transaction" do model = %{activated() | mode: :query} {model, _} = feed(model, chars("\\set tenant_batch 25") ++ [{:key, :enter}]) - assert model.settings.tenant_batch == 25 + assert model.session.settings.tenant_batch == 25 assert model.flash == {:info, "tenant_batch set to 25"} {model, _} = feed(model, chars("\\set tenant_batch off") ++ [{:key, :enter}]) - assert model.settings.tenant_batch == nil + assert model.session.settings.tenant_batch == nil {model, _} = feed(model, chars("\\set tenant_batch 0") ++ [{:key, :enter}]) - assert model.settings.tenant_batch == nil + assert model.session.settings.tenant_batch == nil assert {:error, "usage: " <> _} = model.flash end @@ -223,11 +238,17 @@ defmodule Efsql.Tui.AppTest do batches = {:batches, [{:fan_out, []}, {:fan_out, []}], 4} plan = %Efsql.Physical.Plan{access: batches, ops: [], columns: [:name, :_tenant]} - {batched, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + + {batched, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) + assert frame_text(batched) =~ "(2 rows, 1 ms) · 2 transactions" plan = %Efsql.Physical.Plan{plan | access: {:fan_out, []}} - {single, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + + {single, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) + refute frame_text(single) =~ "transactions" end @@ -237,7 +258,9 @@ defmodule Efsql.Tui.AppTest do rows = [%{id: uuid, item: "widget"}] plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} - {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) text = frame_text(model) assert text =~ uuid @@ -250,7 +273,10 @@ defmodule Efsql.Tui.AppTest do rows = [%{id: "0001", blob: long}] plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} - {model, _} = feed(model, [{:done, :query, {:ok, {plan, rows, %{}, 1}}}]) + + {model, _} = + feed(model, [{:done, :query, {:ok, {Efsql.Result.new(plan, rows, 1), model.session}}}]) + {model, _} = feed(model, [{:key, :tab}, {:key, :enter}]) assert model.mode == :inspector @@ -273,7 +299,10 @@ defmodule Efsql.Tui.AppTest do plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} {model, _} = - feed(model, [{:done, :query, {:ok, {plan, [%{id: "0001", blob: long}], %{}, 1}}}]) + feed(model, [ + {:done, :query, + {:ok, {Efsql.Result.new(plan, [%{id: "0001", blob: long}], 1), model.session}}} + ]) {model, _} = feed(model, [{:key, :tab}, {:key, :enter}]) model @@ -383,7 +412,12 @@ defmodule Efsql.Tui.AppTest do test "a value that fits shows no scroll indicator" do model = %{activated() | mode: :query} plan = %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} - {model, _} = feed(model, [{:done, :query, {:ok, {plan, [%{id: "0001"}], %{}, 1}}}]) + + {model, _} = + feed(model, [ + {:done, :query, {:ok, {Efsql.Result.new(plan, [%{id: "0001"}], 1), model.session}}} + ]) + {model, _} = feed(model, [{:key, :tab}, {:key, :enter}]) # the position indicator only appears when the value overflows diff --git a/test/tui_width_test.exs b/test/tui_width_test.exs index 0242b0e..d7de904 100644 --- a/test/tui_width_test.exs +++ b/test/tui_width_test.exs @@ -7,6 +7,7 @@ defmodule Efsql.Tui.WidthTest do alias Efsql.Tui.View @plan %Efsql.Physical.Plan{access: {:pk_range, nil, nil, nil, []}, ops: []} + @session %Efsql.Session{tenant: :t, tenant_name: "demo", storage_id: "s"} defp results(rows, size) do {model, _} = @@ -15,11 +16,9 @@ defmodule Efsql.Tui.WidthTest do size: size, mode: :query, busy: "q", - tenant: :t, - tenant_id: "demo", - storage_id: "s" + session: @session }, - {:done, :query, {:ok, {@plan, rows, %{}, 1}}} + {:done, :query, {:ok, {Efsql.Result.new(@plan, rows, 1), @session}}} ) model From 379d1ee323347cc45d2b0f9aeb7e3239888e1e82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:27:21 +0000 Subject: [PATCH 3/3] Add Efsql.Predicate: one module for what a condition means A predicate was a bare tuple interpreted in five places: Logical.predicate_field/1 (its field), Executor.eval (checking a row), Planner.kind/1 and range?/1 (how the planner can serve it), Planner.pred_to_expr/1 (the Ecto expression pushed to the adapter), and Logical.take_first/2. Adding a kind of predicate meant finding them all. Efsql.Predicate now owns them, one clause group per predicate type: field/1, matches?/2 (with SQL NULL semantics), pushdown/1 (:key | :in | :index | :filter), equality?/1, range?/1, take_first/2, to_ecto/1 and field_ref/1. The planner's classification becomes a group_by on pushdown/1. Rewrite keeps its rewrite passes, which transform predicates rather than interpret them. A new predicate type (NOT, <>, ...) is now one module, plus its translation and any rewrite. Row evaluation, previously tested only end to end, now has unit tests: NULL semantics, LIKE, IN, ranges, classification and the Ecto encoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFBZUrerge6bbWVCHpS5Ko --- lib/efsql/executor.ex | 56 +------------ lib/efsql/fanout.ex | 6 +- lib/efsql/logical.ex | 28 +------ lib/efsql/parser.ex | 2 +- lib/efsql/planner.ex | 68 +++++----------- lib/efsql/predicate.ex | 158 ++++++++++++++++++++++++++++++++++++ lib/efsql/rewrite.ex | 3 +- test/planner_split_test.exs | 8 +- test/predicate_test.exs | 96 ++++++++++++++++++++++ test/rewrite_test.exs | 6 +- 10 files changed, 290 insertions(+), 141 deletions(-) create mode 100644 lib/efsql/predicate.ex create mode 100644 test/predicate_test.exs diff --git a/lib/efsql/executor.ex b/lib/efsql/executor.ex index dfb1227..242f67f 100644 --- a/lib/efsql/executor.ex +++ b/lib/efsql/executor.ex @@ -17,20 +17,12 @@ defmodule Efsql.Executor do alias Efsql.Exception.Unsupported alias Efsql.Physical.Plan + alias Efsql.Predicate alias Efsql.Types # transaction_too_old (retryable) and transaction_timed_out @too_long [1007, 1031] - @cmp_ops ~w[== > >= < <=]a - @cmp_results %{ - ==: [:eq], - >: [:gt], - >=: [:gt, :eq], - <: [:lt], - <=: [:lt, :eq] - } - def run(%Plan{access: access, ops: ops}) do Enum.reduce(ops, fetch(access), &apply_op/2) end @@ -68,9 +60,6 @@ defmodule Efsql.Executor do end end - @doc "Whether `row` satisfies every one of `predicates`, with SQL NULL semantics." - def matches?(row, predicates), do: Enum.all?(predicates, &eval(&1, row)) - # -- access nodes -- # One transaction per batch, up to `concurrency` at a time. @@ -145,7 +134,8 @@ defmodule Efsql.Executor do # -- operators -- - defp apply_op({:filter, predicates}, rows), do: Enum.filter(rows, &matches?(&1, predicates)) + defp apply_op({:filter, predicates}, rows), + do: Enum.filter(rows, &Predicate.matches?(&1, predicates)) defp apply_op({:aggregate, group_by, aggregates}, rows) do Efsql.Aggregate.run(rows, group_by, aggregates) @@ -161,46 +151,6 @@ defmodule Efsql.Executor do Enum.map(rows, &Map.take(&1, fields)) end - # -- predicate evaluation -- - - defp eval({:cmp, op, field, param}, row) when op in @cmp_ops, - do: on_value(row, field, &(Types.compare(&1, param) in Map.fetch!(@cmp_results, op))) - - defp eval({:range, field, {lower_op, lower}, {upper_op, upper}}, row) do - eval({:cmp, lower_op, field, lower}, row) and eval({:cmp, upper_op, field, upper}, row) - end - - defp eval({:in, field, values}, row), - do: - on_value(row, field, fn value -> Enum.any?(values, &(Types.compare(value, &1) == :eq)) end) - - defp eval({:is_null, field}, row), do: Map.get(row, field) == nil - defp eval({:not_null, field}, row), do: Map.get(row, field) != nil - - defp eval({:like, field, pattern}, row), - do: on_value(row, field, &Regex.match?(like_regex(pattern), &1)) - - defp eval({:not_like, field, pattern}, row), - do: on_value(row, field, &(not Regex.match?(like_regex(pattern), &1))) - - # A NULL (nil or absent) field matches no comparison, LIKE or IN. - defp on_value(row, field, fun) do - case Map.get(row, field) do - nil -> false - value -> fun.(value) - end - end - - defp like_regex(pattern) do - source = - pattern - |> Regex.escape() - |> String.replace("%", ".*") - |> String.replace("_", ".") - - Regex.compile!("\\A" <> source <> "\\z", "s") - end - # -- sorting -- @doc """ diff --git a/lib/efsql/fanout.ex b/lib/efsql/fanout.ex index cc6eda9..01bbc4d 100644 --- a/lib/efsql/fanout.ex +++ b/lib/efsql/fanout.ex @@ -28,10 +28,10 @@ defmodule Efsql.Fanout do """ alias Efsql.Exception.Unsupported - alias Efsql.Executor alias Efsql.Logical alias Efsql.Physical.Plan alias Efsql.Planner + alias Efsql.Predicate @default_max_tenants 100 @default_batch_concurrency 4 @@ -70,12 +70,12 @@ defmodule Efsql.Fanout do end {tenant_preds, row_preds} = - Enum.split_with(logical.predicates, &(Logical.predicate_field(&1) == :_tenant)) + Enum.split_with(logical.predicates, &(Predicate.field(&1) == :_tenant)) names = storage_id |> list_tenants() - |> Enum.filter(&Executor.matches?(%{_tenant: &1}, tenant_preds)) + |> Enum.filter(&Predicate.matches?(%{_tenant: &1}, tenant_preds)) |> Enum.sort() if batch == nil and length(names) > max_tenants do diff --git a/lib/efsql/logical.ex b/lib/efsql/logical.ex index 58e8b84..9c34b0b 100644 --- a/lib/efsql/logical.ex +++ b/lib/efsql/logical.ex @@ -3,14 +3,7 @@ defmodule Efsql.Logical do The logical query representation: what the SQL statement asks for, independent of how it will be executed. - Predicates are an implicitly AND-ed list of: - - * `{:cmp, op, field, value}` — op in `:== :> :>= :< :<=` - * `{:range, field, {lower_op, value}, {upper_op, value}}` — a two-sided - bound, `lower_op` in `:> :>=`, `upper_op` in `:< :<=` - * `{:like, field, pattern}` / `{:not_like, field, pattern}` - * `{:in, field, values}` - * `{:is_null, field}` / `{:not_null, field}` + Its predicates are an implicitly AND-ed list of `Efsql.Predicate`s. The primary key is the pseudo-field `:_`. A query across every tenant of a storage id (`*.table`) also has `:_tenant`, the tenant's name, which @@ -46,23 +39,4 @@ defmodule Efsql.Logical do # [{output_name :: atom, function :: atom, field :: atom | :star}] aggregates: [] end - - @doc """ - Takes the first predicate `fun` accepts out of `predicates`: - `{predicate, rest}`, or `{nil, predicates}` when none does. - """ - def take_first(predicates, fun) do - case Enum.split_while(predicates, &(not fun.(&1))) do - {_before, []} -> {nil, predicates} - {before, [match | rest]} -> {match, before ++ rest} - end - end - - def predicate_field({:cmp, _op, field, _value}), do: field - def predicate_field({:range, field, _lower, _upper}), do: field - def predicate_field({:like, field, _pattern}), do: field - def predicate_field({:not_like, field, _pattern}), do: field - def predicate_field({:in, field, _values}), do: field - def predicate_field({:is_null, field}), do: field - def predicate_field({:not_null, field}), do: field end diff --git a/lib/efsql/parser.ex b/lib/efsql/parser.ex index 3d4637b..71fe778 100644 --- a/lib/efsql/parser.ex +++ b/lib/efsql/parser.ex @@ -49,7 +49,7 @@ defmodule Efsql.Parser do defp check_tenant_field(logical) do used = if(is_list(logical.projection), do: logical.projection, else: []) ++ - Enum.map(logical.predicates, &Logical.predicate_field/1) ++ + Enum.map(logical.predicates, &Efsql.Predicate.field/1) ++ Enum.map(logical.order, &elem(&1, 1)) ++ (logical.group_by || []) ++ Enum.map(logical.aggregates, &elem(&1, 2)) diff --git a/lib/efsql/planner.ex b/lib/efsql/planner.ex index 574e9a0..234ea34 100644 --- a/lib/efsql/planner.ex +++ b/lib/efsql/planner.ex @@ -26,7 +26,7 @@ defmodule Efsql.Planner do alias Efsql.Exception.Unsupported alias Efsql.Logical alias Efsql.Physical.Plan - alias Efsql.Types + alias Efsql.Predicate alias EctoFoundationDB.Layer.Metadata @pk_field :_ @@ -169,7 +169,7 @@ defmodule Efsql.Planner do if idx == nil do field == :id or Enum.any?(indexes, fn ix -> List.first(ix[:fields]) == field end) else - equal_fields = for {:cmp, :==, f, _v} <- pushed, do: f + equal_fields = for p <- pushed, Predicate.equality?(p), do: Predicate.field(p) List.first(idx[:fields] -- equal_fields) == field end end @@ -204,7 +204,7 @@ defmodule Efsql.Planner do |> Enum.filter(fn {_idx, pushed, _rest} -> pushed != [] end) |> Enum.max_by( fn {_idx, pushed, _rest} -> - {length(pushed), Enum.count(pushed, &match?({:cmp, :==, _, _}, &1))} + {length(pushed), Enum.count(pushed, &Predicate.equality?/1)} end, fn -> {nil, [], pushables} end ) @@ -215,12 +215,12 @@ defmodule Efsql.Planner do defp match_index([], pushables, acc), do: {Enum.reverse(acc), pushables} defp match_index([field | rest_fields], pushables, acc) do - case take_pred(pushables, field, &match?({:cmp, :==, _, _}, &1)) do + case take_pred(pushables, field, &Predicate.equality?/1) do {eq, rest} when eq != nil -> match_index(rest_fields, rest, [eq | acc]) {nil, _} -> - case take_pred(pushables, field, &range?/1) do + case take_pred(pushables, field, &Predicate.range?/1) do {range, rest} when range != nil -> {Enum.reverse([range | acc]), rest} {nil, _} -> {Enum.reverse(acc), pushables} end @@ -228,11 +228,7 @@ defmodule Efsql.Planner do end defp take_pred(preds, field, pred_fun), - do: Logical.take_first(preds, &(Logical.predicate_field(&1) == field and pred_fun.(&1))) - - defp range?({:range, _field, _lower, _upper}), do: true - defp range?({:cmp, op, _field, _value}) when op in ~w[> >= < <=]a, do: true - defp range?(_), do: false + do: Predicate.take_first(preds, &(Predicate.field(&1) == field and pred_fun.(&1))) defp load_indexes(%Logical.Select{tenant: tenant, source: source}), do: indexes(tenant, source) @@ -244,27 +240,13 @@ defmodule Efsql.Planner do # -- classification -- + # Grouped by how they can be served (see Predicate.pushdown/1), each + # group in its original order. defp classify(preds) do - Enum.reduce(preds, {[], [], [], []}, fn pred, {pks, ins, pushables, residuals} -> - case kind(pred) do - :pk -> {pks ++ [pred], ins, pushables, residuals} - :in -> {pks, ins ++ [pred], pushables, residuals} - :pushable -> {pks, ins, pushables ++ [pred], residuals} - :residual -> {pks, ins, pushables, residuals ++ [pred]} - end - end) + groups = Enum.group_by(preds, &Predicate.pushdown/1) + {groups[:key] || [], groups[:in] || [], groups[:index] || [], groups[:filter] || []} end - defp kind({:in, _field, _values}), do: :in - defp kind({:like, _field, _pattern}), do: :residual - defp kind({:not_like, _field, _pattern}), do: :residual - defp kind({:is_null, _field}), do: :residual - defp kind({:not_null, _field}), do: :residual - defp kind({:cmp, _op, @pk_field, _value}), do: :pk - defp kind({:range, @pk_field, _lower, _upper}), do: :pk - defp kind({:cmp, _op, _field, _value}), do: :pushable - defp kind({:range, _field, _lower, _upper}), do: :pushable - # -- IN planning -- defp plan_in( @@ -375,7 +357,7 @@ defmodule Efsql.Planner do defp takes(fields, residual, sort) do needed = - (Enum.map(residual, &Logical.predicate_field/1) ++ Enum.map(sort, fn {_dir, f} -> f end)) + (Enum.map(residual, &Predicate.field/1) ++ Enum.map(sort, fn {_dir, f} -> f end)) |> Enum.uniq() case needed -- fields do @@ -385,7 +367,7 @@ defmodule Efsql.Planner do end defp ensure_residual_evaluable!(pred) do - if Logical.predicate_field(pred) == @pk_field do + if Predicate.field(pred) == @pk_field do raise Unsupported, "a constraint on the primary key '_' cannot be combined with this query shape" end @@ -425,7 +407,12 @@ defmodule Efsql.Planner do defp put_wheres(%Ecto.Query{} = q, preds) do wheres = Enum.map(preds, fn pred -> - %Ecto.Query.BooleanExpr{op: :and, expr: pred_to_expr(pred), params: [], subqueries: []} + %Ecto.Query.BooleanExpr{ + op: :and, + expr: Predicate.to_ecto(pred), + params: [], + subqueries: [] + } end) %Ecto.Query{q | wheres: wheres} @@ -434,7 +421,7 @@ defmodule Efsql.Planner do defp put_order(%Ecto.Query{} = q, []), do: q defp put_order(%Ecto.Query{} = q, order) do - expr = Enum.map(order, fn {dir, field} -> {dir, field_ref(field)} end) + expr = Enum.map(order, fn {dir, field} -> {dir, Predicate.field_ref(field)} end) %Ecto.Query{q | order_bys: [%Ecto.Query.ByExpr{expr: expr, params: [], subqueries: []}]} end @@ -443,21 +430,4 @@ defmodule Efsql.Planner do defp put_limit(%Ecto.Query{} = q, n) do %Ecto.Query{q | limit: %Ecto.Query.LimitExpr{expr: n, with_ties: false, params: []}} end - - defp pred_to_expr({:cmp, op, field, value}) do - {op, [], [field_ref(field), Types.index_key(value)]} - end - - defp pred_to_expr({:range, field, {lower_op, lower}, {upper_op, upper}}) do - {{lower_op, [], [field_ref(field), Types.index_key(lower)]}, - {upper_op, [], [field_ref(field), Types.index_key(upper)]}} - end - - defp pred_to_expr(pred) do - raise Unsupported, "predicate #{inspect(pred)} cannot be pushed to the adapter" - end - - defp field_ref(field) do - {{:., [], [{:&, [], [0]}, field]}, [], []} - end end diff --git a/lib/efsql/predicate.ex b/lib/efsql/predicate.ex new file mode 100644 index 0000000..eb42d73 --- /dev/null +++ b/lib/efsql/predicate.ex @@ -0,0 +1,158 @@ +defmodule Efsql.Predicate do + @moduledoc """ + A condition from a `WHERE` clause, and everything it means: which field + it's on, whether a row satisfies it, how the planner can serve it, and + the Ecto expression that pushes it to the adapter. A query's predicates + are an implicitly AND-ed list of: + + * `{:cmp, op, field, value}` — op in `:== :> :>= :< :<=` + * `{:range, field, {lower_op, value}, {upper_op, value}}` — a two-sided + bound, `lower_op` in `:> :>=`, `upper_op` in `:< :<=` + * `{:like, field, pattern}` / `{:not_like, field, pattern}` + * `{:in, field, values}` + * `{:is_null, field}` / `{:not_null, field}` + + The primary key is the pseudo-field `:_`. + + A new kind of predicate is added here, clause by clause, plus its + translation in `Efsql.Parser` and any rewrite in `Efsql.Rewrite`. + """ + + alias Efsql.Exception.Unsupported + alias Efsql.Types + + @type field :: atom() + @type t :: + {:cmp, :== | :> | :>= | :< | :<=, field(), term()} + | {:range, field(), {:> | :>=, term()}, {:< | :<=, term()}} + | {:like | :not_like, field(), String.t()} + | {:in, field(), [term()]} + | {:is_null | :not_null, field()} + + @pk_field :_ + + @cmp_results %{ + ==: [:eq], + >: [:gt], + >=: [:gt, :eq], + <: [:lt], + <=: [:lt, :eq] + } + + @doc "The field a predicate constrains." + @spec field(t()) :: field() + def field({:cmp, _op, field, _value}), do: field + def field({:range, field, _lower, _upper}), do: field + def field({:like, field, _pattern}), do: field + def field({:not_like, field, _pattern}), do: field + def field({:in, field, _values}), do: field + def field({:is_null, field}), do: field + def field({:not_null, field}), do: field + + # -- evaluation -- + + @doc """ + Whether `row` satisfies every one of `predicates`, with SQL NULL + semantics: a NULL (nil or absent) field matches no comparison, LIKE or + IN, NOT LIKE included, and IS NULL matches it. Values compare with + `Efsql.Types.compare/2`. + """ + @spec matches?(map(), [t()]) :: boolean() + def matches?(row, predicates), do: Enum.all?(predicates, &eval(&1, row)) + + defp eval({:cmp, op, field, param}, row), + do: on_value(row, field, &(Types.compare(&1, param) in Map.fetch!(@cmp_results, op))) + + defp eval({:range, field, {lower_op, lower}, {upper_op, upper}}, row), + do: eval({:cmp, lower_op, field, lower}, row) and eval({:cmp, upper_op, field, upper}, row) + + defp eval({:in, field, values}, row), + do: + on_value(row, field, fn value -> Enum.any?(values, &(Types.compare(value, &1) == :eq)) end) + + defp eval({:is_null, field}, row), do: Map.get(row, field) == nil + defp eval({:not_null, field}, row), do: Map.get(row, field) != nil + + defp eval({:like, field, pattern}, row), + do: on_value(row, field, &Regex.match?(like_regex(pattern), &1)) + + defp eval({:not_like, field, pattern}, row), + do: on_value(row, field, &(not Regex.match?(like_regex(pattern), &1))) + + defp on_value(row, field, fun) do + case Map.get(row, field) do + nil -> false + value -> fun.(value) + end + end + + defp like_regex(pattern) do + source = + pattern + |> Regex.escape() + |> String.replace("%", ".*") + |> String.replace("_", ".") + + Regex.compile!("\\A" <> source <> "\\z", "s") + end + + # -- planning -- + + @doc """ + How the planner can serve a predicate: + + * `:key` - a constraint on the primary key, read as a key range + * `:in` - an `IN` list, read as one lookup per value when an index or + the key can serve them + * `:index` - an equality or range an index may serve + * `:filter` - only checked on the rows read + """ + @spec pushdown(t()) :: :key | :in | :index | :filter + def pushdown({:in, _field, _values}), do: :in + def pushdown({:cmp, _op, @pk_field, _value}), do: :key + def pushdown({:range, @pk_field, _lower, _upper}), do: :key + def pushdown({:cmp, _op, _field, _value}), do: :index + def pushdown({:range, _field, _lower, _upper}), do: :index + def pushdown(_predicate), do: :filter + + @doc "Whether a predicate pins its field to one value." + def equality?({:cmp, :==, _field, _value}), do: true + def equality?(_predicate), do: false + + @doc "Whether a predicate bounds its field on one or both sides." + def range?({:range, _field, _lower, _upper}), do: true + def range?({:cmp, op, _field, _value}) when op in ~w[> >= < <=]a, do: true + def range?(_predicate), do: false + + @doc """ + Takes the first predicate `fun` accepts out of `predicates`: + `{predicate, rest}`, or `{nil, predicates}` when none does. + """ + def take_first(predicates, fun) do + case Enum.split_while(predicates, &(not fun.(&1))) do + {_before, []} -> {nil, predicates} + {before, [match | rest]} -> {match, before ++ rest} + end + end + + # -- pushdown -- + + @doc """ + The Ecto `where` expression that pushes an `:index` predicate to the + adapter, its values encoded as the adapter's indexes store them (see + `Efsql.Types.index_key/1`). + """ + def to_ecto({:cmp, op, field, value}), + do: {op, [], [field_ref(field), Types.index_key(value)]} + + def to_ecto({:range, field, {lower_op, lower}, {upper_op, upper}}) do + {{lower_op, [], [field_ref(field), Types.index_key(lower)]}, + {upper_op, [], [field_ref(field), Types.index_key(upper)]}} + end + + def to_ecto(predicate), + do: raise(Unsupported, "predicate #{inspect(predicate)} cannot be pushed to the adapter") + + @doc "An Ecto reference to a field of the query's (only) source." + def field_ref(field), do: {{:., [], [{:&, [], [0]}, field]}, [], []} +end diff --git a/lib/efsql/rewrite.ex b/lib/efsql/rewrite.ex index 7313db7..22bcb13 100644 --- a/lib/efsql/rewrite.ex +++ b/lib/efsql/rewrite.ex @@ -7,6 +7,7 @@ defmodule Efsql.Rewrite do """ alias Efsql.Logical + alias Efsql.Predicate @lower_ops ~w[> >=]a @upper_ops ~w[< <=]a @@ -49,7 +50,7 @@ defmodule Efsql.Rewrite do defp merge_ranges([pred | rest], acc), do: merge_ranges(rest, [pred | acc]) defp take_bound(preds, field, ops) do - Logical.take_first(preds, fn + Predicate.take_first(preds, fn {:cmp, op, f, _v} -> op in ops and f == field _ -> false end) diff --git a/test/planner_split_test.exs b/test/planner_split_test.exs index a92fbfc..1b4463f 100644 --- a/test/planner_split_test.exs +++ b/test/planner_split_test.exs @@ -1,7 +1,7 @@ defmodule Efsql.PlannerSplitTest do use ExUnit.Case, async: true - alias Efsql.Executor + alias Efsql.Predicate alias Efsql.Logical alias Efsql.Planner @@ -45,8 +45,8 @@ defmodule Efsql.PlannerSplitTest do test "tenant names are chosen with the usual predicate semantics" do preds = [{:like, :_tenant, "acme%"}] - assert Executor.matches?(%{_tenant: "acme-eu"}, preds) - refute Executor.matches?(%{_tenant: "globex"}, preds) - assert Executor.matches?(%{_tenant: "anything"}, []) + assert Predicate.matches?(%{_tenant: "acme-eu"}, preds) + refute Predicate.matches?(%{_tenant: "globex"}, preds) + assert Predicate.matches?(%{_tenant: "anything"}, []) end end diff --git a/test/predicate_test.exs b/test/predicate_test.exs new file mode 100644 index 0000000..7256f6d --- /dev/null +++ b/test/predicate_test.exs @@ -0,0 +1,96 @@ +defmodule Efsql.PredicateTest do + use ExUnit.Case, async: true + + alias Efsql.Predicate + + @row %{name: "Alice", age: 30, notes: nil, at: ~D[2024-03-01], price: Decimal.new("9.50")} + + defp holds?(predicate, row \\ @row), do: Predicate.matches?(row, [predicate]) + + test "comparisons use Types.compare, so dates and Decimals compare by value" do + assert holds?({:cmp, :==, :age, 30}) + assert holds?({:cmp, :>=, :at, ~D[2024-01-01]}) + refute holds?({:cmp, :<, :at, ~D[2024-01-01]}) + assert holds?({:cmp, :<, :price, 10}) + assert holds?({:range, :age, {:>, 20}, {:<=, 30}}) + refute holds?({:range, :age, {:>, 30}, {:<=, 40}}) + end + + test "LIKE and IN" do + assert holds?({:like, :name, "Al%"}) + assert holds?({:like, :name, "_lice"}) + refute holds?({:like, :name, "al%"}) + assert holds?({:not_like, :name, "B%"}) + assert holds?({:in, :age, [10, 30]}) + refute holds?({:in, :age, [10, 20]}) + end + + test "a NULL field matches no comparison, LIKE or IN, not even NOT LIKE" do + for predicate <- [ + {:cmp, :==, :notes, "x"}, + {:range, :notes, {:>, "a"}, {:<, "z"}}, + {:like, :notes, "%"}, + {:not_like, :notes, "x"}, + {:in, :notes, ["x"]}, + {:cmp, :==, :missing, 1} + ] do + refute holds?(predicate), inspect(predicate) + end + + assert holds?({:is_null, :notes}) + assert holds?({:is_null, :missing}) + refute holds?({:not_null, :notes}) + assert holds?({:not_null, :name}) + end + + test "every predicate must hold" do + assert Predicate.matches?(@row, []) + refute Predicate.matches?(@row, [{:cmp, :==, :age, 30}, {:is_null, :name}]) + end + + test "field" do + for {predicate, field} <- [ + {{:cmp, :==, :a, 1}, :a}, + {{:range, :b, {:>, 1}, {:<, 2}}, :b}, + {{:like, :c, "x"}, :c}, + {{:not_like, :d, "x"}, :d}, + {{:in, :e, [1]}, :e}, + {{:is_null, :f}, :f}, + {{:not_null, :g}, :g} + ] do + assert Predicate.field(predicate) == field + end + end + + test "pushdown says how the planner can serve a predicate" do + assert Predicate.pushdown({:cmp, :==, :_, "k"}) == :key + assert Predicate.pushdown({:range, :_, {:>=, "a"}, {:<, "b"}}) == :key + assert Predicate.pushdown({:in, :_, ["a", "b"]}) == :in + assert Predicate.pushdown({:in, :name, ["a"]}) == :in + assert Predicate.pushdown({:cmp, :>, :age, 1}) == :index + assert Predicate.pushdown({:range, :age, {:>, 1}, {:<, 2}}) == :index + + for predicate <- [{:like, :a, "x"}, {:not_like, :a, "x"}, {:is_null, :a}, {:not_null, :a}] do + assert Predicate.pushdown(predicate) == :filter + end + end + + test "equality and range" do + assert Predicate.equality?({:cmp, :==, :a, 1}) + refute Predicate.equality?({:cmp, :>, :a, 1}) + assert Predicate.range?({:cmp, :<=, :a, 1}) + assert Predicate.range?({:range, :a, {:>, 1}, {:<, 2}}) + refute Predicate.range?({:cmp, :==, :a, 1}) + refute Predicate.range?({:like, :a, "x"}) + end + + test "to_ecto encodes values as the index stores them" do + ref = Predicate.field_ref(:at) + assert {:>=, [], [^ref, "20240301"]} = Predicate.to_ecto({:cmp, :>=, :at, ~D[2024-03-01]}) + + assert {{:>, [], [_, 1]}, {:<, [], [_, 2]}} = + Predicate.to_ecto({:range, :age, {:>, 1}, {:<, 2}}) + + assert_raise Efsql.Exception.Unsupported, fn -> Predicate.to_ecto({:like, :a, "x"}) end + end +end diff --git a/test/rewrite_test.exs b/test/rewrite_test.exs index e0a39b5..2c008a5 100644 --- a/test/rewrite_test.exs +++ b/test/rewrite_test.exs @@ -75,13 +75,13 @@ defmodule Efsql.RewriteTest do end end - test "Logical.take_first takes the first match out, keeping the rest in order" do + test "Predicate.take_first takes the first match out, keeping the rest in order" do preds = [{:is_null, :a}, {:cmp, :>, :b, 1}, {:cmp, :>, :c, 2}] gt? = &match?({:cmp, :>, _, _}, &1) - assert Efsql.Logical.take_first(preds, gt?) == + assert Efsql.Predicate.take_first(preds, gt?) == {{:cmp, :>, :b, 1}, [{:is_null, :a}, {:cmp, :>, :c, 2}]} - assert Efsql.Logical.take_first(preds, fn _ -> false end) == {nil, preds} + assert Efsql.Predicate.take_first(preds, fn _ -> false end) == {nil, preds} end end