Packages

ash_postgres

2.10.0
2.10.0 2.9.1 2.9.0 2.8.0 2.7.0 2.6.32 2.6.31 2.6.30 2.6.29 2.6.28 2.6.27 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.22 2.5.21 2.5.20 2.5.19 2.5.18 2.5.17 2.5.16 2.5.15 2.5.14 2.5.13 2.5.12 2.5.11 2.5.10 2.5.9 2.5.8 2.5.7 2.5.6 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.21 2.4.20 2.4.19 2.4.18 2.4.17 2.4.16 2.4.15 2.4.14 2.4.13 2.4.12 2.4.11 2.4.10 2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4.0 2.3.1 2.3.0 2.2.5 2.2.4 2.2.3 2.2.2 2.2.1 2.2.0 2.1.19 2.1.18 2.1.17 2.1.15 2.1.14 2.1.13 2.1.12 2.1.11 2.1.10 2.1.9 2.1.8 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.12 2.0.11 2.0.10 2.0.9 2.0.8 2.0.7 2.0.6 2.0.5 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 2.0.0-rc.15 2.0.0-rc.14 2.0.0-rc.13 2.0.0-rc.12 2.0.0-rc.11 2.0.0-rc.10 2.0.0-rc.9 2.0.0-rc.8 2.0.0-rc.7 2.0.0-rc.6 2.0.0-rc.5 2.0.0-rc.4 2.0.0-rc.3 2.0.0-rc.2 2.0.0-rc.1 2.0.0-rc.0 1.5.30 1.5.29 1.5.28 1.5.27 1.5.26 1.5.25 1.5.24 1.5.23 1.5.22 1.5.21 1.5.20 1.5.19 1.5.18 1.5.17 1.5.16 1.5.15 1.5.14 1.5.13 1.5.12 1.5.11 1.5.10 1.5.9 1.5.8 1.5.7 1.5.6 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 1.4.0 1.3.68 1.3.67 1.3.66 1.3.65 1.3.64 1.3.63 1.3.62 1.3.61 1.3.60 1.3.59 1.3.58 1.3.56 1.3.55 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 1.3.45 1.3.44 1.3.43 1.3.42 1.3.41 1.3.40 1.3.39 1.3.38 1.3.37 1.3.36 1.3.35 1.3.34 1.3.33 1.3.32 1.3.31 1.3.30 1.3.29 1.3.28 1.3.26 1.3.25 1.3.24 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 1.3.18 1.3.17 1.3.16 1.3.15 1.3.14 1.3.12 1.3.11 1.3.10 1.3.9 1.3.8 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.3.0-rc.4 1.3.0-rc.3 1.3.0-rc.2 1.3.0-rc.0 1.2.6 1.2.5 1.2.4 1.2.3 1.2.2 1.2.1 1.2.0 1.2.0-rc.1 1.2.0-rc.0 1.1.3 1.1.2 1.1.1 1.1.0 1.0.0 1.0.0-rc.9 1.0.0-rc.8 1.0.0-rc.7 1.0.0-rc.6 1.0.0-rc.5 1.0.0-rc.4 1.0.0-rc.3 1.0.0-rc.2 1.0.0-rc.1 1.0.0-rc.0 1.0.0-pre.3 1.0.0-pre.2 1.0.0-pre.1 1.0.0-pre.0 0.43.0 0.42.0-rc.7 0.42.0-rc.6 0.42.0-rc.5 0.42.0-rc.4 0.42.0-rc.3 0.42.0-rc.1 0.42.0-rc.0 0.41.7 0.41.6 0.41.5 0.41.4 0.41.3 0.41.2 0.41.1 0.41.0-rc0 retired 0.41.0-rc.9 0.41.0-rc.8 0.41.0-rc.7 0.41.0-rc.6 0.41.0-rc.5 0.41.0-rc.4 0.41.0-rc.3 0.41.0-rc.2 0.41.0-rc.1 0.41.0-rc.0 0.40.11 0.40.10 0.40.9 0.40.8 0.40.7 0.40.6 0.40.5 0.40.4 0.40.3 0.40.2 0.40.1 0.40.0-rc5 0.40.0-rc4 0.40.0-rc3 0.40.0-rc2 0.40.0-rc1 0.39.0-rc0 0.38.11 0.38.10 0.38.9 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.8 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.5 0.35.4 0.35.3 0.35.1 0.34.5 0.34.4 0.34.3 0.34.2 0.34.1 0.34.0 0.33.1 0.33.0 0.32.2 0.32.1 0.32.0 0.31.1 0.30.1 0.29.6 0.29.5 0.29.4 0.29.3 0.29.2 0.29.1 0.28.1 0.28.0 0.27.0 0.26.2 0.26.1 0.26.0 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.0 0.23.2 0.23.1 0.23.0 0.22.1 0.22.0 0.21.0 0.20.1 0.20.0 0.19.0 0.18.0 0.17.0 0.16.1 0.15.0 0.14.0 0.13.0 0.12.1 0.12.0 0.11.2 0.10.0 0.9.0 0.8.0 0.7.0 0.6.0 0.5.0 0.4.0 0.3.0 0.2.1 0.2.0 0.1.4 0.1.3 0.1.2 0.1.1 0.1.1-rc.2 0.1.1-rc.1 0.1.1-rc.0 0.1.0

The PostgreSQL data layer for Ash Framework

Current section

Files

Jump to
ash_postgres lib merge.ex
Raw

lib/merge.ex

# SPDX-FileCopyrightText: 2024 ash_postgres contributors <https://github.com/ash-project/ash_postgres/graphs/contributors>
#
# SPDX-License-Identifier: MIT
defmodule AshPostgres.Merge do
@moduledoc false
# Builds and executes PostgreSQL `MERGE` statements, used to implement upserts.
#
# `MERGE` performs the upsert in a single statement and, via `merge_action()`, reports per
# row whether that row was inserted or updated. Both `MERGE ... RETURNING` and
# `merge_action()` require PostgreSQL 17, so callers must gate on the server version.
#
# Ecto has no `MERGE` query type, so the statement text is assembled here. To avoid
# re-implementing SQL generation, each piece is still produced by Ecto:
#
# * the `USING` source is rendered by `Ecto.Query.values/2`, which handles type casting
# and parameter dumping
# * the `SET` clause and the `ON`/`WHEN` conditions are rendered from ordinary Ecto
# queries via `Ecto.Adapters.SQL.to_sql/4`, passing a `:counter` offset so the `$n`
# parameter placeholders of each piece line up when concatenated
#
# Ecto names the source in its rendered SQL positionally (e.g. `p0`). Rather than rewrite
# those aliases, the target alias is read back out of the rendered `UPDATE ... AS <alias>`
# and reused verbatim in the `MERGE INTO ... AS <alias>` header.
#
# The incoming row (the `USING` source) is aliased `AS EXCLUDED`, matching the name Postgres
# gives it in `INSERT ... ON CONFLICT`. As a result, upsert expressions written either with
# `upsert_conflict(:field)` or as a raw `fragment("EXCLUDED.col")` render and behave the same
# whether the upsert runs through `MERGE` or `ON CONFLICT`.
#
# This module lives in ash_postgres because PostgreSQL is the only AshSql-backed database with
# a `MERGE` statement (SQLite and MySQL have none). It relies only on the generic AshSql
# machinery (bindings, atomics, expression rendering, `parameterized_type`) plus the `MERGE`
# syntax and `merge_action()`, so it could be moved into ash_sql if another MERGE-capable
# backend is ever added.
import Ecto.Query, only: [from: 1]
@source_alias "EXCLUDED"
@doc """
Executes a `MERGE` statement.
Options:
* `:resource` (required) - the Ash resource, used for column/type resolution and loading
* `:table` (required) - the target table name
* `:prefix` - the schema/prefix for the target table
* `:entries` (required) - a list of attribute maps for the `USING (VALUES ...)` source
* `:source_fields` - attribute names that must exist on the source in addition to the entry
columns (e.g. upsert keys or calculation-key dependencies referenced via `EXCLUDED`); any not
set by the entries are supplied as NULL and are not inserted
* `:extra_source_columns` - synthetic (non-attribute) columns to add to the source, each
`%{name: atom, type: ecto_type, values: [value_per_entry]}` (e.g. per-row present? flags for
`update_many`). Exposed on `EXCLUDED` but never inserted.
* `:on_query` (required) - an `Ecto.Query` (`select: 1`, `where: <pred>`) whose WHERE is the
full `ON` condition (per-key matching plus base_filter / identity where)
* `:set_query` - an `Ecto.Query` carrying `update: [set: ...]` (no `where`); rendered into
`WHEN MATCHED THEN UPDATE SET ...`. When absent/`:do_nothing`, `WHEN MATCHED` is omitted.
* `:when_matched_condition_query` - optional `Ecto.Query` (`select: 1`, `where: <cond>`) whose
WHERE becomes `WHEN MATCHED AND (<cond>)` (the upsert condition; may reference `EXCLUDED`)
* `:on_not_matched` - `:insert` (default) or `:do_nothing`
* `:returning` - `true`, a list of fields, or `false`/`nil`
* `:report_action?` - when true, append `merge_action()` and tag returned records with
`:upsert_action` (`:insert`/`:update`) metadata
Returns `{count, records | nil}`.
"""
def merge_all(repo, opts) do
resource = Keyword.fetch!(opts, :resource)
entries = Keyword.fetch!(opts, :entries)
case entries do
[] ->
if opts[:returning], do: {0, []}, else: {0, nil}
entries ->
do_merge(repo, resource, entries, opts)
end
end
defp do_merge(repo, resource, entries, opts) do
table = Keyword.fetch!(opts, :table)
prefix = opts[:prefix]
# 1. USING (VALUES ...) AS EXCLUDED(cols) -- rendered, dumped, and cast by Ecto.
{insert_header, values_sql, values_params} =
build_values_source(
repo,
resource,
entries,
opts[:source_fields] || [],
opts[:extra_source_columns] || []
)
counter = length(values_params)
# 2. ON <predicate> -- key matching + base_filter + identity where, rendered upstream.
{on_predicate_sql, on_predicate_params, counter, target_alias_from_on} =
render_optional_where(repo, Keyword.fetch!(opts, :on_query), counter)
on_predicate_sql || raise(ArgumentError, "MERGE requires an ON condition")
# 3. WHEN MATCHED [AND <cond>] THEN UPDATE SET <set> (cond params precede set params)
{when_matched_cond_sql, when_matched_cond_params, counter, target_alias_from_cond} =
render_optional_where(repo, opts[:when_matched_condition_query], counter)
{when_matched_sql, set_params, target_alias_from_set} =
build_when_matched(repo, opts[:set_query], when_matched_cond_sql, counter)
target_alias =
pick_target_alias([
target_alias_from_set,
target_alias_from_cond,
target_alias_from_on
])
on_sql = [" ON ", on_predicate_sql]
not_matched_sql = build_when_not_matched(opts[:on_not_matched] || :insert, insert_header)
{returning_sql, returning_fields} = build_returning(opts, resource, target_alias)
sql =
IO.iodata_to_binary([
"MERGE INTO ",
quote_table(prefix, table),
" AS ",
target_alias,
" USING (",
values_sql,
") AS ",
@source_alias,
on_sql,
when_matched_sql,
not_matched_sql,
returning_sql
])
params = values_params ++ on_predicate_params ++ when_matched_cond_params ++ set_params
result = repo.query!(sql, params)
# Postgrex does not parse the `MERGE <n>` command tag, so `num_rows` is reported as 0.
# When we requested RETURNING, the affected count is the number of returned rows.
count = if is_list(result.rows), do: length(result.rows), else: result.num_rows
{count, load_returning(repo, resource, result.rows, returning_fields, opts)}
end
defp build_values_source(repo, resource, entries, extra_fields, extra_columns) do
entry_fields = entries |> Enum.flat_map(&Map.keys/1) |> Enum.uniq()
# The source is referenced as `EXCLUDED` in the ON/SET/condition clauses. Beyond the entry
# columns it must also expose any attribute referenced there but not set by these entries
# (e.g. an upsert key with no provided value, or an attribute a calculation key depends on);
# those are supplied as NULL. Only the entry columns are inserted (below), so database
# defaults still apply to the rest.
source_fields = Enum.uniq(entry_fields ++ extra_fields)
insert_cols = source_columns(resource, entry_fields)
attr_cols = source_columns(resource, source_fields)
# Synthetic, non-attribute columns (e.g. per-row "is this column present?" flags for
# update_many). They are exposed on `EXCLUDED` for the SET clause but never inserted.
types =
attr_cols
|> Map.new(fn {_field, source_col, type} -> {source_col, type} end)
|> Map.merge(Map.new(extra_columns, fn %{name: name, type: type} -> {name, type} end))
rows =
entries
|> Enum.with_index()
|> Enum.map(fn {entry, index} ->
attr_values =
Map.new(attr_cols, fn {field, source_col, _type} ->
{source_col, Map.get(entry, field)}
end)
synthetic_values =
Map.new(extra_columns, fn %{name: name, values: values} ->
{name, Enum.at(values, index)}
end)
Map.merge(attr_values, synthetic_values)
end)
{values_sql, values_params} =
Ecto.Adapters.SQL.to_sql(:all, repo, from(v in values(rows, types)), counter: 0)
insert_header = Enum.map(insert_cols, fn {_field, source_col, _type} -> source_col end)
{insert_header, values_sql, values_params}
end
defp source_columns(resource, fields) do
Enum.map(fields, fn field ->
{field, db_column(resource, field), column_type(resource, field)}
end)
end
# The Ecto type used to cast/dump a column of the source, resolved from the Ash field (which
# may be an attribute, calculation, or aggregate). A configured `storage_type` wins; otherwise
# the field's `{type, constraints}` are run through `parameterized_type/2` as elsewhere.
defp column_type(resource, field) do
AshPostgres.SqlImplementation.storage_type(resource, field) ||
case field_type_and_constraints(resource, field) do
{type, constraints} ->
AshPostgres.SqlImplementation.parameterized_type(type, constraints || []) || type
nil ->
resource.__schema__(:type, field) ||
raise ArgumentError,
"Cannot build MERGE source: #{inspect(field)} is not a field of #{inspect(resource)}"
end
end
defp field_type_and_constraints(resource, field) do
case Ash.Resource.Info.field(resource, field) do
%Ash.Resource.Attribute{type: type, constraints: constraints} ->
{type, constraints}
%Ash.Resource.Calculation{type: type, constraints: constraints} ->
{type, constraints}
%Ash.Resource.Aggregate{} = aggregate ->
aggregate_type_and_constraints(resource, aggregate)
_ ->
nil
end
end
# Mirrors how aggregates are typed elsewhere: prefer an explicit type, otherwise derive it from
# the aggregate kind and the type of the aggregated field.
defp aggregate_type_and_constraints(_resource, %{type: type, constraints: constraints})
when not is_nil(type),
do: {type, constraints}
defp aggregate_type_and_constraints(resource, aggregate) do
{field_type, field_constraints} =
case aggregate.field do
nil ->
{nil, nil}
field ->
related = Ash.Resource.Info.related(resource, aggregate.relationship_path)
case Ash.Resource.Info.field(related, field) do
%{type: type, constraints: constraints} -> {type, constraints}
_ -> {nil, nil}
end
end
case Ash.Query.Aggregate.kind_to_type(aggregate.kind, field_type, field_constraints) do
{:ok, type, constraints} -> {type, constraints}
_ -> {aggregate.type, aggregate.constraints || []}
end
end
# The database column for a field, as Ecto knows it. This is the name used in the rendered
# VALUES header, in `EXCLUDED.<col>`/`<target_alias>.<col>` references, and as the
# `repo.load/2` key (which maps by source column, not field name).
defp db_column(resource, field) do
resource.__schema__(:field_source, field) || field
end
defp build_when_matched(_repo, nil, _cond_sql, _counter), do: {"", [], nil}
defp build_when_matched(_repo, :do_nothing, _cond_sql, _counter),
do: {" WHEN MATCHED THEN DO NOTHING", [], nil}
defp build_when_matched(repo, %Ecto.Query{} = set_query, cond_sql, counter) do
{sql, params} = Ecto.Adapters.SQL.to_sql(:update_all, repo, set_query, counter: counter)
{target_alias, set_clause} = extract_set_clause(sql)
cond_part = if cond_sql, do: [" AND ", cond_sql], else: []
{[" WHEN MATCHED", cond_part, " THEN UPDATE SET ", set_clause], params, target_alias}
end
defp build_when_not_matched(:do_nothing, _header), do: " WHEN NOT MATCHED THEN DO NOTHING"
defp build_when_not_matched(:insert, header) do
cols = Enum.map_join(header, ", ", &quote_name/1)
vals = Enum.map_join(header, ", ", fn col -> "#{@source_alias}.#{quote_name(col)}" end)
" WHEN NOT MATCHED THEN INSERT (#{cols}) VALUES (#{vals})"
end
defp build_returning(opts, resource, target_alias) do
case opts[:returning] do
nil -> {"", nil}
false -> {"", nil}
fields -> build_returning_fields(fields, resource, target_alias, opts[:report_action?])
end
end
defp build_returning_fields(fields, resource, target_alias, report_action?) do
fields =
case fields do
true -> Ash.Resource.Info.attributes(resource) |> Enum.map(& &1.name)
fields when is_list(fields) -> fields
end
# Use the Ecto source column for both the SQL projection and the `repo.load/2` key.
sources = Enum.map(fields, &db_column(resource, &1))
col_sql =
Enum.map_join(sources, ", ", fn source_col ->
"#{target_alias}.#{quote_name(source_col)}"
end)
action_sql = if report_action?, do: ", merge_action()", else: ""
{" RETURNING " <> col_sql <> action_sql, %{sources: sources, report_action?: report_action?}}
end
defp load_returning(_repo, _resource, _rows, nil, _opts), do: nil
defp load_returning(
repo,
resource,
rows,
%{sources: sources, report_action?: report_action?},
_opts
) do
Enum.map(rows, fn row ->
{source_values, action} =
if report_action? do
{action_value, values} = List.pop_at(row, length(sources))
{Enum.zip(sources, values), action_value}
else
{Enum.zip(sources, row), nil}
end
record =
repo.load(resource, Map.new(source_values))
|> Map.put(:__meta__, %Ecto.Schema.Metadata{
state: :loaded,
source: AshPostgres.DataLayer.Info.table(resource),
schema: resource
})
case action do
"INSERT" -> Ash.Resource.put_metadata(record, :upsert_action, :insert)
"UPDATE" -> Ash.Resource.put_metadata(record, :upsert_action, :update)
"DELETE" -> Ash.Resource.put_metadata(record, :upsert_action, :delete)
_ -> record
end
end)
end
# Deterministically pull clauses out of rendered SQL (no alias rewriting).
# `UPDATE "table" AS <alias> SET <set>` -> {alias, set}
defp extract_set_clause(update_sql) do
{before_set, after_set} = split_sql_keyword!(update_sql, "SET")
set_clause =
after_set
|> trim_sql_keyword("WHERE")
|> trim_sql_keyword("RETURNING")
|> String.trim()
{read_trailing_alias(before_set), set_clause}
end
# `SELECT ... FROM "table" AS <alias> WHERE <pred>` -> {alias, "(pred)"}; nil query -> all-nil
defp render_optional_where(_repo, nil, counter), do: {nil, [], counter, nil}
defp render_optional_where(repo, %Ecto.Query{} = query, counter) do
{sql, params} = Ecto.Adapters.SQL.to_sql(:all, repo, query, counter: counter)
{before_where, after_where} = split_sql_keyword!(sql, "WHERE")
pred =
after_where
|> trim_sql_keyword("GROUP BY")
|> trim_sql_keyword("ORDER BY")
|> trim_sql_keyword("LIMIT")
|> trim_sql_keyword("OFFSET")
|> String.trim()
{pred, params, counter + length(params), read_trailing_alias(before_where)}
end
# The alias Ecto emits for the (single) source. In `... "table" AS p0 ...` it is the last
# whitespace-delimited token before the split keyword.
defp read_trailing_alias(before_keyword) do
before_keyword
|> String.trim()
|> String.split()
|> List.last()
end
defp pick_target_alias(candidates) do
case Enum.uniq(Enum.reject(candidates, &is_nil/1)) do
[] ->
raise ArgumentError,
"MERGE requires at least a SET clause or a condition to derive the target alias"
[alias] ->
alias
aliases ->
raise ArgumentError,
"Rendered MERGE clauses disagree on the target alias: #{inspect(aliases)}. " <>
"This usually means a clause referenced a joined source, which MERGE cannot express."
end
end
# Scans for a top-level SQL keyword, skipping quoted strings and parens (not a regex).
defp split_sql_keyword!(sql, keyword) do
case split_sql_keyword(sql, keyword) do
nil -> raise ArgumentError, "Expected #{keyword} in rendered SQL: #{inspect(sql)}"
split -> split
end
end
defp split_sql_keyword(sql, keyword) do
pattern = " #{keyword} "
case find_top_level(sql, pattern) do
nil ->
nil
pos ->
{String.slice(sql, 0, pos), String.slice(sql, (pos + String.length(pattern))..-1//1)}
end
end
defp trim_sql_keyword(sql, keyword) do
case split_sql_keyword(sql, keyword) do
{before, _} -> before
nil -> sql
end
end
# Finds `pattern` at the top level, ignoring matches inside single quotes,
# double-quoted identifiers, and parentheses.
defp find_top_level(sql, pattern), do: find_top_level(sql, pattern, 0, 0, :normal)
defp find_top_level(sql, pattern, pos, depth, state) do
remaining = String.slice(sql, pos..-1//1)
if remaining == "" do
nil
else
case state do
:normal ->
cond do
depth == 0 && String.starts_with?(remaining, pattern) ->
pos
String.starts_with?(remaining, "'") ->
find_top_level(sql, pattern, pos + 1, depth, :single_quote)
String.starts_with?(remaining, "\"") ->
find_top_level(sql, pattern, pos + 1, depth, :double_quote)
String.starts_with?(remaining, "(") ->
find_top_level(sql, pattern, pos + 1, depth + 1, :normal)
String.starts_with?(remaining, ")") && depth > 0 ->
find_top_level(sql, pattern, pos + 1, depth - 1, :normal)
true ->
find_top_level(sql, pattern, pos + 1, depth, :normal)
end
:single_quote ->
cond do
String.starts_with?(remaining, "''") ->
find_top_level(sql, pattern, pos + 2, depth, :single_quote)
String.starts_with?(remaining, "'") ->
find_top_level(sql, pattern, pos + 1, depth, :normal)
true ->
find_top_level(sql, pattern, pos + 1, depth, :single_quote)
end
:double_quote ->
cond do
String.starts_with?(remaining, "\"\"") ->
find_top_level(sql, pattern, pos + 2, depth, :double_quote)
String.starts_with?(remaining, "\"") ->
find_top_level(sql, pattern, pos + 1, depth, :normal)
true ->
find_top_level(sql, pattern, pos + 1, depth, :double_quote)
end
end
end
end
defp quote_name(name) when is_atom(name), do: quote_name(Atom.to_string(name))
defp quote_name(name) when is_binary(name) do
if String.contains?(name, "\"") do
raise ArgumentError, "bad field/table name #{inspect(name)}"
end
<<?", name::binary, ?">>
end
defp quote_table(nil, table), do: quote_name(table)
defp quote_table(prefix, table), do: "#{quote_name(prefix)}.#{quote_name(table)}"
end