defmodule QuackDB.Sequence do
@moduledoc """
Helpers for DuckDB sequences.
Native append writes full column vectors and does not evaluate column defaults.
Use `next_values/4` to allocate sequence-backed IDs before appending rows
with explicit primary keys.
"""
@doc """
Returns the sequence backing a table column default.
This inspects `pragma_table_info` and returns the sequence referenced by a
`nextval('...')` default.
"""
@spec for_column(
DBConnection.conn() | module(),
QuackDB.Meta.source(),
atom() | String.t(),
Keyword.t()
) ::
{:ok, String.t()} | {:error, Exception.t()}
def for_column(connection, source, column, options \\ []) do
with {:ok, columns} <- QuackDB.Meta.table_info(connection, table_source(source), options) do
column_name = column_source(source, column)
columns
|> Enum.find(&(&1.name == to_string(column_name)))
|> sequence_from_column(source, column)
end
end
@doc "Returns the sequence backing a table column default, raising on errors."
@spec for_column!(
DBConnection.conn() | module(),
QuackDB.Meta.source(),
atom() | String.t(),
Keyword.t()
) ::
String.t()
def for_column!(connection, source, column, options \\ []) do
case for_column(connection, source, column, options) do
{:ok, sequence} -> sequence
{:error, error} -> raise error
end
end
@doc """
Returns `count` values from a DuckDB sequence.
ids = QuackDB.Sequence.next_values(conn, "fragments_id_seq", 3)
#=> [1, 2, 3]
The sequence name is encoded as a SQL string literal for `nextval/1`; callers
should pass the actual DuckDB sequence name, not raw SQL.
"""
@spec next_values(DBConnection.conn(), atom() | String.t(), non_neg_integer(), Keyword.t()) :: [
integer()
]
def next_values(connection, sequence_name, count, options \\ [])
def next_values(connection, sequence_name, count, options)
when (is_atom(sequence_name) or is_binary(sequence_name)) and is_integer(count) and
count >= 0 do
statement = [
"SELECT nextval(",
QuackDB.SQL.literal!(to_string(sequence_name)),
") AS value FROM range(",
Integer.to_string(count),
")"
]
connection
|> QuackDB.query!(statement, [], options)
|> values_from_result()
end
def next_values(_connection, sequence_name, count, _options) do
raise ArgumentError,
"expected a sequence name atom/string and a non-negative count, got: #{inspect(sequence_name)}, #{inspect(count)}"
end
defp values_from_result(%QuackDB.Result{rows: rows}) when is_list(rows) do
Enum.map(rows, fn [value] -> value end)
end
defp sequence_from_column(nil, source, column) do
{:error,
QuackDB.Error.new(
:column_not_found,
"could not find column #{inspect(column)} in #{inspect(source)}",
source: :client
)}
end
defp sequence_from_column(%QuackDB.Meta.Column{dflt_value: default}, source, column) do
case parse_nextval_default(default) do
{:ok, sequence} ->
{:ok, sequence}
:error ->
{:error,
QuackDB.Error.new(
:sequence_not_found,
"column #{inspect(column)} in #{inspect(source)} is not backed by a nextval default",
source: :client,
metadata: %{default: default}
)}
end
end
defp parse_nextval_default(default) when is_binary(default) do
case Regex.run(~r/^nextval\('((?:''|[^'])+)'/, default) do
[_match, sequence] -> {:ok, String.replace(sequence, "''", "'")}
_other -> :error
end
end
defp parse_nextval_default(_default), do: :error
defp table_source({source, schema}) when is_atom(schema) do
if ecto_schema?(schema) do
case schema_prefix(schema) do
nil -> source
prefix -> {prefix, source}
end
else
{source, schema}
end
end
defp table_source(source), do: source
defp column_source(source, column) when is_atom(column) do
case schema_from_source(source) do
nil -> column
schema -> schema_field_source(schema, column)
end
end
defp column_source(_source, column), do: column
defp schema_from_source({_source, schema}) when is_atom(schema) do
if ecto_schema?(schema), do: schema
end
defp schema_from_source(schema) when is_atom(schema) do
if ecto_schema?(schema), do: schema
end
defp schema_from_source(_source), do: nil
defp ecto_schema?(schema) do
Code.ensure_loaded?(schema) and function_exported?(schema, :__schema__, 1)
end
defp schema_field_source(schema, field) do
schema.__schema__(:field_source, field)
rescue
FunctionClauseError -> field
end
defp schema_prefix(schema) do
schema.__schema__(:prefix)
rescue
FunctionClauseError -> nil
end
end