Current section
Files
Jump to
Current section
Files
lib/quack_db/ddl.ex
defmodule QuackDB.DDL do
@moduledoc """
Small DuckDB DDL SQL builders.
These helpers return SQL iodata for common analytical setup tasks such as
temporary tables in tests or notebooks. They are not an Ecto migration layer;
execute the generated SQL with `QuackDB.query/4` or `Repo.query/3`.
QuackDB.DDL.create_table("events",
[
id: :integer,
name: :varchar,
payload: :json,
occurred_at: :timestamp
],
temporary: true,
if_not_exists: true
)
"""
@type column_type :: QuackDB.Type.spec()
@type column ::
{atom() | String.t(), column_type()} | {atom() | String.t(), column_type(), keyword()}
@type create_table_option ::
{:temporary, boolean()} | {:if_not_exists, boolean()} | {:as, iodata()}
@doc "Builds a `CREATE TABLE` statement from an Ecto schema module."
@spec create_table(module()) :: iodata()
def create_table(schema) when is_atom(schema), do: create_table(schema, [])
@spec create_table(module() | String.t() | atom(), [create_table_option()] | [column()]) ::
iodata()
def create_table(schema_or_name, options_or_columns) when is_list(options_or_columns) do
cond do
Keyword.has_key?(options_or_columns, :as) ->
create_table_as_options(schema_or_name, options_or_columns)
is_atom(schema_or_name) and function_exported?(schema_or_name, :__schema__, 1) ->
create_table(
schema_or_name.__schema__(:source),
schema_columns(schema_or_name),
options_or_columns
)
true ->
create_table(schema_or_name, options_or_columns, [])
end
end
@doc """
Builds a `CREATE TABLE` statement.
QuackDB.DDL.create_table("events", id: :integer, name: :varchar)
QuackDB.DDL.create_table("events", [id: :integer], temporary: true)
Pass `:as` to build `CREATE TABLE AS` from iodata or an Ecto query without pinned params:
QuackDB.DDL.create_table("docs", as: query, temporary: true)
"""
@spec create_table(String.t() | atom(), [column()], [create_table_option()]) :: iodata()
def create_table(name, columns, options \\ []) when is_list(columns) and is_list(options) do
[
"CREATE ",
temporary(options),
"TABLE ",
if_not_exists(options),
QuackDB.Type.quote_identifier(name),
" (",
columns(columns),
")"
]
end
@doc "Builds a `CREATE TABLE AS` statement."
@spec create_table_as(String.t() | atom(), iodata(), [create_table_option()]) :: iodata()
def create_table_as(name, query, options \\ []) when is_list(options) do
[
"CREATE ",
temporary(options),
"TABLE ",
if_not_exists(options),
QuackDB.Type.quote_identifier(name),
" AS ",
table_query(query)
]
end
defp create_table_as_options(name, options) do
{query, options} = Keyword.pop!(options, :as)
create_table_as(name, query, options)
end
@doc "Builds a `DROP TABLE` statement."
@spec drop_table(String.t() | atom(), keyword()) :: iodata()
def drop_table(name, options \\ []) when is_list(options) do
["DROP TABLE ", if_exists(options), QuackDB.Type.quote_identifier(name)]
end
defp table_query(%{__struct__: Ecto.Query} = query) do
assert_unparameterized_query!(query)
apply(Ecto.Adapters.QuackDB.Query, :all_literal, [query])
end
defp table_query(query), do: query
defp assert_unparameterized_query!(query) do
if parameterized_query?(query) do
raise ArgumentError,
"QuackDB.DDL.create_table/2 with :as does not support parameterized Ecto queries; use literal query expressions or materialize with Repo.all/query first"
end
end
defp parameterized_query?(%{__struct__: Ecto.Query} = query) do
query
|> Map.take([
:wheres,
:havings,
:order_bys,
:group_bys,
:combinations,
:select,
:joins,
:limit,
:offset
])
|> parameterized_query?()
end
defp parameterized_query?(%{params: [_ | _]}), do: true
defp parameterized_query?(value) when is_map(value) do
value
|> Map.values()
|> Enum.any?(¶meterized_query?/1)
end
defp parameterized_query?(value) when is_list(value),
do: Enum.any?(value, ¶meterized_query?/1)
defp parameterized_query?(_value), do: false
defp schema_columns(schema) do
Enum.map(schema.__schema__(:fields), fn field ->
{field, schema_field_type!(schema, field)}
end)
end
defp schema_field_type!(schema, field) do
schema.__schema__(:type, field)
|> ecto_type_to_duckdb()
rescue
error in ArgumentError ->
raise ArgumentError,
"unsupported Ecto schema type for #{inspect(schema)}.#{field}: #{Exception.message(error)}"
end
defp ecto_type_to_duckdb(:id), do: :bigint
defp ecto_type_to_duckdb(:binary_id), do: :uuid
defp ecto_type_to_duckdb(:integer), do: :integer
defp ecto_type_to_duckdb(:float), do: :double
defp ecto_type_to_duckdb(:boolean), do: :boolean
defp ecto_type_to_duckdb(:string), do: :varchar
defp ecto_type_to_duckdb(:binary), do: :blob
defp ecto_type_to_duckdb(:decimal), do: :decimal
defp ecto_type_to_duckdb(:date), do: :date
defp ecto_type_to_duckdb(:time), do: :time
defp ecto_type_to_duckdb(:time_usec), do: :time
defp ecto_type_to_duckdb(:naive_datetime), do: :timestamp
defp ecto_type_to_duckdb(:naive_datetime_usec), do: :timestamp
defp ecto_type_to_duckdb(:utc_datetime), do: :timestamptz
defp ecto_type_to_duckdb(:utc_datetime_usec), do: :timestamptz
defp ecto_type_to_duckdb({:array, type}), do: {:list, ecto_type_to_duckdb(type)}
defp ecto_type_to_duckdb(type) do
raise ArgumentError, inspect(type)
end
defp temporary(options) do
if Keyword.get(options, :temporary, false), do: "TEMP ", else: []
end
defp if_not_exists(options) do
if Keyword.get(options, :if_not_exists, false), do: "IF NOT EXISTS ", else: []
end
defp if_exists(options) do
if Keyword.get(options, :if_exists, false), do: "IF EXISTS ", else: []
end
defp columns([]), do: raise(ArgumentError, "expected at least one column")
defp columns(columns) do
columns
|> Enum.map(&column/1)
|> Enum.intersperse(", ")
end
defp column({name, type}) do
[QuackDB.Type.quote_identifier(name), " ", QuackDB.Type.to_sql(type)]
end
defp column({name, type, options}) when is_list(options) do
[
QuackDB.Type.quote_identifier(name),
" ",
QuackDB.Type.to_sql(type),
column_options(options)
]
end
defp column(other) do
raise ArgumentError, "expected column as {name, type}, got: #{inspect(other)}"
end
defp column_options(options) do
[
nullable(options),
primary_key(options),
default(options)
]
end
defp nullable(options) do
if Keyword.get(options, :null, true), do: [], else: " NOT NULL"
end
defp primary_key(options) do
if Keyword.get(options, :primary_key, false), do: " PRIMARY KEY", else: []
end
defp default(options) do
case Keyword.fetch(options, :default) do
{:ok, value} -> [" DEFAULT ", literal!(value)]
:error -> []
end
end
defp literal!(value) do
case QuackDB.SQL.literal(value) do
{:ok, literal} -> literal
{:error, %QuackDB.Error{} = error} -> raise error
end
end
end