defmodule ExSQL.Codegen do @moduledoc """ Runtime query codegen — the JIT path. Two shapes compile today: * single-table scan / filter / project / sort, and * two-table `LEFT JOIN` on an equi-key — hash-joined, with NULL-filled non-matches, residual `ON` filters pushed into the index build, and 2-arg `COALESCE(col, literal)` projections. For either, this compiles a parsed `%ExSQL.AST.Select{}` into a native BEAM function (via `Module.create/3`, which the compiler/JIT turns into machine code) rather than walking the AST per row. Columns are read positionally from the stored row tuple (`:erlang.element/2`), and SQL semantics are preserved by delegating comparisons/booleans/affinity to `ExSQL.Value` — the exact functions the tree walker uses. Literals are lifted to a `params` tuple so a query *shape* compiles once and is reused for any literal values (`WHERE x > 30` and `WHERE x > 40` share a module; the value is a runtime argument). Compilation costs a few ms and only pays off for repeated/prepared queries, so the caller gates on that and falls back to the tree walker for anything `compilable?/2` rejects. The generated function takes the table's positional rows (`[{rowid, tuple}]`, scan order) plus a `params` tuple and returns `[[value, ...]]` — the same shape as a tree-walked result's rows. The scan form is `run(rows, params)`; the join form is `run(left_rows, right_rows, params)`, which indexes the right side by the (affinity-canonicalized) join key and probes it once per left row, then re-verifies each candidate with an exact comparison before emitting. """ alias ExSQL.{Database, Table, Value} @cmp_ops [:gt, :lt, :ge, :le, :eq, :ne] # ---- shape detection ------------------------------------------------------- @doc """ Returns `{:ok, spec}` if `stmt` is a supported single-table scan shape, else `:no`. The spec carries column positions, a param-lifted WHERE/ORDER/LIMIT IR, and the lifted literal values. """ @spec compilable?(ExSQL.AST.Select.t(), Database.t()) :: {:ok, map()} | :no def compilable?(%ExSQL.AST.Select{} = stmt, db) do with true <- simple_shape?(stmt), true <- default_pragmas?(db) do case stmt.from do {:table, name, _alias} -> compile_scan(stmt, db, name) {:join, %{left: true, right: false, natural: false}, {:table, lt, la}, {:table, rt, ra}, {:on, on}} -> compile_left_join(stmt, db, {lt, la}, {rt, ra}, on) _ -> :no end else _ -> :no end catch :uncompilable -> :no end def compilable?(_stmt, _db), do: :no defp compile_scan(stmt, db, name) do with true <- is_binary(name), {:ok, %Table{without_rowid: false} = table} <- fetch_plain_table(db, name) do index = Table.column_index(table) meta = column_meta(table) projection = plain_projection!(stmt.columns, index) {acc, where_ir} = lift_where(stmt.where, index, meta, []) {acc, order_ir} = lift_order(stmt.order_by, projection, index, meta, acc) {acc, limit_ir} = lift_int(stmt.limit, acc) {params, offset_ir} = lift_int(stmt.offset, acc) {:ok, %{ kind: :scan, table: name, columns: Enum.map(projection, &elem(&1, 0)), positions: Enum.map(projection, &elem(&1, 1)), names: Enum.map(projection, &elem(&1, 2)), affinities: Enum.map(projection, fn {_k, pos, _n} -> meta |> Map.fetch!(pos) |> elem(0) end), where: where_ir, order: order_ir, limit: limit_ir, offset: offset_ir, params: params |> Enum.reverse() |> List.to_tuple() }} else _ -> :no end end # ---- left-join shape ------------------------------------------------------- # # `L LEFT JOIN R ON [AND ]`, two base tables, # WHERE over left columns only, projection of qualified columns and 2-arg # COALESCE(col, literal). Compiled to a hash join: the right side is indexed by # the (affinity-canonicalized) join key with the residual ON filters applied, # then each left row probes it; unmatched left rows are NULL-filled. defp compile_left_join(stmt, db, {lt, la}, {rt, ra}, on) do with true <- is_binary(lt), true <- is_binary(rt), {:ok, %Table{without_rowid: false} = ltab} <- fetch_plain_table(db, lt), {:ok, %Table{without_rowid: false} = rtab} <- fetch_plain_table(db, rt) do lqual = la || lt rqual = ra || rt if lqual == rqual, do: throw(:uncompilable) ctx = %{ lqual: lqual, rqual: rqual, lindex: Table.column_index(ltab), lmeta: column_meta(ltab), rindex: Table.column_index(rtab), rmeta: column_meta(rtab) } {join_key, residual_ir, acc} = lift_on(on, ctx, []) {acc, where_ir} = lift_cmp_tree(stmt.where, ctx, :left, acc) {acc, projection} = lift_join_projection(stmt.columns, ctx, acc) {acc, order_ir} = lift_join_order(stmt.order_by, projection, ctx, acc) {acc, limit_ir} = lift_int(stmt.limit, acc) {params, offset_ir} = lift_int(stmt.offset, acc) {:ok, %{ kind: :left_join, left_table: lt, right_table: rt, join_key: join_key, residual: residual_ir, where: where_ir, projection: Enum.map(projection, &elem(&1, 0)), names: Enum.map(projection, &elem(&1, 1)), affinities: Enum.map(projection, &elem(&1, 2)), order: order_ir, limit: limit_ir, offset: offset_ir, params: params |> Enum.reverse() |> List.to_tuple() }} else _ -> :no end end # Resolve a (qualifier, name) reference to {:left | :right, position, affinity, # collation}. Unqualified names must live in exactly one side. defp resolve_col(qual, name, ctx) do key = Table.key(name) cond do qual == ctx.lqual -> side_col(:left, key, ctx) qual == ctx.rqual -> side_col(:right, key, ctx) qual != nil -> throw(:uncompilable) true -> unqualified_col(key, ctx) end end defp unqualified_col(key, ctx) do case {Map.has_key?(ctx.lindex, key), Map.has_key?(ctx.rindex, key)} do {true, false} -> side_col(:left, key, ctx) {false, true} -> side_col(:right, key, ctx) _ -> throw(:uncompilable) end end defp side_col(:left, key, ctx) do pos = Map.get(ctx.lindex, key) || throw(:uncompilable) {affinity, collation} = Map.fetch!(ctx.lmeta, pos) {:left, pos, affinity, collation} end defp side_col(:right, key, ctx) do pos = Map.get(ctx.rindex, key) || throw(:uncompilable) {affinity, collation} = Map.fetch!(ctx.rmeta, pos) {:right, pos, affinity, collation} end # ON → {join_key, residual_ir, acc}. Exactly one equi-key (`left.col = right.col`) # plus any number of right-only filter comparisons (pushed into the index build). defp lift_on(on, ctx, acc) do leaves = flatten_and(on) {equis, others} = Enum.split_with(leaves, &equi_key?(&1, ctx)) case equis do [equi] -> join_key = build_join_key(equi, ctx) {acc, residual} = Enum.reduce(others, {acc, nil}, fn leaf, {acc, ir} -> {acc, leaf_ir} = lift_cmp_leaf(leaf, ctx, :right, acc) {acc, and_ir(ir, leaf_ir)} end) {join_key, residual, acc} _ -> throw(:uncompilable) end end defp flatten_and({:binary, :and, l, r}), do: flatten_and(l) ++ flatten_and(r) defp flatten_and({:binary, op, _a, _b} = leaf) when op in @cmp_ops, do: [leaf] defp flatten_and(_other), do: throw(:uncompilable) # An equi-key leaf is `col = col` with the two columns on opposite sides. defp equi_key?({:binary, :eq, {:column, qa, na}, {:column, qb, nb}}, ctx) do {sa, _, _, _} = resolve_col(qa, na, ctx) {sb, _, _, _} = resolve_col(qb, nb, ctx) sa != sb catch :uncompilable -> false end defp equi_key?(_leaf, _ctx), do: false defp build_join_key({:binary, :eq, a, b}, ctx) do {:column, qa, na} = a {:column, qb, nb} = b ca = resolve_col(qa, na, ctx) cb = resolve_col(qb, nb, ctx) {{:left, lpos, laff, lcoll}, {:right, rpos, raff, rcoll}} = order_sides(ca, cb) class = key_class(laff, raff) if class == :text, do: binary_collation!(lcoll) && binary_collation!(rcoll) {lpos, rpos, class, laff, raff} end defp order_sides({:left, _, _, _} = l, {:right, _, _, _} = r), do: {l, r} defp order_sides({:right, _, _, _} = r, {:left, _, _, _} = l), do: {l, r} # The comparison-affinity class for the equi-key (mirrors `comparison_coerce`): # numeric if either side is numeric, else text if either is text, else none. defp key_class(a, b) do cond do a in [:integer, :real, :numeric] or b in [:integer, :real, :numeric] -> :numeric a == :text or b == :text -> :text true -> :none end end # A WHERE/residual condition tree restricted to one side's columns. Produces IR # over {:lcol | :rcol, pos, affinity} and {:param, i, :blob}. defp lift_cmp_tree(nil, _ctx, _side, acc), do: {acc, nil} defp lift_cmp_tree({:binary, :and, l, r}, ctx, side, acc) do {acc, lir} = lift_cmp_tree(l, ctx, side, acc) {acc, rir} = lift_cmp_tree(r, ctx, side, acc) {acc, {:and, lir, rir}} end defp lift_cmp_tree({:binary, op, _a, _b} = leaf, ctx, side, acc) when op in @cmp_ops do lift_cmp_leaf(leaf, ctx, side, acc) end defp lift_cmp_tree(_other, _ctx, _side, _acc), do: throw(:uncompilable) defp lift_cmp_leaf({:binary, op, a, b}, ctx, side, acc) do {acc, air} = lift_join_operand(a, ctx, side, acc) {acc, bir} = lift_join_operand(b, ctx, side, acc) {acc, {:cmp, op, air, bir}} end defp lift_join_operand({:column, q, name}, ctx, side, acc) do {rside, pos, affinity, collation} = resolve_col(q, name, ctx) if rside != side, do: throw(:uncompilable) binary_collation!(collation) {acc, {col_tag(side), pos, affinity}} end defp lift_join_operand({:literal, value}, _ctx, _side, acc), do: {[value | acc], {:param, length(acc), :blob}} defp lift_join_operand(_other, _ctx, _side, _acc), do: throw(:uncompilable) defp col_tag(:left), do: :lcol defp col_tag(:right), do: :rcol defp and_ir(nil, ir), do: ir defp and_ir(l, r), do: {:and, l, r} # Projection items → [{read_ir, name, affinity}]. Supports qualified columns and # 2-arg COALESCE(col, literal). A coalesce result has no affinity (NONE/:blob). defp lift_join_projection(columns, ctx, acc) do {acc, items} = Enum.reduce(columns, {acc, []}, fn col, {acc, items} -> {acc, item} = lift_proj_item(col, ctx, acc) {acc, [item | items]} end) {acc, Enum.reverse(items)} end defp lift_proj_item({{:column, q, name}, alias_name}, ctx, acc) do {side, pos, affinity, collation} = resolve_col(q, name, ctx) binary_collation!(collation) {acc, {{col_tag(side), pos}, alias_name || name, affinity}} end defp lift_proj_item( {{:function, "coalesce", [{:column, q, name}, {:literal, dflt}]}, alias_name}, ctx, acc ) do {side, pos, _affinity, collation} = resolve_col(q, name, ctx) binary_collation!(collation) name = alias_name || "coalesce(#{q_prefix(q)}#{name}, ?)" {[dflt | acc], {{:coalesce, {col_tag(side), pos}, length(acc)}, name, :blob}} end defp lift_proj_item(_col, _ctx, _acc), do: throw(:uncompilable) defp q_prefix(nil), do: "" defp q_prefix(q), do: "#{q}." # ORDER BY for a join: positional (into the projection) or a qualified column. defp lift_join_order([], _projection, _ctx, acc), do: {acc, nil} defp lift_join_order(order_by, projection, ctx, acc) do keys = Enum.map(order_by, fn {{:literal, pos}, dir} when is_integer(pos) and pos >= 1 and pos <= length(projection) -> {read_ir, _name, _aff} = Enum.at(projection, pos - 1) {:key, read_ir, dir} {{:column, q, name}, dir} -> {side, pos, _aff, collation} = resolve_col(q, name, ctx) binary_collation!(collation) {:key, {col_tag(side), pos}, dir} _ -> throw(:uncompilable) end) {acc, keys} end defp simple_shape?(%ExSQL.AST.Select{} = s) do not s.distinct and s.group_by == [] and s.having == nil and map_size(s.windows) == 0 end # Pragmas that change SELECT output (result order or column names) aren't # modeled by the generated code — fall back to the tree walker when set. defp default_pragmas?(db) do not db.reverse_unordered_selects and not db.full_column_names and db.short_column_names end # position => {affinity, collation}, from the cached frame columns. defp column_meta(table) do table |> Table.frame_columns() |> Enum.with_index() |> Map.new(fn {{_key, _name, affinity, collation}, index} -> {index, {affinity, collation}} end) end # Resolve via the schema-aware lookup (main/temp/attached order) so codegen # scans the *same* table the tree walker would — and so views (not in # `db.tables`) fall through to `:no`. defp fetch_plain_table(db, name) do case Database.lookup_table(db, name) do {:ok, %Table{} = t} -> {:ok, t} _ -> :no end end defp plain_projection!(columns, index) do Enum.map(columns, fn {{:column, _q, cname}, alias_name} -> {Table.key(cname), column_pos!(cname, index), alias_name || cname} _ -> throw(:uncompilable) end) end defp column_pos!(name, index) do key = Table.key(name) case index do %{^key => pos} -> pos _ -> throw(:uncompilable) end end # WHERE → IR: {:and, l, r} | {:cmp, op, a, b}; operands carry affinity: # {:col, pos, affinity} | {:param, i, affinity}. Comparison affinity is applied # via `comparison_coerce`, matching the tree walker. Non-binary collations fall # back (a global compiled module can't see connection-local collations). defp lift_where(nil, _index, _meta, acc), do: {acc, nil} defp lift_where({:binary, :and, l, r}, index, meta, acc) do {acc, lir} = lift_where(l, index, meta, acc) {acc, rir} = lift_where(r, index, meta, acc) {acc, {:and, lir, rir}} end defp lift_where({:binary, op, a, b}, index, meta, acc) when op in @cmp_ops do {acc, air} = lift_operand(a, index, meta, acc) {acc, bir} = lift_operand(b, index, meta, acc) {acc, {:cmp, op, air, bir}} end defp lift_where(_other, _index, _meta, _acc), do: throw(:uncompilable) defp lift_operand({:column, _q, name}, index, meta, acc) do pos = column_pos!(name, index) {affinity, collation} = Map.fetch!(meta, pos) binary_collation!(collation) {acc, {:col, pos, affinity}} end # A literal has affinity NONE (`:blob`); `comparison_coerce` then applies the # column's affinity to it (SQLite §4.2). defp lift_operand({:literal, value}, _index, _meta, acc), do: {[value | acc], {:param, length(acc), :blob}} defp lift_operand(_other, _index, _meta, _acc), do: throw(:uncompilable) defp binary_collation!(coll) when coll in [nil, :binary], do: :ok defp binary_collation!(_coll), do: throw(:uncompilable) # ORDER BY → [{:read, pos, dir}]: positions reference projected columns. Sort # uses binary collation, so a non-binary-collated key column falls back. defp lift_order([], _projection, _index, _meta, acc), do: {acc, nil} defp lift_order(order_by, projection, index, meta, acc) do positions = Enum.map(projection, &elem(&1, 1)) keys = Enum.map(order_by, fn {{:literal, pos}, dir} when is_integer(pos) and pos >= 1 and pos <= length(positions) -> read_pos = Enum.at(positions, pos - 1) order_key!(read_pos, dir, meta) {{:column, _q, name}, dir} -> order_key!(column_pos!(name, index), dir, meta) _ -> throw(:uncompilable) end) {acc, keys} end defp order_key!(pos, dir, meta) do {_affinity, collation} = Map.fetch!(meta, pos) binary_collation!(collation) {:read, pos, dir} end defp lift_int(nil, acc), do: {acc, nil} defp lift_int({:literal, n}, acc) when is_integer(n) and n >= 0, do: {[n | acc], {:param, length(acc)}} defp lift_int(_other, _acc), do: throw(:uncompilable) # ---- top-level entry (cache + gate + run) ---------------------------------- @doc """ Runs `stmt` via a compiled native function when enabled, the shape is supported, and it's been seen enough times to compile. Returns `{:ok, %ExSQL.Result{}}` or `:fallback` (use the tree walker). Enabled by `EXSQL_CODEGEN=1`. """ @spec run_select(Database.t(), ExSQL.AST.Select.t()) :: {:ok, ExSQL.Result.t()} | :fallback def run_select(db, stmt) do if enabled?() do case compilable?(stmt, db) do {:ok, spec} -> dispatch(db, spec) :no -> :fallback end else :fallback end end defp enabled?, do: System.get_env("EXSQL_CODEGEN") == "1" defp dispatch(db, spec) do key = shape_key(spec) case ExSQL.Codegen.Cache.fetch(key) do {:hit, module} -> run_compiled(db, spec, module) {:compile, _n} -> {:ok, %{module: module}} = build(spec) ExSQL.Codegen.Cache.store(key, module) run_compiled(db, spec, module) :too_few -> :fallback end end # The shape: everything but the lifted literal *values* (so all literal values # of one structure share a compiled module). defp shape_key(%{kind: :left_join} = s) do {:left_join, s.left_table, s.right_table, s.join_key, s.residual, s.where, s.projection, s.names, s.order, s.limit, s.offset} end defp shape_key(s) do {:scan, s.table, s.positions, s.names, s.where, s.order, s.limit, s.offset} end defp run_compiled(db, %{kind: :left_join} = spec, module) do with {:ok, ltab} <- Database.lookup_table(db, spec.left_table), {:ok, rtab} <- Database.lookup_table(db, spec.right_table) do rows = module.run(Table.scan_positional(ltab), Table.scan_positional(rtab), spec.params) select_result(spec, rows) else _ -> :fallback end end defp run_compiled(db, spec, module) do case Database.lookup_table(db, spec.table) do {:ok, table} -> select_result(spec, module.run(Table.scan_positional(table), spec.params)) _ -> :fallback end end defp select_result(spec, rows) do {:ok, %ExSQL.Result{ command: :select, columns: spec.names, rows: rows, rows_affected: 0, affinities: spec.affinities }} end # ---- compilation ----------------------------------------------------------- @doc "Compiles a `spec` (from `compilable?/2`) into a native module. Returns `{:ok, %{module, params, columns}}`." @spec build(map()) :: {:ok, map()} def build(spec) do # A globally-unique module name (not a hash of the shape) — the ETS cache, # keyed by the full shape, is the source of truth for reuse, so two distinct # shapes can never collide onto the same module. module = :"Elixir.ExSQL.Codegen.Q#{System.unique_integer([:positive, :monotonic])}" ast = if spec.kind == :left_join, do: function_ast_join(spec), else: function_ast(spec) Module.create(module, ast, Macro.Env.location(__ENV__)) {:ok, %{module: module, params: spec.params}} end defp function_ast(spec) do row_match = quote(do: {_rowid, row}) where_guard = emit_where(spec.where) proj = quote(do: [unquote_splicing(Enum.map(spec.positions, &read_pos/1))]) body = case spec.order do nil -> collected = if where_guard do quote(do: for(unquote(row_match) <- rows, unquote(where_guard), do: unquote(proj))) else quote(do: for(unquote(row_match) <- rows, do: unquote(proj))) end quote do unquote(collected) |> unquote(emit_clamp(spec)) end keys -> key_reads = quote( do: {unquote(Enum.map(keys, fn {:read, p, _} -> read_pos(p) end)), unquote(proj)} ) dirs = Enum.map(keys, fn {:read, _, dir} -> dir end) collected = if where_guard do quote( do: for(unquote(row_match) <- rows, unquote(where_guard), do: unquote(key_reads)) ) else quote(do: for(unquote(row_match) <- rows, do: unquote(key_reads))) end quote do unquote(collected) |> Enum.sort(&ExSQL.Codegen.order_le?(elem(&1, 0), elem(&2, 0), unquote(dirs))) |> Enum.map(&elem(&1, 1)) |> unquote(emit_clamp(spec)) end end quote do def run(rows, params) do _ = params unquote(body) end end end # OFFSET then LIMIT, applied to the (already projected) list. defp emit_clamp(spec) do dropped = case spec.offset do nil -> quote(do: & &1) {:param, i} -> quote(do: &Enum.drop(&1, elem(params, unquote(i)))) end taken = case spec.limit do nil -> quote(do: & &1) {:param, i} -> quote(do: &Enum.take(&1, elem(params, unquote(i)))) end quote(do: unquote(dropped).() |> unquote(taken).()) end # A WHERE node → a boolean expression over `row`/`params`. compare_op/sql_and # match the tree walker exactly (true passes; nil/false fail the comprehension). defp emit_where(nil), do: nil defp emit_where({:and, l, r}) do quote(do: ExSQL.Value.sql_and(unquote(emit_where(l)), unquote(emit_where(r)))) end defp emit_where({:cmp, op, a, b}) do {av, aaff} = emit_operand(a) {bv, baff} = emit_operand(b) quote( do: ExSQL.Codegen.cmp(unquote(op), unquote(av), unquote(aaff), unquote(bv), unquote(baff)) ) end defp emit_operand({:col, pos, aff}), do: {read_pos(pos), aff} defp emit_operand({:param, i, aff}), do: {quote(do: elem(params, unquote(i))), aff} # Positional read of a stored tuple cell (element/2 is 1-based). defp read_pos(pos), do: quote(do: :erlang.element(unquote(pos + 1), row)) # ---- left-join emission ---------------------------------------------------- # # run(left_rows, right_rows, params): index the right side by the (canonical) # join key with the residual ON filters applied, scan the left side (filtered by # WHERE), probe + re-verify each match exactly, and NULL-fill unmatched lefts. defp function_ast_join(spec) do {lpos, rpos, class, laff, raff} = spec.join_key keep_fun = case spec.residual do nil -> quote(do: fn _srow -> true end) ir -> quote(do: fn srow -> unquote(emit_cond(ir)) end) end key_fun = quote do fn srow -> ExSQL.Codegen.join_canon(:erlang.element(unquote(rpos + 1), srow), unquote(class)) end end probe = quote do lkey = :erlang.element(unquote(lpos + 1), lrow) matches = case ExSQL.Codegen.join_canon(lkey, unquote(class)) do :__exsql_null__ -> [] k -> rindex |> Map.get(k, []) |> Enum.filter(fn srow -> ExSQL.Codegen.cmp( :eq, lkey, unquote(laff), :erlang.element(unquote(rpos + 1), srow), unquote(raff) ) == true end) end case matches do [] -> [unquote(join_row(spec, :null))] ms -> for srow <- ms, do: unquote(join_row(spec, :matched)) end end loop = case emit_cond(spec.where) do nil -> quote(do: for({_lrid, lrow} <- left_rows, do: unquote(probe))) guard -> quote(do: for({_lrid, lrow} <- left_rows, unquote(guard) == true, do: unquote(probe))) end quote do def run(left_rows, right_rows, params) do _ = params rindex = ExSQL.Codegen.build_index(right_rows, unquote(keep_fun), unquote(key_fun)) unquote(loop) |> Enum.concat() |> unquote(join_post(spec)) end end end # Sort (when ordered) then OFFSET/LIMIT, over the concatenated output rows. defp join_post(%{order: nil} = spec), do: emit_clamp(spec) defp join_post(%{order: keys} = spec) do dirs = Enum.map(keys, fn {:key, _ir, dir} -> dir end) quote do Enum.sort(&ExSQL.Codegen.order_le?(elem(&1, 0), elem(&2, 0), unquote(dirs))) |> Enum.map(&elem(&1, 1)) |> unquote(emit_clamp(spec)) end end # One output row for a (lrow, srow) pair. `:matched` reads the right tuple; # `:null` fills the right side with NULL. Ordered queries pair it with sort keys. defp join_row(%{order: nil} = spec, branch), do: emit_proj(spec.projection, branch) defp join_row(%{order: keys} = spec, branch) do key_reads = Enum.map(keys, fn {:key, ir, _dir} -> emit_proj_read(ir, branch) end) proj = emit_proj(spec.projection, branch) quote(do: {[unquote_splicing(key_reads)], unquote(proj)}) end defp emit_proj(projection, branch) do quote(do: [unquote_splicing(Enum.map(projection, &emit_proj_read(&1, branch)))]) end defp emit_proj_read({:coalesce, colref, pi}, branch) do quote do case unquote(emit_col_read(colref, branch)) do nil -> elem(params, unquote(pi)) v -> v end end end defp emit_proj_read(colref, branch), do: emit_col_read(colref, branch) defp emit_col_read({:lcol, pos}, _branch), do: quote(do: :erlang.element(unquote(pos + 1), lrow)) defp emit_col_read({:rcol, pos}, :matched), do: quote(do: :erlang.element(unquote(pos + 1), srow)) defp emit_col_read({:rcol, _pos}, :null), do: nil # A condition IR ({:and,…} | {:cmp,…}) → a boolean expression over lrow/srow/params. # Each operand reads from its own side, which is the variable in scope. defp emit_cond(nil), do: nil defp emit_cond({:and, l, r}) do quote(do: ExSQL.Value.sql_and(unquote(emit_cond(l)), unquote(emit_cond(r)))) end defp emit_cond({:cmp, op, a, b}) do {av, aaff} = emit_cond_operand(a) {bv, baff} = emit_cond_operand(b) quote( do: ExSQL.Codegen.cmp(unquote(op), unquote(av), unquote(aaff), unquote(bv), unquote(baff)) ) end defp emit_cond_operand({:lcol, pos, aff}), do: {quote(do: :erlang.element(unquote(pos + 1), lrow)), aff} defp emit_cond_operand({:rcol, pos, aff}), do: {quote(do: :erlang.element(unquote(pos + 1), srow)), aff} defp emit_cond_operand({:param, i, aff}), do: {quote(do: elem(params, unquote(i))), aff} # ---- runtime helpers (called from generated code) ------------------------- @doc false # A comparison with SQLite affinity + (binary) collation — exactly the tree # walker's `comparison_operands` + `compare_op`, but called directly from # generated code with the affinities baked in. def cmp(op, a, a_aff, b, b_aff) do {x, y} = Value.comparison_coerce(a, a_aff, b, b_aff) Value.compare_op(op, x, y, :binary) end @doc false # Mirrors the tree walker's ORDER BY: compare keys left-to-right, NULL sorts # low via `Value.compare`, direction flips lt/gt. Returns `ka <= kb`. # Recursive (no per-comparison `Enum.zip` allocation). def order_le?([a | ta], [b | tb], [dir | td]) do case Value.compare(a, b, :binary) do :eq -> order_le?(ta, tb, td) :lt -> dir == :asc :gt -> dir == :desc end end def order_le?([], [], []), do: true @doc false # Builds the right-side hash index for a left join: `canonical_key => [srow, …]` # in scan order, keeping only rows that pass the residual ON filters (`keep_fun`) # and have a non-NULL join key (`key_fun` returns `:__exsql_null__` for NULL, # which never matches — as `NULL = x` is never true). def build_index(rows, keep_fun, key_fun) do rows |> Enum.reduce(%{}, fn {_rid, srow}, acc -> if keep_fun.(srow) do case key_fun.(srow) do :__exsql_null__ -> acc k -> Map.update(acc, k, [srow], &[srow | &1]) end else acc end end) |> Map.new(fn {k, rows} -> {k, :lists.reverse(rows)} end) end @doc false # A canonical hash key for an equi-join value under the comparison-affinity # `class` (see `key_class/2`). Values that compare equal must share a key; an # exact `cmp/5` re-verify in the generated code then rejects any over-grouping. # Numeric coercion canonicalizes `5.0`/`5`; NULL keys never match. def join_canon(nil, _class), do: :__exsql_null__ def join_canon(value, :numeric), do: value |> Value.apply_affinity(:numeric) |> norm_num() def join_canon(value, :text), do: Value.apply_affinity(value, :text) def join_canon(value, :none), do: norm_num(value) defp norm_num(f) when is_float(f) do t = trunc(f) if t == f, do: t, else: f end defp norm_num(v), do: v end