Current section
Files
Jump to
Current section
Files
lib/nex_base.ex
defmodule NexBase do
@moduledoc """
A fluent PostgreSQL query builder for Elixir, inspired by Supabase.
## Quick Start
# 1. Initialize (once, in application.ex or script)
NexBase.init(url: "postgres://localhost/mydb")
# 2. Query
{:ok, users} = NexBase.from("users")
|> NexBase.eq(:active, true)
|> NexBase.order(:name, :asc)
|> NexBase.run()
# 3. Insert
NexBase.from("users")
|> NexBase.insert(%{name: "Alice", active: true})
|> NexBase.run()
# 4. Raw SQL
{:ok, rows} = NexBase.sql("SELECT * FROM users WHERE id = $1", [1])
"""
alias NexBase.Query
require Ecto.Query
# -- Initialization --
@doc """
Initialize NexBase with database configuration.
Call this once in your application startup. NexBase handles the rest internally.
## Options
- `:url` - Database URL (falls back to DATABASE_URL env var)
- `:ssl` - Enable SSL for cloud databases (default: false)
- `:pool_size` - Connection pool size (default: 10)
## In an application (supervision tree)
# application.ex
def start(_type, _args) do
NexBase.init(url: System.get_env("DATABASE_URL"), ssl: true)
children = [{NexBase.Repo, []}]
Supervisor.start_link(children, strategy: :one_for_one)
end
## In a script (seeds, migrations)
NexBase.init(url: System.get_env("DATABASE_URL"), ssl: true, start: true)
"""
def init(opts \\ []) do
url = opts[:url] || System.get_env("DATABASE_URL")
pool_size = opts[:pool_size] || 10
repo_config = [url: url, pool_size: pool_size]
repo_config =
if opts[:ssl] do
repo_config ++ [
ssl: [verify: :verify_none],
queue_target: 10_000,
queue_interval: 20_000
]
else
repo_config
end
Application.put_env(:nex_base, :repo_config, repo_config)
if opts[:start] do
Application.ensure_all_started(:postgrex)
Application.ensure_all_started(:ecto_sql)
case NexBase.Repo.start_link([]) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
{:error, reason} -> {:error, reason}
end
else
:ok
end
end
# -- Query Building --
@doc """
Starts a query builder for the given table.
"""
def from(table_name) when is_binary(table_name) do
%Query{table: table_name}
end
@doc """
Selects specific columns.
Default is all columns (`*`) if not specified.
"""
def select(%Query{} = query, columns) when is_list(columns) do
%{query | select: columns}
end
@doc """
Adds an equality filter.
"""
def eq(%Query{} = query, column, value) do
filter = {:eq, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a not-equal filter.
"""
def neq(%Query{} = query, column, value) do
filter = {:neq, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a greater-than filter.
"""
def gt(%Query{} = query, column, value) do
filter = {:gt, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a less-than filter.
"""
def lt(%Query{} = query, column, value) do
filter = {:lt, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a greater-than-or-equal filter.
"""
def gte(%Query{} = query, column, value) do
filter = {:gte, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a less-than-or-equal filter.
"""
def lte(%Query{} = query, column, value) do
filter = {:lte, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds an IS filter (e.g. for NULL).
"""
def is(%Query{} = query, column, value) do
filter = {:is, column, value}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds an IN filter.
Note: Named `in_list` to avoid conflict with Kernel.in operator.
"""
def in_list(%Query{} = query, column, values) when is_list(values) do
filter = {:in, column, values}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds a like filter.
"""
def like(%Query{} = query, column, pattern) do
filter = {:like, column, pattern}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Adds an ilike filter.
"""
def ilike(%Query{} = query, column, pattern) do
filter = {:ilike, column, pattern}
%{query | filters: query.filters ++ [filter]}
end
@doc """
Sets the limit.
"""
def limit(%Query{} = query, limit) do
%{query | limit: limit}
end
@doc """
Sets the offset.
"""
def offset(%Query{} = query, offset) do
%{query | offset: offset}
end
@doc """
Sets order by.
"""
def order(%Query{} = query, column, direction \\ :asc) do
# We append to allow multiple order by clauses if needed
%{query | order_by: query.order_by ++ [{direction, column}]}
end
@doc """
Executes a raw SQL query and returns results as a list of maps.
## Examples
{:ok, rows} = NexBase.sql("SELECT * FROM users WHERE active = $1", [true])
{:ok, rows} = NexBase.sql(client, "SELECT * FROM users", [])
"""
def sql(sql, params \\ []) when is_binary(sql) do
case Ecto.Adapters.SQL.query(NexBase.Repo, sql, params) do
{:ok, %{rows: rows, columns: columns}} ->
{:ok, Enum.map(rows, fn row -> columns |> Enum.zip(row) |> Map.new() end)}
{:error, _} = err -> err
end
end
@doc """
Executes a raw SQL query (low-level, returns raw Postgrex result).
"""
def query(sql, params \\ []) when is_binary(sql) do
Ecto.Adapters.SQL.query(NexBase.Repo, sql, params)
end
@doc """
Executes a raw SQL query, raising on error.
"""
def query!(sql, params \\ []) when is_binary(sql) do
Ecto.Adapters.SQL.query!(NexBase.Repo, sql, params)
end
@doc """
Adds data for insertion.
"""
def insert(%Query{} = query, data) when is_map(data) or is_list(data) do
%{query | type: :insert, data: data}
end
@doc """
Adds data for update.
"""
def update(%Query{} = query, data) when is_map(data) do
%{query | type: :update, data: data}
end
@doc """
Sets query to delete.
"""
def delete(%Query{} = query) do
%{query | type: :delete}
end
@doc """
Adds data for upsert.
"""
def upsert(%Query{} = query, data) when is_map(data) or is_list(data) do
%{query | type: :upsert, data: data}
end
@doc """
Sets limits the range of rows to return.
"""
def range(%Query{} = query, from, to) do
# Supabase .range(0, 9) means limit 10 offset 0
# to is inclusive index
limit = to - from + 1
offset = from
%{query | limit: limit, offset: offset}
end
@doc """
Sets query to return a single result.
"""
def single(%Query{} = query) do
%{query | limit: 1}
end
@doc """
Sets query to return a single result or nil.
"""
def maybe_single(%Query{} = query) do
%{query | limit: 1}
end
@doc """
Executes a stored procedure (RPC).
## Examples
{:ok, result} = NexBase.rpc("my_function", %{param1: "value"})
"""
def rpc(function_name, params \\ %{}) do
repo = NexBase.Repo
# Build raw SQL: SELECT * FROM func($1, $2)
# This is complex because params can be positional or named.
# Postgres functions support named params via `func(param := $1)`.
# For simplicity MVP, assuming params is a map, we construct `func(key := $val)`
placeholders = Enum.map(1..map_size(params), fn i -> "$#{i}" end)
keys = Map.keys(params)
values = Map.values(params)
# args_str = "p1 := $1, p2 := $2"
args_str = Enum.zip(keys, placeholders)
|> Enum.map(fn {k, p} -> "#{k} := #{p}" end)
|> Enum.join(", ")
sql = "SELECT * FROM #{function_name}(#{args_str})"
Ecto.Adapters.SQL.query(repo, sql, values)
end
# -- Execution --
@doc """
Executes the built query.
## Examples
{:ok, rows} = NexBase.from("users") |> NexBase.eq(:active, true) |> NexBase.run()
"""
def run(query)
def run(%Query{type: :select} = query) do
repo = NexBase.Repo
ecto_query = build_ecto_query(query)
{:ok, repo.all(ecto_query)}
rescue
e -> {:error, e}
end
def run(%Query{type: :insert, table: table, data: data} = _query) do
repo = NexBase.Repo
data_list = if is_list(data), do: data, else: [data]
{count, _} = repo.insert_all(table, data_list)
{:ok, %{count: count}}
rescue
e -> {:error, e}
end
def run(%Query{type: :update, table: table, data: data, filters: filters} = _query) do
repo = NexBase.Repo
base_query = Ecto.Query.from(t in table)
query_with_filters = Enum.reduce(filters, base_query, fn filter, acc ->
apply_filter(acc, filter)
end)
updates = [set: Enum.to_list(data)]
{count, _} = repo.update_all(query_with_filters, updates)
{:ok, %{count: count}}
rescue
e -> {:error, e}
end
def run(%Query{type: :delete, table: table, filters: filters} = _query) do
repo = NexBase.Repo
base_query = Ecto.Query.from(t in table)
query_with_filters = Enum.reduce(filters, base_query, fn filter, acc ->
apply_filter(acc, filter)
end)
{count, _} = repo.delete_all(query_with_filters)
{:ok, %{count: count}}
rescue
e -> {:error, e}
end
def run(%Query{type: :upsert, table: table, data: data} = _query) do
repo = NexBase.Repo
data_list = if is_list(data), do: data, else: [data]
upsert_opts = [
on_conflict: :replace_all,
conflict_target: :id
]
{count, _} = repo.insert_all(table, data_list, upsert_opts)
{:ok, %{count: count}}
rescue
e -> {:error, e}
end
# -- Internal Builder --
defp build_ecto_query(%Query{table: table, select: select_fields, filters: filters, limit: limit, offset: offset, order_by: order_by}) do
# Start with the table (string table name support)
q = Ecto.Query.from(t in table)
# 1. Apply Selects
q = if select_fields == [] or select_fields == ["*"] do
# Use fragment to select all fields without schema
Ecto.Query.select(q, [t], fragment("row_to_json(?)", t))
else
# map(t, ^fields)
Ecto.Query.select(q, [t], map(t, ^select_fields))
end
# 2. Apply Filters
q = Enum.reduce(filters, q, fn filter, acc ->
apply_filter(acc, filter)
end)
# 3. Apply Order
q = Enum.reduce(order_by, q, fn {dir, col}, acc ->
# dir must be :asc or :desc
Ecto.Query.order_by(acc, [t], [{^dir, field(t, ^col)}])
end)
# 4. Apply Limit/Offset
q = if limit, do: Ecto.Query.limit(q, ^limit), else: q
q = if offset, do: Ecto.Query.offset(q, ^offset), else: q
q
end
# Helper to apply a single filter
defp apply_filter(query, {:eq, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) == ^val)
end
defp apply_filter(query, {:neq, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) != ^val)
end
defp apply_filter(query, {:gt, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) > ^val)
end
defp apply_filter(query, {:lt, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) < ^val)
end
defp apply_filter(query, {:like, col, pattern}) do
Ecto.Query.where(query, [t], like(field(t, ^col), ^pattern))
end
defp apply_filter(query, {:ilike, col, pattern}) do
Ecto.Query.where(query, [t], ilike(field(t, ^col), ^pattern))
end
defp apply_filter(query, {:gte, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) >= ^val)
end
defp apply_filter(query, {:lte, col, val}) do
Ecto.Query.where(query, [t], field(t, ^col) <= ^val)
end
defp apply_filter(query, {:is, col, val}) do
if is_nil(val) or val == :null do
Ecto.Query.where(query, [t], is_nil(field(t, ^col)))
else
# Supabase .is('col', true) -> col IS TRUE
Ecto.Query.where(query, [t], field(t, ^col) == ^val)
end
end
defp apply_filter(query, {:in, col, values}) do
Ecto.Query.where(query, [t], field(t, ^col) in ^values)
end
end