Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow passing Ecto query and preloads as list_... function args #45

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
90f029e
feat: allow passing Ecto query and preloads as list_... function args
vincentvanbush Feb 4, 2025
7b00130
feat: allow calling list function with schema module
vincentvanbush Feb 4, 2025
ae98381
Fix formatter indications
vincentvanbush Feb 4, 2025
644faf2
feat: add preloads to get_* and create get_*_by function
vincentvanbush Feb 4, 2025
45116a8
feat: add query builder module
vincentvanbush Feb 5, 2025
ca6339a
chore: add ecto dependency
vincentvanbush Feb 5, 2025
aea208c
chore: import formatter settings from ecto
vincentvanbush Feb 5, 2025
83029ee
feat: add query building based on plain keyword lists
vincentvanbush Feb 5, 2025
d9c913f
feat: query builder can now take nil as seeked values
vincentvanbush Feb 5, 2025
03cba0c
feat: add doc to query builder
vincentvanbush Feb 5, 2025
d6957ae
chore: update credo
vincentvanbush Feb 5, 2025
ff90ab1
chore: fix credo indication in Tracer
vincentvanbush Feb 5, 2025
afd57b6
feat: rework get_*_by and list_* API to allow preloads, queries and c…
vincentvanbush Feb 5, 2025
930e0fa
chore: update deps to suppress warnings
vincentvanbush Feb 5, 2025
9240e02
add specs
vincentvanbush Feb 5, 2025
decb073
add framework for Ecto testing, tests for list functions
vincentvanbush Feb 5, 2025
f219d61
add tests for preloads together with condition lists
vincentvanbush Feb 5, 2025
5e063ad
separate test section for list_*/2
vincentvanbush Feb 5, 2025
1c2c20f
add test and fixes to get_*_by functions
vincentvanbush Feb 6, 2025
85283b3
fix and add tests to get_* functions
vincentvanbush Feb 6, 2025
e61348f
update docs for get and list functions
vincentvanbush Feb 6, 2025
50b5a67
chore: add postrges to CI
vincentvanbush Feb 6, 2025
6bc91cc
chore: fix credo indications
vincentvanbush Feb 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [:ecto]
]
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ jobs:
elixir-version: ${{ env.elixir_version }}
otp-version: ${{ env.otp_version }}

- name: Set up Postgres
run: |
sudo apt-get update
sudo apt-get install -y postgresql
sudo service postgresql start
sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'postgres';"

- name: Restore dependencies and _build cache
uses: actions/cache@v2
with:
Expand All @@ -48,6 +55,11 @@ jobs:
- name: Check formatting
run: mix format --check-formatted

- name: Create test database
run: mix do ecto.create, ecto.migrate
env:
MIX_ENV: test

- name: Run tests
run: mix test

Expand Down
4 changes: 2 additions & 2 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
elixir 1.14.4
erlang 25.3
elixir 1.18.2
erlang 27.2.1
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Config

import_config "#{Mix.env()}.exs"
3 changes: 3 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Config

# no-op: we don't need to configure anything for the dev environment
13 changes: 13 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Config

config :contexted, ecto_repos: [Contexted.TestApp.Repo]

config :contexted, Contexted.TestApp.Repo,
database: "contexted_test",
username: "postgres",
password: "postgres",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
show_sensitive_data_on_connection_error: true,
pool_size: 10,
log: false
247 changes: 239 additions & 8 deletions lib/contexted/crud.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ defmodule Contexted.CRUD do

The following functions are generated by default. Any of them can be excluded by adding their correspoding atom to the `:exclude` option.

- `list_{plural resource name}` - Lists all resources in the schema.
- `get_{resource name}` - Retrieves a resource by its ID. Returns `nil` if not found.
- `get_{resource name}!` - Retrieves a resource by its ID. Raises an error if not found.
- `list_{plural resource name}` - Lists all resources in the schema, optionally filtered by a query or condition list, or preloaded with associations.
- `get_{resource name}` - Retrieves a resource by its ID, optionally preloaded with associations. Returns `nil` if not found.
- `get_{resource name}!` - Retrieves a resource by its ID, optionally preloaded with associations. Raises an error if not found.
- `get_{resource name}_by` - Retrieves a resource by a query or condition list, optionally preloaded with associations. Returns `nil` if not found.
- `get_{resource name}_by!` - Retrieves a resource by a query or condition list, optionally preloaded with associations. Raises an error if not found.
- `create_{resource name}` - Creates a new resource with the provided attributes. Returns an `:ok` tuple with the resource or an `:error` tuple with changeset.
- `create_{resource name}!` - Creates a new resource with the provided attributes. Raises an error if creation fails.
- `update_{resource name}` - Updates an existing resource with the provided attributes. Returns an `:ok` tuple with the resource or an `:error` tuple with changeset.
Expand Down Expand Up @@ -64,22 +66,75 @@ defmodule Contexted.CRUD do
resource_name: resource_name,
plural_resource_name: plural_resource_name
] do
import Contexted.QueryBuilder

unless :list in exclude do
function_name = String.to_atom("list_#{plural_resource_name}")

@doc """
Returns a list of all #{plural_resource_name} from the database.

## Arguments

The function accepts several argument patterns:

- No arguments: Returns all records
- Single argument:
- `Ecto.Query` or schema module: Uses this as the base query
- Keyword list: Exact, possibly nested, match conditions (translated to Ecto queries under the hood) and options (e.g. preload)
- Two arguments:
- Query + options: Uses query and applies options like preloads

## Options

- `:preload` - Preloads associations. Can be an atom or list of atoms.

## Examples

iex> list_#{plural_resource_name}()
[%#{Macro.camelize(resource_name)}{}, ...]

iex> list_#{plural_resource_name}(from r in #{schema}, limit: 10)
[%#{Macro.camelize(resource_name)}{}, ...]

iex> list_#{plural_resource_name}(preload: :associated)
[%#{Macro.camelize(resource_name)}{associated: ...}, ...]

iex> list_#{plural_resource_name}(status: :active, preload: [:associated])
[%#{Macro.camelize(resource_name)}{associated: ...}, ...]

iex> list_#{plural_resource_name}(#{schema} |> limit(10), preload: [:associated])
[%#{Macro.camelize(resource_name)}{associated: ...}, ...]
"""
@spec unquote(function_name)() :: [%unquote(schema){}]

def unquote(function_name)() do
# No args: list all resources based on the schema
unquote(schema)
|> unquote(repo).all()
end

def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do
# One arg: list all resources based on the query
query
|> unquote(repo).all()
end

def unquote(function_name)(conditions_and_opts) do
# One arg: list all resources based on the query
{opts, conditions} = Keyword.split(conditions_and_opts, [:preload])

build_query(unquote(schema), conditions)
|> unquote(repo).all()
|> unquote(repo).preload(opts[:preload] || [])
end

def unquote(function_name)(query, opts)
when (is_struct(query, Ecto.Query) or is_atom(query)) and is_list(opts) do
# Two args: list all resources based on the query and opts
query
|> unquote(repo).all()
|> unquote(repo).preload(opts[:preload] || [])
end
end

unless :get in exclude do
Expand All @@ -88,33 +143,209 @@ defmodule Contexted.CRUD do
@doc """
Retrieves a single #{resource_name} by its ID from the database. Returns nil if the #{resource_name} is not found.

If a list of preloads is provided, it will be used to preload the #{resource_name}.
Preloads can be an atom or a list of atoms.

## Examples

iex> get_#{resource_name}(id)
%#{Macro.camelize(resource_name)}{} or nil

iex> get_#{resource_name}(id, preload: [:associated])
%#{Macro.camelize(resource_name)}{associated: ...} or nil
"""

@spec unquote(function_name)(integer() | String.t()) :: %unquote(schema){} | nil
def unquote(function_name)(id) do
@spec unquote(function_name)(integer() | String.t(), keyword()) ::
%unquote(schema){} | nil
def unquote(function_name)(id, opts \\ []) when is_list(opts) do
unquote(schema)
|> unquote(repo).get(id)
|> case do
nil -> nil
record -> unquote(repo).preload(record, opts[:preload] || [])
end
end

function_name = String.to_atom("get_#{resource_name}!")

@doc """
Retrieves a single #{resource_name} by its ID from the database. Raises an error if the #{resource_name} is not found.

If a list of preloads is provided, it will be used to preload the #{resource_name}.
Preloads can be an atom or a list of atoms.

## Examples

iex> get_#{resource_name}!(id)
%#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError

iex> get_#{resource_name}!(id, preload: [:associated])
%#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError
"""

@spec unquote(function_name)(integer() | String.t()) :: %unquote(schema){}
def unquote(function_name)(id) do
@spec unquote(function_name)(integer() | String.t(), keyword()) :: %unquote(schema){}
def unquote(function_name)(id, preloads \\ [])
when is_list(preloads) or is_atom(preloads) do
unquote(schema)
|> unquote(repo).get!(id)
|> unquote(repo).preload(preloads)
end
end

unless :get_by in exclude do
function_name = String.to_atom("get_#{resource_name}_by")

defp maybe_preload(nil, _preload), do: nil
defp maybe_preload(record, nil), do: record
defp maybe_preload(record, preload), do: unquote(repo).preload(record, preload)

@doc """
Retrieves a single #{resource_name} by either an Ecto.Query or a map/keyword list of conditions from the database. Returns nil if the #{resource_name} is not found.

## Arguments

The function accepts several argument patterns:

- Single argument:
- `Ecto.Query` or schema module: Uses this as the base query
- Keyword list or map: Used as conditions and options (e.g. preload)
- Two arguments:
- Query + options: Uses query and applies options like preloads
- Conditions + options: Applies conditions and options separately

## Options

- `:preload` - Preloads associations. Can be an atom or list of atoms.

## Examples

# Exact match condition list
iex> get_#{resource_name}_by(status: "active")
%#{Macro.camelize(resource_name)}{} or nil

# Exact match condition list + preload
iex> get_#{resource_name}_by(status: "active", preload: :associated)
%#{Macro.camelize(resource_name)}{associated: ...} or nil

# Exact match condition list with a nested condition in a join table + preload
iex> get_#{resource_name}_by(associated: [id: 1], preload: :associated)
%#{Macro.camelize(resource_name)}{associated: ...} or nil

# Query
iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active")
%#{Macro.camelize(resource_name)}{} or nil

# Query + preload
iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active", preload: :associated)
%#{Macro.camelize(resource_name)}{associated: ...} or nil

# Query + preload
iex> get_#{resource_name}_by(#{schema} |> where(...), preload: [:associated])
%#{Macro.camelize(resource_name)}{associated: ...} or nil
"""

@spec unquote(function_name)(Ecto.Queryable.t(), keyword()) :: %unquote(schema){}
def unquote(function_name)(query, opts)
when is_struct(query, Ecto.Query) or is_atom(query) do
# Two args: get resource based on the query, with preloads
query
|> unquote(repo).one()
|> maybe_preload(opts[:preload])
end

@spec unquote(function_name)(Ecto.Queryable.t()) :: %unquote(schema){}
def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do
# One arg: get resource based on the query
query
|> unquote(repo).one()
end

@spec unquote(function_name)(map() | keyword()) :: %unquote(schema){}
def unquote(function_name)(conditions_and_opts)
when is_list(conditions_and_opts) or is_map(conditions_and_opts) do
# One arg: get resource with conditions and preloads
{opts, conditions} =
if is_list(conditions_and_opts),
do: Keyword.split(conditions_and_opts, [:preload]),
else: {[], conditions_and_opts}

build_query(unquote(schema), conditions)
|> unquote(repo).one()
|> maybe_preload(opts[:preload])
end

@spec unquote(function_name)(map() | keyword(), keyword()) :: %unquote(schema){}
def unquote(function_name)(conditions, preloads) when is_list(preloads) do
# Two args: get resource with separate conditions and preloads
build_query(unquote(schema), conditions)
|> unquote(repo).one()
|> maybe_preload(preloads[:preload])
end

function_name = String.to_atom("get_#{resource_name}_by!")

@doc """
Similar to get_#{resource_name}_by/2 but raises Ecto.NoResultsError if no result is found.

## Examples

# Exact match condition list
iex> get_#{resource_name}_by!(status: "active")
%#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError

# Exact match condition list + preload
iex> get_#{resource_name}_by!(status: "active", preload: :associated)
%#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError

# Query
iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active")
%#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError

# Query + preload
iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active", preload: :associated)
%#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError

# Query + preload
iex> get_#{resource_name}_by!(#{schema} |> where(...), preload: [:associated])
%#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError
"""

@spec unquote(function_name)(Ecto.Queryable.t(), keyword()) :: %unquote(schema){}
def unquote(function_name)(query, opts)
when is_struct(query, Ecto.Query) or is_atom(query) do
# Two args: get resource based on the query, with preloads
query
|> unquote(repo).one!()
|> maybe_preload(opts[:preload])
end

@spec unquote(function_name)(Ecto.Queryable.t()) :: %unquote(schema){}
def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do
# One arg: get resource based on the query
query
|> unquote(repo).one!()
end

@spec unquote(function_name)(map() | keyword()) :: %unquote(schema){}
def unquote(function_name)(conditions_and_opts)
when is_list(conditions_and_opts) or is_map(conditions_and_opts) do
# One arg: get resource with conditions and preloads
{opts, conditions} =
if is_list(conditions_and_opts),
do: Keyword.split(conditions_and_opts, [:preload]),
else: {[], conditions_and_opts}

build_query(unquote(schema), conditions)
|> unquote(repo).one!()
|> maybe_preload(opts[:preload])
end

@spec unquote(function_name)(map() | keyword(), keyword()) :: %unquote(schema){}
def unquote(function_name)(conditions, preloads) when is_list(preloads) do
# Two args: get resource with separate conditions and preloads
build_query(unquote(schema), conditions)
|> unquote(repo).one!()
|> maybe_preload(preloads[:preload])
end
end

Expand Down
Loading
Loading