From 90f029ed107f8a9880d66689b076197225ea96ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Tue, 4 Feb 2025 13:53:03 +0100 Subject: [PATCH 01/26] feat: allow passing Ecto query and preloads as list_... function args --- lib/contexted/crud.ex | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 372baae..c10abd2 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -70,15 +70,45 @@ defmodule Contexted.CRUD do @doc """ Returns a list of all #{plural_resource_name} from the database. + If a query is provided, it will be used to fetch the #{plural_resource_name}. + + If a list of preloads is provided, it will be used to preload the #{plural_resource_name}. + Preloads can be an atom or a 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}(query, [:associated]) + [%#{Macro.camelize(resource_name)}{associated: ...}, ...] + + iex> list_#{plural_resource_name}([:associated]) + [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)() :: [%unquote(schema){}] - def unquote(function_name)() do + @spec unquote(function_name)(keyword() | atom() | Ecto.Query.t(), keyword() | atom()) :: [ + %unquote(schema){} + ] + def unquote(function_name)(query_or_preloads \\ [], preloads \\ []) + + def unquote(function_name)(preloads, []) when is_list(preloads) or is_atom(preloads) do unquote(schema) |> unquote(repo).all() + |> unquote(repo).preload(preloads) + end + + def unquote(function_name)( + %Ecto.Query{from: %{source: {_, unquote(schema)}}} = query, + preloads + ) + when is_list(preloads) or is_atom(preloads) do + query + |> unquote(repo).all() + |> unquote(repo).preload(preloads) end end From 7b00130557f666182d12d3f19663dbc7d7bddd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Tue, 4 Feb 2025 15:30:34 +0100 Subject: [PATCH 02/26] feat: allow calling list function with schema module --- lib/contexted/crud.ex | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index c10abd2..9e12ec5 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -70,7 +70,8 @@ defmodule Contexted.CRUD do @doc """ Returns a list of all #{plural_resource_name} from the database. - If a query is provided, it will be used to fetch the #{plural_resource_name}. + If an `Ecto.Query` or the schema module is provided, it will be used to fetch the #{plural_resource_name}. + Note that this argument does not take any arbitrary queryable, but only `Ecto.Query` or the specific schema module of the resource. If a list of preloads is provided, it will be used to preload the #{plural_resource_name}. Preloads can be an atom or a list of atoms. @@ -90,10 +91,19 @@ defmodule Contexted.CRUD do iex> list_#{plural_resource_name}([:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)(keyword() | atom() | Ecto.Query.t(), keyword() | atom()) :: [ + @spec unquote(function_name)(keyword() | atom() | Ecto.Queryable.t(), keyword() | atom()) :: [ %unquote(schema){} ] - def unquote(function_name)(query_or_preloads \\ [], preloads \\ []) + def unquote(function_name)(queryable_or_preloads \\ [], preloads \\ []) + + def unquote(function_name)( + queryable, + preloads + ) when queryable == unquote(schema) and (is_list(preloads) or is_atom(preloads)) do + queryable + |> unquote(repo).all() + |> unquote(repo).preload(preloads) + end def unquote(function_name)(preloads, []) when is_list(preloads) or is_atom(preloads) do unquote(schema) @@ -110,6 +120,8 @@ defmodule Contexted.CRUD do |> unquote(repo).all() |> unquote(repo).preload(preloads) end + + end unless :get in exclude do From ae983816667970b098b6eb58456960aca25d8b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Tue, 4 Feb 2025 17:51:22 +0100 Subject: [PATCH 03/26] Fix formatter indications --- .tool-versions | 4 ++-- lib/contexted/crud.ex | 16 ++++++++-------- lib/contexted/tracer.ex | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.tool-versions b/.tool-versions index 9ac8ac3..3f738e9 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.14.4 -erlang 25.3 +elixir 1.18.2 +erlang 27.2.1 diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 9e12ec5..e2e0802 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -91,15 +91,17 @@ defmodule Contexted.CRUD do iex> list_#{plural_resource_name}([:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)(keyword() | atom() | Ecto.Queryable.t(), keyword() | atom()) :: [ - %unquote(schema){} - ] + @spec unquote(function_name)(keyword() | atom() | Ecto.Queryable.t(), keyword() | atom()) :: + [ + %unquote(schema){} + ] def unquote(function_name)(queryable_or_preloads \\ [], preloads \\ []) def unquote(function_name)( - queryable, - preloads - ) when queryable == unquote(schema) and (is_list(preloads) or is_atom(preloads)) do + queryable, + preloads + ) + when queryable == unquote(schema) and (is_list(preloads) or is_atom(preloads)) do queryable |> unquote(repo).all() |> unquote(repo).preload(preloads) @@ -120,8 +122,6 @@ defmodule Contexted.CRUD do |> unquote(repo).all() |> unquote(repo).preload(preloads) end - - end unless :get in exclude do diff --git a/lib/contexted/tracer.ex b/lib/contexted/tracer.ex index 8523692..bf13845 100644 --- a/lib/contexted/tracer.ex +++ b/lib/contexted/tracer.ex @@ -119,7 +119,7 @@ defmodule Contexted.Tracer do |> then(&~r/\b#{&1}\b/) end - @spec silence_recompilation_warnings((() -> any())) :: any() + @spec silence_recompilation_warnings((-> any())) :: any() defp silence_recompilation_warnings(fun) do original_logger_level = Logger.level() original_compiler_options = Code.compiler_options() From 644faf2836b74364f93dd9cbc1042e9b1b6ab6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Tue, 4 Feb 2025 18:24:01 +0100 Subject: [PATCH 04/26] feat: add preloads to get_* and create get_*_by function --- lib/contexted/crud.ex | 76 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index e2e0802..af80ea4 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -130,16 +130,26 @@ 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, [: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() | atom()) :: %unquote(schema){} | nil + def unquote(function_name)(id, preloads \\ []) when is_list(preloads) or is_atom(preloads) do unquote(schema) |> unquote(repo).get(id) + |> case do + nil -> nil + record -> unquote(repo).preload(record, preloads) + end end function_name = String.to_atom("get_#{resource_name}!") @@ -147,16 +157,74 @@ defmodule Contexted.CRUD do @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, [: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() | atom()) :: %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") + + @doc """ + Retrieves a single #{resource_name} by an Ecto.Query 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}_by(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or nil + + iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active", [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or nil + """ + @spec unquote(function_name)(Ecto.Queryable.t(), keyword() | atom()) :: %unquote(schema){} | nil + def unquote(function_name)(queryable, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + queryable + |> unquote(repo).one() + |> case do + nil -> nil + record -> unquote(repo).preload(record, preloads) + end + end + + function_name = String.to_atom("get_#{resource_name}_by!") + + @doc """ + Retrieves a single #{resource_name} by an Ecto.Query 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}_by!(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError + + iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active", [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError + """ + @spec unquote(function_name)(Ecto.Queryable.t(), keyword() | atom()) :: %unquote(schema){} + def unquote(function_name)(queryable, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + queryable + |> unquote(repo).one!() + |> unquote(repo).preload(preloads) end end From 45116a85a609ee91790e6df86be1d4b7de42dd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:06:24 +0100 Subject: [PATCH 05/26] feat: add query builder module --- lib/contexted/query_builder.ex | 56 ++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 lib/contexted/query_builder.ex diff --git a/lib/contexted/query_builder.ex b/lib/contexted/query_builder.ex new file mode 100644 index 0000000..e5df5f3 --- /dev/null +++ b/lib/contexted/query_builder.ex @@ -0,0 +1,56 @@ +defmodule Contexted.QueryBuilder do + import Ecto.Query + + def build_query(schema, conditions) do + from(r in schema) + |> traverse_conditions(conditions, []) + end + + defp traverse_conditions(query, [condition | rest], parent_path) do + query + |> join_or_where(condition, parent_path) + |> traverse_conditions(rest, parent_path) + end + + defp traverse_conditions(query, [], _parent_path) do + query + end + + defp join_or_where(query, {assoc_name, assoc_conditions}, parent_path) + when is_list(assoc_conditions) do + new_parent_path = parent_path ++ [assoc_name] + new_binding_name = join_parent_path(new_parent_path) + + query = + case parent_path do + [] -> + from r in query, + join: s in assoc(r, ^assoc_name), + as: ^new_binding_name + + _path -> + from [{^join_parent_path(parent_path), r}] in query, + join: s in assoc(r, ^assoc_name), + as: ^new_binding_name + end + + query + |> traverse_conditions(assoc_conditions, new_parent_path) + end + + defp join_or_where(query, {field, value}, parent_path) do + case parent_path do + [] -> + from r in query, where: field(r, ^field) == ^value + + _path -> + from [{^join_parent_path(parent_path), r}] in query, + where: field(r, ^field) == ^value + end + end + + defp join_parent_path(parent_path) do + Enum.map_join(parent_path, "_", &"#{&1}") + |> String.to_atom() + end +end From ca6339acb1580e58cb063bfde77a458e0de6d460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:07:27 +0100 Subject: [PATCH 06/26] chore: add ecto dependency --- mix.exs | 3 ++- mix.lock | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 69355b0..8c4110d 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,8 @@ defmodule Contexted.MixProject do [ {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.25", only: :dev, runtime: false}, - {:versioce, "~> 2.0.0", only: :dev, runtime: false} + {:versioce, "~> 2.0.0", only: :dev, runtime: false}, + {:ecto, "~> 3.0"} ] end diff --git a/mix.lock b/mix.lock index 0d065e7..6a6222e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, @@ -9,5 +11,6 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "versioce": {:hex, :versioce, "2.0.0", "a31b5e7b744d0d4a3694dd6fe4c0ee403e969631789e73cbd2a3367246404948", [:mix], [{:git_cli, "~> 0.3.0", [hex: :git_cli, repo: "hexpm", optional: true]}], "hexpm", "b2112ce621cd40fe23ad957a3dd82bccfdfa33c9a7f1e710a44b75ae772186cc"}, } From aea208c9b014a42cc04c95342744dca85f464d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:07:44 +0100 Subject: [PATCH 07/26] chore: import formatter settings from ecto --- .formatter.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.formatter.exs b/.formatter.exs index d2cda26..90c80fe 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -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] ] From 83029eeb5df6f2fde2a9fe7d4e762e85515e298e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:08:18 +0100 Subject: [PATCH 08/26] feat: add query building based on plain keyword lists --- lib/contexted/crud.ex | 86 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index af80ea4..3d04e48 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -64,6 +64,8 @@ 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}") @@ -91,7 +93,10 @@ defmodule Contexted.CRUD do iex> list_#{plural_resource_name}([:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)(keyword() | atom() | Ecto.Queryable.t(), keyword() | atom()) :: + @spec unquote(function_name)( + keyword() | atom() | Ecto.Queryable.t() | list(), + keyword() | atom() + ) :: [ %unquote(schema){} ] @@ -107,6 +112,15 @@ defmodule Contexted.CRUD do |> unquote(repo).preload(preloads) end + def unquote(function_name)(conditions, preloads) + when is_list(conditions) and (is_list(preloads) or is_atom(preloads)) do + query = build_query(unquote(schema), conditions) + + query + |> unquote(repo).all() + |> unquote(repo).preload(preloads) + end + def unquote(function_name)(preloads, []) when is_list(preloads) or is_atom(preloads) do unquote(schema) |> unquote(repo).all() @@ -142,8 +156,10 @@ defmodule Contexted.CRUD do %#{Macro.camelize(resource_name)}{associated: ...} or nil """ - @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: %unquote(schema){} | nil - def unquote(function_name)(id, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: + %unquote(schema){} | nil + def unquote(function_name)(id, preloads \\ []) + when is_list(preloads) or is_atom(preloads) do unquote(schema) |> unquote(repo).get(id) |> case do @@ -169,8 +185,11 @@ defmodule Contexted.CRUD do %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError """ - @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: %unquote(schema){} - def unquote(function_name)(id, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: %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) @@ -181,21 +200,41 @@ defmodule Contexted.CRUD do function_name = String.to_atom("get_#{resource_name}_by") @doc """ - Retrieves a single #{resource_name} by an Ecto.Query from the database. Returns nil if the #{resource_name} is not found. + 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. 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}_by(from r in #{schema}, where: r.status == "active") + iex> get_#{resource_name}_by(%{status: "active"}) %#{Macro.camelize(resource_name)}{} or nil - iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active", [:associated]) + iex> get_#{resource_name}_by([status: "active"], [:associated]) %#{Macro.camelize(resource_name)}{associated: ...} or nil + + iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or nil """ - @spec unquote(function_name)(Ecto.Queryable.t(), keyword() | atom()) :: %unquote(schema){} | nil - def unquote(function_name)(queryable, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + @spec unquote(function_name)(Ecto.Queryable.t() | map() | keyword(), keyword() | atom()) :: + %unquote(schema){} | nil + def unquote(function_name)(query_or_conditions, preloads \\ []) + + def unquote(function_name)(query_or_conditions, preloads) + when (is_map(query_or_conditions) or is_list(query_or_conditions)) and + (is_list(preloads) or is_atom(preloads)) do + query = build_query(unquote(schema), query_or_conditions) + + query + |> unquote(repo).one() + |> case do + nil -> nil + record -> unquote(repo).preload(record, preloads) + end + end + + def unquote(function_name)(queryable, preloads) + when is_list(preloads) or is_atom(preloads) do queryable |> unquote(repo).one() |> case do @@ -207,21 +246,38 @@ defmodule Contexted.CRUD do function_name = String.to_atom("get_#{resource_name}_by!") @doc """ - Retrieves a single #{resource_name} by an Ecto.Query from the database. Raises an error if the #{resource_name} is not found. + Retrieves a single #{resource_name} by either an Ecto.Query or a map/keyword list of conditions 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}_by!(from r in #{schema}, where: r.status == "active") + iex> get_#{resource_name}_by!(%{status: "active"}) %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError - iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active", [:associated]) + iex> get_#{resource_name}_by!([status: "active"], [:associated]) %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError + + iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError """ - @spec unquote(function_name)(Ecto.Queryable.t(), keyword() | atom()) :: %unquote(schema){} - def unquote(function_name)(queryable, preloads \\ []) when is_list(preloads) or is_atom(preloads) do + @spec unquote(function_name)(Ecto.Queryable.t() | map() | keyword(), keyword() | atom()) :: + %unquote(schema){} + def unquote(function_name)(query_or_conditions, preloads \\ []) + + def unquote(function_name)(query_or_conditions, preloads) + when (is_map(query_or_conditions) or is_list(query_or_conditions)) and + (is_list(preloads) or is_atom(preloads)) do + query = build_query(unquote(schema), query_or_conditions) + + query + |> unquote(repo).one!() + |> unquote(repo).preload(preloads) + end + + def unquote(function_name)(queryable, preloads) + when is_list(preloads) or is_atom(preloads) do queryable |> unquote(repo).one!() |> unquote(repo).preload(preloads) From d9c913f89c971c4c04486fcb71b57b099257c2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:14:26 +0100 Subject: [PATCH 09/26] feat: query builder can now take nil as seeked values --- lib/contexted/query_builder.ex | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/contexted/query_builder.ex b/lib/contexted/query_builder.ex index e5df5f3..951adc1 100644 --- a/lib/contexted/query_builder.ex +++ b/lib/contexted/query_builder.ex @@ -39,13 +39,18 @@ defmodule Contexted.QueryBuilder do end defp join_or_where(query, {field, value}, parent_path) do - case parent_path do - [] -> + case {parent_path, value} do + {[], nil} -> + from r in query, where: is_nil(field(r, ^field)) + + {[], _} -> from r in query, where: field(r, ^field) == ^value - _path -> - from [{^join_parent_path(parent_path), r}] in query, - where: field(r, ^field) == ^value + {_path, nil} -> + from [{^join_parent_path(parent_path), r}] in query, where: is_nil(field(r, ^field)) + + {_path, _} -> + from [{^join_parent_path(parent_path), r}] in query, where: field(r, ^field) == ^value end end From 03cba0cbd37bbeffcc8ad6f74b19d714e4c8bc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 01:22:08 +0100 Subject: [PATCH 10/26] feat: add doc to query builder --- lib/contexted/query_builder.ex | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/lib/contexted/query_builder.ex b/lib/contexted/query_builder.ex index 951adc1..fd16cc1 100644 --- a/lib/contexted/query_builder.ex +++ b/lib/contexted/query_builder.ex @@ -1,6 +1,46 @@ defmodule Contexted.QueryBuilder do + @moduledoc """ + Builds queries based on conditions defined as keyword lists. + + ## Example + + ```elixir + query = Contexted.QueryBuilder.build_query(MyApp.Inventory.Item, [ + part_number: "1234567890", + category: "electronics", + manufacturer: [name: "Acme"] + ]) + + # This will generate the following query: + # + # from i in MyApp.Inventory.Item, + # join: m in assoc(i, :manufacturer), + # where: i.category == "electronics" and m.name == "Acme" and i.part_number == "1234567890" + ``` + """ import Ecto.Query + @doc """ + Builds a query based on the given schema and conditions. + + Automatically joins associations based on the conditions. + + ## Example + + ```elixir + query = Contexted.QueryBuilder.build_query(MyApp.Inventory.Item, [ + part_number: "1234567890", + category: "electronics", + manufacturer: [name: "Acme"] + ]) + + # This will generate the following query: + # + # from i in MyApp.Inventory.Item, + # join: m in assoc(i, :manufacturer), + # where: i.category == "electronics" and m.name == "Acme" and i.part_number == "1234567890" + ``` + """ def build_query(schema, conditions) do from(r in schema) |> traverse_conditions(conditions, []) From d6957ae43303475b43a9a2c5a9123c141d519706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 09:55:55 +0100 Subject: [PATCH 11/26] chore: update credo --- mix.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.lock b/mix.lock index 6a6222e..44ede53 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ - "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, - "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, From ff90ab160b09f0e9f52e23663d4d6a4965138b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 09:58:20 +0100 Subject: [PATCH 12/26] chore: fix credo indication in Tracer --- lib/contexted/tracer.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/contexted/tracer.ex b/lib/contexted/tracer.ex index bf13845..d88bad6 100644 --- a/lib/contexted/tracer.ex +++ b/lib/contexted/tracer.ex @@ -73,7 +73,7 @@ defmodule Contexted.Tracer do @spec verify_modules_mismatch(module(), module(), String.t()) :: :ok defp verify_modules_mismatch(analyzed_module, referenced_module, file) do - if is_file_excluded_from_check?(file) do + if file_excluded_from_check?(file) do :ok else analyzed_context_module = map_module_to_context_module(analyzed_module) @@ -106,8 +106,8 @@ defmodule Contexted.Tracer do end) end - @spec is_file_excluded_from_check?(String.t()) :: boolean() - defp is_file_excluded_from_check?(file) do + @spec file_excluded_from_check?(String.t()) :: boolean() + defp file_excluded_from_check?(file) do Utils.get_config_exclude_paths() |> Enum.any?(&String.contains?(file, &1)) end From afd57b6bf7314c00606cca6db5c30e312f8bea62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 13:55:18 +0100 Subject: [PATCH 13/26] feat: rework get_*_by and list_* API to allow preloads, queries and condition lists --- lib/contexted/crud.ex | 188 +++++++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 84 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 3d04e48..7fd33a8 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -72,12 +72,21 @@ defmodule Contexted.CRUD do @doc """ Returns a list of all #{plural_resource_name} from the database. - If an `Ecto.Query` or the schema module is provided, it will be used to fetch the #{plural_resource_name}. - Note that this argument does not take any arbitrary queryable, but only `Ecto.Query` or the specific schema module of the resource. + ## Arguments - If a list of preloads is provided, it will be used to preload the #{plural_resource_name}. - Preloads can be an atom or a list of atoms. + 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: Used as conditions and options (e.g. preload) + - Two arguments: + - Query + options: Uses query and applies options like preloads + - Conditions + preloads: Applies conditions and preloads separately + ## Options + + - `:preload` - Preloads associations. Can be an atom or list of atoms. ## Examples @@ -87,53 +96,50 @@ defmodule Contexted.CRUD do iex> list_#{plural_resource_name}(from r in #{schema}, limit: 10) [%#{Macro.camelize(resource_name)}{}, ...] - iex> list_#{plural_resource_name}(query, [:associated]) + 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}([:associated]) + iex> list_#{plural_resource_name}(query, preload: [:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)( - keyword() | atom() | Ecto.Queryable.t() | list(), - keyword() | atom() - ) :: - [ - %unquote(schema){} - ] - def unquote(function_name)(queryable_or_preloads \\ [], preloads \\ []) - - def unquote(function_name)( - queryable, - preloads - ) - when queryable == unquote(schema) and (is_list(preloads) or is_atom(preloads)) do - queryable + + def unquote(function_name)() do + # No args: list all resources based on the schema + unquote(schema) |> unquote(repo).all() - |> unquote(repo).preload(preloads) end - def unquote(function_name)(conditions, preloads) - when is_list(conditions) and (is_list(preloads) or is_atom(preloads)) do - query = build_query(unquote(schema), conditions) - + 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() - |> unquote(repo).preload(preloads) end - def unquote(function_name)(preloads, []) when is_list(preloads) or is_atom(preloads) do - unquote(schema) + 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(preloads) + |> unquote(repo).preload(opts[:preload] || []) end - def unquote(function_name)( - %Ecto.Query{from: %{source: {_, unquote(schema)}}} = query, - preloads - ) - when is_list(preloads) or is_atom(preloads) do + 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 + + def unquote(function_name)(conditions, preloads) + when is_list(conditions) and is_list(preloads) do + # Two args: list all resources based on the preloads + unquote(schema) + |> unquote(repo).all() |> unquote(repo).preload(preloads) end end @@ -199,88 +205,102 @@ defmodule Contexted.CRUD do 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. - 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. + ## 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 iex> get_#{resource_name}_by(%{status: "active"}) %#{Macro.camelize(resource_name)}{} or nil - iex> get_#{resource_name}_by([status: "active"], [:associated]) + iex> get_#{resource_name}_by([status: "active"], preload: :associated) %#{Macro.camelize(resource_name)}{associated: ...} or nil iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active") %#{Macro.camelize(resource_name)}{} or nil """ - @spec unquote(function_name)(Ecto.Queryable.t() | map() | keyword(), keyword() | atom()) :: - %unquote(schema){} | nil - def unquote(function_name)(query_or_conditions, preloads \\ []) - - def unquote(function_name)(query_or_conditions, preloads) - when (is_map(query_or_conditions) or is_list(query_or_conditions)) and - (is_list(preloads) or is_atom(preloads)) do - query = build_query(unquote(schema), query_or_conditions) + def unquote(function_name)(query, opts) + when is_struct(query, Ecto.Query) or is_atom(query) do + # One arg: get resource based on the query query |> unquote(repo).one() - |> case do - nil -> nil - record -> unquote(repo).preload(record, preloads) - end + |> maybe_preload(opts[:preload]) end - def unquote(function_name)(queryable, preloads) - when is_list(preloads) or is_atom(preloads) do - queryable + 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() - |> case do - nil -> nil - record -> unquote(repo).preload(record, preloads) - end + |> maybe_preload(opts[:preload]) + end + + 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 """ - Retrieves a single #{resource_name} by either an Ecto.Query or a map/keyword list of conditions 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. + Similar to get_#{resource_name}_by/2 but raises Ecto.NoResultsError if no result is found. - ## Examples - - iex> get_#{resource_name}_by!(%{status: "active"}) - %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError - - iex> get_#{resource_name}_by!([status: "active"], [:associated]) - %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError - - iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active") - %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError + See get_#{resource_name}_by/2 for more details on arguments and options. """ - @spec unquote(function_name)(Ecto.Queryable.t() | map() | keyword(), keyword() | atom()) :: - %unquote(schema){} - def unquote(function_name)(query_or_conditions, preloads \\ []) - - def unquote(function_name)(query_or_conditions, preloads) - when (is_map(query_or_conditions) or is_list(query_or_conditions)) and - (is_list(preloads) or is_atom(preloads)) do - query = build_query(unquote(schema), query_or_conditions) + def unquote(function_name)(query, opts) + when is_struct(query, Ecto.Query) or is_atom(query) do + # One arg: get resource based on the query query |> unquote(repo).one!() - |> unquote(repo).preload(preloads) + |> maybe_preload(opts[:preload]) end - def unquote(function_name)(queryable, preloads) - when is_list(preloads) or is_atom(preloads) do - queryable + 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!() - |> unquote(repo).preload(preloads) + |> maybe_preload(opts[:preload]) + end + + 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 From 930e0fad735684b0414f9bbf5296f7de937da52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 13:55:36 +0100 Subject: [PATCH 14/26] chore: update deps to suppress warnings --- mix.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mix.lock b/mix.lock index 44ede53..415f663 100644 --- a/mix.lock +++ b/mix.lock @@ -2,15 +2,15 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, + "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "versioce": {:hex, :versioce, "2.0.0", "a31b5e7b744d0d4a3694dd6fe4c0ee403e969631789e73cbd2a3367246404948", [:mix], [{:git_cli, "~> 0.3.0", [hex: :git_cli, repo: "hexpm", optional: true]}], "hexpm", "b2112ce621cd40fe23ad957a3dd82bccfdfa33c9a7f1e710a44b75ae772186cc"}, } From 9240e0275bebb81bb556d537458f22507a38d656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 14:26:05 +0100 Subject: [PATCH 15/26] add specs --- lib/contexted/crud.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 7fd33a8..424ee38 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -239,6 +239,7 @@ defmodule Contexted.CRUD do %#{Macro.camelize(resource_name)}{} 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 # One arg: get resource based on the query @@ -247,6 +248,7 @@ defmodule Contexted.CRUD do |> maybe_preload(opts[:preload]) 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 @@ -260,6 +262,7 @@ defmodule Contexted.CRUD do |> 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) @@ -274,7 +277,7 @@ defmodule Contexted.CRUD do See get_#{resource_name}_by/2 for more details on arguments and options. """ - + @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 # One arg: get resource based on the query @@ -283,6 +286,7 @@ defmodule Contexted.CRUD do |> maybe_preload(opts[:preload]) 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 @@ -296,6 +300,7 @@ defmodule Contexted.CRUD do |> 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) From decb073c7d03551cb841edd27035d4d2c15fbbb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 17:40:49 +0100 Subject: [PATCH 16/26] add framework for Ecto testing, tests for list functions --- config/config.exs | 3 + config/dev.exs | 3 + config/test.exs | 13 ++ mix.exs | 9 +- mix.lock | 3 + .../20250205134318_create_test_tables.exs | 28 ++++ test/contexted/crud/get_by_test.exs | 0 test/contexted/crud/get_test.exs | 0 test/contexted/crud/list_test.exs | 139 ++++++++++++++++++ test/support/data_case.ex | 17 +++ .../test_app/contexts/category_context.ex | 6 + .../support/test_app/contexts/item_context.ex | 6 + .../test_app/contexts/subcategory_context.ex | 6 + test/support/test_app/repo.ex | 5 + test/support/test_app/schemas/category.ex | 17 +++ test/support/test_app/schemas/item.ex | 20 +++ test/support/test_app/schemas/subcategory.ex | 19 +++ test/support/test_records.ex | 72 +++++++++ test/test_helper.exs | 5 + 19 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/test.exs create mode 100644 priv/repo/migrations/20250205134318_create_test_tables.exs create mode 100644 test/contexted/crud/get_by_test.exs create mode 100644 test/contexted/crud/get_test.exs create mode 100644 test/contexted/crud/list_test.exs create mode 100644 test/support/data_case.ex create mode 100644 test/support/test_app/contexts/category_context.ex create mode 100644 test/support/test_app/contexts/item_context.ex create mode 100644 test/support/test_app/contexts/subcategory_context.ex create mode 100644 test/support/test_app/repo.ex create mode 100644 test/support/test_app/schemas/category.ex create mode 100644 test/support/test_app/schemas/item.ex create mode 100644 test/support/test_app/schemas/subcategory.ex create mode 100644 test/support/test_records.ex diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..871a3d1 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +import Config + +import_config "#{Mix.env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..0f93468 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +import Config + +# no-op: we don't need to configure anything for the dev environment diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..8bf3d82 --- /dev/null +++ b/config/test.exs @@ -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 diff --git a/mix.exs b/mix.exs index 8c4110d..de50d0b 100644 --- a/mix.exs +++ b/mix.exs @@ -8,6 +8,7 @@ defmodule Contexted.MixProject do "Contexted is an Elixir library designed to streamline the management of complex Phoenix contexts in your projects, offering tools for module separation, subcontext creation, and auto-generating CRUD operations for improved code maintainability.", version: "0.3.3", elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), package: package() @@ -27,10 +28,16 @@ defmodule Contexted.MixProject do {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.25", only: :dev, runtime: false}, {:versioce, "~> 2.0.0", only: :dev, runtime: false}, - {:ecto, "~> 3.0"} + {:ecto, "~> 3.0"}, + {:ecto_sql, "~> 3.0", optional: true}, + {:postgrex, ">= 0.0.0", optional: true, only: [:dev, :test]} ] end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp package do [ licenses: ["MIT"], diff --git a/mix.lock b/mix.lock index 415f663..edf5100 100644 --- a/mix.lock +++ b/mix.lock @@ -1,9 +1,11 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, "ex_doc": {:hex, :ex_doc, "0.37.0", "970f92b39e62c460aa8a367508e938f5e4da6e2ff3eaed3f8530b25870f45471", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "b0ee7f17373948e0cf471e59c3a0ee42f3bd1171c67d91eb3626456ef9c6202c"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -11,6 +13,7 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "versioce": {:hex, :versioce, "2.0.0", "a31b5e7b744d0d4a3694dd6fe4c0ee403e969631789e73cbd2a3367246404948", [:mix], [{:git_cli, "~> 0.3.0", [hex: :git_cli, repo: "hexpm", optional: true]}], "hexpm", "b2112ce621cd40fe23ad957a3dd82bccfdfa33c9a7f1e710a44b75ae772186cc"}, } diff --git a/priv/repo/migrations/20250205134318_create_test_tables.exs b/priv/repo/migrations/20250205134318_create_test_tables.exs new file mode 100644 index 0000000..8bd056e --- /dev/null +++ b/priv/repo/migrations/20250205134318_create_test_tables.exs @@ -0,0 +1,28 @@ +defmodule Contexted.TestApp.Repo.Migrations.CreateTestTables do + use Ecto.Migration + + def change do + create table(:categories) do + add :name, :string, null: false + timestamps() + end + + create table(:subcategories) do + add :name, :string, null: false + add :category_id, references(:categories, on_delete: :delete_all), null: false + timestamps() + end + + create table(:items) do + add :name, :string, null: false + add :serial_number, :string, null: false + add :subcategory_id, references(:subcategories, on_delete: :delete_all), null: false + timestamps() + end + + create unique_index(:categories, [:name]) + create unique_index(:subcategories, [:category_id, :name]) + create unique_index(:items, [:serial_number]) + create index(:items, [:subcategory_id]) + end +end diff --git a/test/contexted/crud/get_by_test.exs b/test/contexted/crud/get_by_test.exs new file mode 100644 index 0000000..e69de29 diff --git a/test/contexted/crud/get_test.exs b/test/contexted/crud/get_test.exs new file mode 100644 index 0000000..e69de29 diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs new file mode 100644 index 0000000..ad3c3eb --- /dev/null +++ b/test/contexted/crud/list_test.exs @@ -0,0 +1,139 @@ +defmodule Contexted.CRUD.ListTest do + use Contexted.DataCase + doctest Contexted.CRUD + + alias Contexted.TestApp.Item + + import Contexted.TestRecords + import Ecto.Query + + setup [:test_records] + + describe "list_*/0" do + test "list_categories/0", %{categories: categories} do + assert Contexted.TestApp.Contexts.CategoryContext.list_categories() |> MapSet.new() == + categories |> MapSet.new() + end + + test "list_subcategories/0", %{subcategories: subcategories} do + assert Contexted.TestApp.Contexts.SubcategoryContext.list_subcategories() |> MapSet.new() == + subcategories |> MapSet.new() + end + + test "list_items/0", %{items: items} do + assert Contexted.TestApp.Contexts.ItemContext.list_items() |> MapSet.new() == + items |> MapSet.new() + end + end + + describe "list_*/1" do + test "list_items(%Item{})", %{items: items} do + assert Contexted.TestApp.Contexts.ItemContext.list_items(Contexted.TestApp.Item) + |> MapSet.new() == items |> MapSet.new() + end + + test "list_items(Ecto.Query.t())", %{items: items} do + assert Contexted.TestApp.Contexts.ItemContext.list_items( + from(i in Item, where: like(i.name, "Item 1.2.%")) + ) + |> MapSet.new() == + items + |> Enum.filter(&String.starts_with?(&1.name, "Item 1.2.")) + |> MapSet.new() + end + + test "list_items(Ecto.Query.t(), preload: :subcategory)", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items( + from(i in Item, where: like(i.name, "Item 1.2.%"), preload: :subcategory) + ) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.2.")) + + assert loaded_items + |> Enum.map(& &1.name) + |> Enum.all?(fn name -> String.starts_with?(name, "Item 1.2.") end) + + assert loaded_items |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) + end + + test "list_items(Ecto.Query.t(), preload: [:subcategory])", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items( + from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [:subcategory]) + ) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.2.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.2.") end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) + end + + test "list_items(Ecto.Query.t(), preload: [subcategory: :category])", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items( + from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [subcategory: :category]) + ) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.2.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.2.") end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) + end + + test "list_items(Ecto.Query.t(), preload: [subcategory: [:category]])", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items( + from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [subcategory: :category]) + ) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.2.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.2.") end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) + end + + test "list_items(subcategory: [name: \"Subcategory 1.1\"])", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) + end + + test "list_items(subcategory: [category: [name: \"Category 1\"]])", %{items: items} do + loaded_items = + Contexted.TestApp.Contexts.ItemContext.list_items( + subcategory: [category: [name: "Category 2"]] + ) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 2.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 2.") end) + end + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..b0528a3 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,17 @@ +defmodule Contexted.DataCase do + @moduledoc false + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + alias Contexted.TestApp.Repo + + setup tags do + :ok = Sandbox.checkout(Repo) + + unless tags[:async] do + Sandbox.mode(Repo, {:shared, self()}) + end + + :ok + end +end diff --git a/test/support/test_app/contexts/category_context.ex b/test/support/test_app/contexts/category_context.ex new file mode 100644 index 0000000..0faa004 --- /dev/null +++ b/test/support/test_app/contexts/category_context.ex @@ -0,0 +1,6 @@ +defmodule Contexted.TestApp.Contexts.CategoryContext do + use Contexted.CRUD, + repo: Contexted.TestApp.Repo, + schema: Contexted.TestApp.Category, + plural_resource_name: "categories" +end diff --git a/test/support/test_app/contexts/item_context.ex b/test/support/test_app/contexts/item_context.ex new file mode 100644 index 0000000..b56fc1b --- /dev/null +++ b/test/support/test_app/contexts/item_context.ex @@ -0,0 +1,6 @@ +defmodule Contexted.TestApp.Contexts.ItemContext do + use Contexted.CRUD, + repo: Contexted.TestApp.Repo, + schema: Contexted.TestApp.Item, + plural_resource_name: "items" +end diff --git a/test/support/test_app/contexts/subcategory_context.ex b/test/support/test_app/contexts/subcategory_context.ex new file mode 100644 index 0000000..8098e5d --- /dev/null +++ b/test/support/test_app/contexts/subcategory_context.ex @@ -0,0 +1,6 @@ +defmodule Contexted.TestApp.Contexts.SubcategoryContext do + use Contexted.CRUD, + repo: Contexted.TestApp.Repo, + schema: Contexted.TestApp.Subcategory, + plural_resource_name: "subcategories" +end diff --git a/test/support/test_app/repo.ex b/test/support/test_app/repo.ex new file mode 100644 index 0000000..88e98dc --- /dev/null +++ b/test/support/test_app/repo.ex @@ -0,0 +1,5 @@ +defmodule Contexted.TestApp.Repo do + use Ecto.Repo, + otp_app: :contexted, + adapter: Ecto.Adapters.Postgres +end diff --git a/test/support/test_app/schemas/category.ex b/test/support/test_app/schemas/category.ex new file mode 100644 index 0000000..d5ca65e --- /dev/null +++ b/test/support/test_app/schemas/category.ex @@ -0,0 +1,17 @@ +defmodule Contexted.TestApp.Category do + use Ecto.Schema + import Ecto.Changeset + + schema "categories" do + field :name, :string + has_many :subcategories, Contexted.TestApp.Subcategory + timestamps() + end + + def changeset(category, attrs) do + category + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> unique_constraint([:name]) + end +end diff --git a/test/support/test_app/schemas/item.ex b/test/support/test_app/schemas/item.ex new file mode 100644 index 0000000..ca02521 --- /dev/null +++ b/test/support/test_app/schemas/item.ex @@ -0,0 +1,20 @@ +defmodule Contexted.TestApp.Item do + use Ecto.Schema + import Ecto.Changeset + + schema "items" do + field :name, :string + field :serial_number, :string + belongs_to :subcategory, Contexted.TestApp.Subcategory + timestamps() + end + + def changeset(item, attrs) do + item + |> cast(attrs, [:name, :serial_number, :subcategory_id]) + |> validate_required([:name, :serial_number, :subcategory_id]) + |> validate_length(:serial_number, max: 10) + |> assoc_constraint(:subcategory) + |> unique_constraint([:serial_number]) + end +end diff --git a/test/support/test_app/schemas/subcategory.ex b/test/support/test_app/schemas/subcategory.ex new file mode 100644 index 0000000..4fea9d7 --- /dev/null +++ b/test/support/test_app/schemas/subcategory.ex @@ -0,0 +1,19 @@ +defmodule Contexted.TestApp.Subcategory do + use Ecto.Schema + import Ecto.Changeset + + schema "subcategories" do + field :name, :string + belongs_to :category, Contexted.TestApp.Category + has_many :items, Contexted.TestApp.Item + timestamps() + end + + def changeset(subcategory, attrs) do + subcategory + |> cast(attrs, [:name, :category_id]) + |> validate_required([:name, :category_id]) + |> assoc_constraint(:category) + |> unique_constraint([:category_id, :name]) + end +end diff --git a/test/support/test_records.ex b/test/support/test_records.ex new file mode 100644 index 0000000..324b02c --- /dev/null +++ b/test/support/test_records.ex @@ -0,0 +1,72 @@ +defmodule Contexted.TestRecords do + alias Contexted.TestApp.Category + alias Contexted.TestApp.Subcategory + alias Contexted.TestApp.Item + alias Contexted.TestApp.Repo + + def test_records(context) do + [category1, category2] = + categories = [ + Repo.insert!(%Category{name: "Category 1"}), + Repo.insert!(%Category{name: "Category 2"}) + ] + + [subcategory1, subcategory2, subcategory3, subcategory4] = + subcategories = [ + Repo.insert!(%Subcategory{name: "Subcategory 1.1", category_id: category1.id}), + Repo.insert!(%Subcategory{name: "Subcategory 1.2", category_id: category1.id}), + Repo.insert!(%Subcategory{name: "Subcategory 2.1", category_id: category2.id}), + Repo.insert!(%Subcategory{name: "Subcategory 2.2", category_id: category2.id}) + ] + + items = [ + Repo.insert!(%Item{ + name: "Item 1.1.1", + serial_number: "1234567890", + subcategory_id: subcategory1.id + }), + Repo.insert!(%Item{ + name: "Item 1.1.2", + serial_number: "1234567891", + subcategory_id: subcategory1.id + }), + Repo.insert!(%Item{ + name: "Item 1.2.1", + serial_number: "1234567892", + subcategory_id: subcategory2.id + }), + Repo.insert!(%Item{ + name: "Item 1.2.2", + serial_number: "1234567893", + subcategory_id: subcategory2.id + }), + Repo.insert!(%Item{ + name: "Item 2.1.1", + serial_number: "1234567894", + subcategory_id: subcategory3.id + }), + Repo.insert!(%Item{ + name: "Item 2.1.2", + serial_number: "1234567895", + subcategory_id: subcategory3.id + }), + Repo.insert!(%Item{ + name: "Item 2.2.1", + serial_number: "1234567896", + subcategory_id: subcategory4.id + }), + Repo.insert!(%Item{ + name: "Item 2.2.2", + serial_number: "1234567897", + subcategory_id: subcategory4.id + }) + ] + + {:ok, + Map.merge(context, %{ + categories: categories, + subcategories: subcategories, + items: items + })} + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..e98ae9c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,6 @@ ExUnit.start() + +{:ok, _} = Ecto.Adapters.Postgres.ensure_all_started(Contexted.TestApp.Repo, :temporary) +{:ok, _pid} = Contexted.TestApp.Repo.start_link() + +Process.flag(:trap_exit, true) From f219d6151dc9d48f1277ea50e4a08004056db415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 17:54:03 +0100 Subject: [PATCH 17/26] add tests for preloads together with condition lists --- test/contexted/crud/list_test.exs | 48 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs index ad3c3eb..7d24ef7 100644 --- a/test/contexted/crud/list_test.exs +++ b/test/contexted/crud/list_test.exs @@ -3,6 +3,7 @@ defmodule Contexted.CRUD.ListTest do doctest Contexted.CRUD alias Contexted.TestApp.Item + alias Contexted.TestApp.Contexts.{CategoryContext, SubcategoryContext, ItemContext} import Contexted.TestRecords import Ecto.Query @@ -11,31 +12,26 @@ defmodule Contexted.CRUD.ListTest do describe "list_*/0" do test "list_categories/0", %{categories: categories} do - assert Contexted.TestApp.Contexts.CategoryContext.list_categories() |> MapSet.new() == - categories |> MapSet.new() + assert CategoryContext.list_categories() |> MapSet.new() == categories |> MapSet.new() end test "list_subcategories/0", %{subcategories: subcategories} do - assert Contexted.TestApp.Contexts.SubcategoryContext.list_subcategories() |> MapSet.new() == + assert SubcategoryContext.list_subcategories() |> MapSet.new() == subcategories |> MapSet.new() end test "list_items/0", %{items: items} do - assert Contexted.TestApp.Contexts.ItemContext.list_items() |> MapSet.new() == - items |> MapSet.new() + assert ItemContext.list_items() |> MapSet.new() == items |> MapSet.new() end end describe "list_*/1" do test "list_items(%Item{})", %{items: items} do - assert Contexted.TestApp.Contexts.ItemContext.list_items(Contexted.TestApp.Item) - |> MapSet.new() == items |> MapSet.new() + assert ItemContext.list_items(Item) |> MapSet.new() == items |> MapSet.new() end test "list_items(Ecto.Query.t())", %{items: items} do - assert Contexted.TestApp.Contexts.ItemContext.list_items( - from(i in Item, where: like(i.name, "Item 1.2.%")) - ) + assert ItemContext.list_items(from(i in Item, where: like(i.name, "Item 1.2.%"))) |> MapSet.new() == items |> Enum.filter(&String.starts_with?(&1.name, "Item 1.2.")) @@ -44,7 +40,7 @@ defmodule Contexted.CRUD.ListTest do test "list_items(Ecto.Query.t(), preload: :subcategory)", %{items: items} do loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items( + ItemContext.list_items( from(i in Item, where: like(i.name, "Item 1.2.%"), preload: :subcategory) ) @@ -60,7 +56,7 @@ defmodule Contexted.CRUD.ListTest do test "list_items(Ecto.Query.t(), preload: [:subcategory])", %{items: items} do loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items( + ItemContext.list_items( from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [:subcategory]) ) @@ -76,7 +72,7 @@ defmodule Contexted.CRUD.ListTest do test "list_items(Ecto.Query.t(), preload: [subcategory: :category])", %{items: items} do loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items( + ItemContext.list_items( from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [subcategory: :category]) ) @@ -95,7 +91,7 @@ defmodule Contexted.CRUD.ListTest do test "list_items(Ecto.Query.t(), preload: [subcategory: [:category]])", %{items: items} do loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items( + ItemContext.list_items( from(i in Item, where: like(i.name, "Item 1.2.%"), preload: [subcategory: :category]) ) @@ -113,8 +109,7 @@ defmodule Contexted.CRUD.ListTest do end test "list_items(subcategory: [name: \"Subcategory 1.1\"])", %{items: items} do - loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) + loaded_items = ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) assert loaded_items |> length == items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) @@ -125,9 +120,7 @@ defmodule Contexted.CRUD.ListTest do test "list_items(subcategory: [category: [name: \"Category 1\"]])", %{items: items} do loaded_items = - Contexted.TestApp.Contexts.ItemContext.list_items( - subcategory: [category: [name: "Category 2"]] - ) + ItemContext.list_items(subcategory: [category: [name: "Category 2"]]) assert loaded_items |> length == items |> Enum.count(&String.starts_with?(&1.name, "Item 2.")) @@ -135,5 +128,22 @@ defmodule Contexted.CRUD.ListTest do assert loaded_items |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 2.") end) end + + test "list_items(subcategory_id: 1, preload: [subcategory: :category])", %{ + items: items, + subcategories: [subcategory1 | _] + } do + loaded_items = + ItemContext.list_items(subcategory_id: subcategory1.id, preload: [subcategory: :category]) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) + end end end From 5e063ad99f4b09412982118810f0cb2cd827d6ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Wed, 5 Feb 2025 17:55:51 +0100 Subject: [PATCH 18/26] separate test section for list_*/2 --- test/contexted/crud/list_test.exs | 78 ++++++++++++++++--------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs index 7d24ef7..6aba2f6 100644 --- a/test/contexted/crud/list_test.exs +++ b/test/contexted/crud/list_test.exs @@ -38,6 +38,46 @@ defmodule Contexted.CRUD.ListTest do |> MapSet.new() end + test "list_items(subcategory: [name: \"Subcategory 1.1\"])", %{items: items} do + loaded_items = ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) + end + + test "list_items(subcategory: [category: [name: \"Category 1\"]])", %{items: items} do + loaded_items = + ItemContext.list_items(subcategory: [category: [name: "Category 2"]]) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 2.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 2.") end) + end + + test "list_items(subcategory_id: 1, preload: [subcategory: :category])", %{ + items: items, + subcategories: [subcategory1 | _] + } do + loaded_items = + ItemContext.list_items(subcategory_id: subcategory1.id, preload: [subcategory: :category]) + + assert loaded_items |> length == + items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) + + assert loaded_items + |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) + + assert loaded_items + |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) + end + end + + describe "list_*/2" do test "list_items(Ecto.Query.t(), preload: :subcategory)", %{items: items} do loaded_items = ItemContext.list_items( @@ -107,43 +147,5 @@ defmodule Contexted.CRUD.ListTest do assert loaded_items |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) end - - test "list_items(subcategory: [name: \"Subcategory 1.1\"])", %{items: items} do - loaded_items = ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) - - assert loaded_items |> length == - items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) - - assert loaded_items - |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) - end - - test "list_items(subcategory: [category: [name: \"Category 1\"]])", %{items: items} do - loaded_items = - ItemContext.list_items(subcategory: [category: [name: "Category 2"]]) - - assert loaded_items |> length == - items |> Enum.count(&String.starts_with?(&1.name, "Item 2.")) - - assert loaded_items - |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 2.") end) - end - - test "list_items(subcategory_id: 1, preload: [subcategory: :category])", %{ - items: items, - subcategories: [subcategory1 | _] - } do - loaded_items = - ItemContext.list_items(subcategory_id: subcategory1.id, preload: [subcategory: :category]) - - assert loaded_items |> length == - items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) - - assert loaded_items - |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) - - assert loaded_items - |> Enum.all?(fn item -> item.subcategory.category.name == "Category 1" end) - end end end From 1c2c20ffc469db6daa912b5cfae2eac6e157b3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 6 Feb 2025 12:24:18 +0100 Subject: [PATCH 19/26] add test and fixes to get_*_by functions --- lib/contexted/crud.ex | 18 ++++- test/contexted/crud/get_by_test.exs | 120 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 424ee38..d814c5a 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -242,12 +242,19 @@ defmodule Contexted.CRUD do @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 - # One arg: get resource based on the query + # 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 @@ -280,12 +287,19 @@ defmodule Contexted.CRUD do @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 - # One arg: get resource based on the query + # 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 diff --git a/test/contexted/crud/get_by_test.exs b/test/contexted/crud/get_by_test.exs index e69de29..9ccfec4 100644 --- a/test/contexted/crud/get_by_test.exs +++ b/test/contexted/crud/get_by_test.exs @@ -0,0 +1,120 @@ +defmodule Contexted.CRUD.GetByTest do + use Contexted.DataCase + doctest Contexted.CRUD + + alias Contexted.TestApp.{Item, Category, Subcategory} + alias Contexted.TestApp.Contexts.ItemContext + + import Contexted.TestRecords + import Ecto.Query + setup [:test_records] + + describe "get_*_by/1" do + test "get_item_by(name: \"Item 1.1.1\")", %{items: items} do + assert ItemContext.get_item_by(name: "Item 1.1.1") == + items |> Enum.find(&(&1.name == "Item 1.1.1")) + end + + test "get_item_by(name: \"Item 1.1.1\", preload: :subcategory)" do + assert %Item{subcategory: %Subcategory{name: "Subcategory 1.1"}} = + ItemContext.get_item_by(name: "Item 1.1.1", preload: :subcategory) + end + + test "get_item_by(name: \"Item 1.1.1\", preload: [subcategory: :category])" do + assert %Item{ + subcategory: %Subcategory{ + name: "Subcategory 1.1", + category: %Category{name: "Category 1"} + } + } = + ItemContext.get_item_by(name: "Item 1.1.1", preload: [subcategory: :category]) + end + + test "get_item_by(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"))" do + assert %Item{name: "Item 1.1.1"} = + ItemContext.get_item_by( + from(i in Item, + where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890" + ) + ) + end + + test "get_item_by(from(i in Item, where: i.serial_number == \"nonexistent\"))" do + assert nil == + ItemContext.get_item_by(from(i in Item, where: i.serial_number == "nonexistent")) + end + end + + describe("get_*_by!/1") do + test "get_item_by!(name: \"Item 1.1.1\")", %{items: items} do + assert ItemContext.get_item_by!(name: "Item 1.1.1") == + items |> Enum.find(&(&1.name == "Item 1.1.1")) + end + + test "get_item_by!(name: \"Item 1.1.1\", preload: :subcategory)" do + assert %Item{subcategory: %Subcategory{name: "Subcategory 1.1"}} = + ItemContext.get_item_by!(name: "Item 1.1.1", preload: :subcategory) + end + + test "get_item_by!(name: \"Item 1.1.1\", preload: [subcategory: :category])" do + assert %Item{ + subcategory: %Subcategory{ + name: "Subcategory 1.1", + category: %Category{name: "Category 1"} + } + } = + ItemContext.get_item_by!(name: "Item 1.1.1", preload: [subcategory: :category]) + end + + test "get_item_by!(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"))" do + assert %Item{name: "Item 1.1.1"} = + ItemContext.get_item_by!( + from(i in Item, + where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890" + ) + ) + end + + test "get_item_by!(from(i in Item, where: i.serial_number == \"nonexistent\"))" do + assert_raise Ecto.NoResultsError, fn -> + ItemContext.get_item_by!(from(i in Item, where: i.serial_number == "nonexistent")) + end + end + end + + describe("get_*_by/2") do + test "get_item_by(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"), preload: [subcategory: :category])" do + assert %Item{ + name: "Item 1.1.1", + subcategory: %Subcategory{ + name: "Subcategory 1.1", + category: %Category{name: "Category 1"} + } + } = + ItemContext.get_item_by( + from(i in Item, + where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890" + ), + preload: [subcategory: :category] + ) + end + end + + describe("get_*_by!/2") do + test "get_item_by!(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"), preload: [subcategory: :category])" do + assert %Item{ + name: "Item 1.1.1", + subcategory: %Subcategory{ + name: "Subcategory 1.1", + category: %Category{name: "Category 1"} + } + } = + ItemContext.get_item_by!( + from(i in Item, + where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890" + ), + preload: [subcategory: :category] + ) + end + end +end From 85283b371070c37b6803708f385ee3032971cdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 6 Feb 2025 12:59:46 +0100 Subject: [PATCH 20/26] fix and add tests to get_* functions --- lib/contexted/crud.ex | 7 +++-- test/contexted/crud/get_test.exs | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index d814c5a..82a8e77 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -158,19 +158,18 @@ defmodule Contexted.CRUD do iex> get_#{resource_name}(id) %#{Macro.camelize(resource_name)}{} or nil - iex> get_#{resource_name}(id, [:associated]) + iex> get_#{resource_name}(id, preload: [:associated]) %#{Macro.camelize(resource_name)}{associated: ...} or nil """ @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: %unquote(schema){} | nil - def unquote(function_name)(id, preloads \\ []) - when is_list(preloads) or is_atom(preloads) do + 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, preloads) + record -> unquote(repo).preload(record, opts[:preload] || []) end end diff --git a/test/contexted/crud/get_test.exs b/test/contexted/crud/get_test.exs index e69de29..c9a44eb 100644 --- a/test/contexted/crud/get_test.exs +++ b/test/contexted/crud/get_test.exs @@ -0,0 +1,45 @@ +defmodule Contexted.CRUD.GetTest do + use Contexted.DataCase + doctest Contexted.CRUD + + alias Contexted.TestApp.{Item, Category, Subcategory} + alias Contexted.TestApp.Contexts.ItemContext + + import Contexted.TestRecords + + setup [:test_records] + + describe "get/1" do + test "returns the resource", %{items: [%{id: id} | _]} do + assert %Item{id: ^id} = ItemContext.get_item(id) + end + + test "returns the resource with subcategory preload", %{ + items: [%{id: id, subcategory_id: subcategory_id} | _] + } do + assert %Item{id: ^id, subcategory: %Subcategory{id: ^subcategory_id}} = + ItemContext.get_item(id, preload: :subcategory) + end + + test "returns the resource with subcategory and category preloads", %{ + items: [%{id: id, subcategory_id: subcategory_id} | _], + subcategories: subcategories + } do + category_id = + subcategories |> Enum.find(&(&1.id == subcategory_id)) |> Map.get(:category_id) + + assert %Item{ + id: ^id, + subcategory: %Subcategory{ + id: ^subcategory_id, + category: %Category{id: ^category_id} + } + } = + ItemContext.get_item(id, preload: [subcategory: :category]) + end + + test "returns nil if the resource does not exist" do + assert nil == ItemContext.get_item(0) + end + end +end From e61348facee6745b844a95258c8aa1ca21250892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 6 Feb 2025 13:33:16 +0100 Subject: [PATCH 21/26] update docs for get and list functions --- lib/contexted/crud.ex | 73 +++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 82a8e77..608484d 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -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. @@ -79,10 +81,9 @@ defmodule Contexted.CRUD do - No arguments: Returns all records - Single argument: - `Ecto.Query` or schema module: Uses this as the base query - - Keyword list: Used as conditions and options (e.g. preload) + - 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 - - Conditions + preloads: Applies conditions and preloads separately ## Options @@ -99,10 +100,10 @@ defmodule Contexted.CRUD do iex> list_#{plural_resource_name}(preload: :associated) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] - iex> list_#{plural_resource_name}([status: :active], preload: [:associated]) + iex> list_#{plural_resource_name}(status: :active, preload: [:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] - iex> list_#{plural_resource_name}(query, preload: [:associated]) + iex> list_#{plural_resource_name}(#{schema} |> limit(10), preload: [:associated]) [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ @@ -134,14 +135,6 @@ defmodule Contexted.CRUD do |> unquote(repo).all() |> unquote(repo).preload(opts[:preload] || []) end - - def unquote(function_name)(conditions, preloads) - when is_list(conditions) and is_list(preloads) do - # Two args: list all resources based on the preloads - unquote(schema) - |> unquote(repo).all() - |> unquote(repo).preload(preloads) - end end unless :get in exclude do @@ -162,7 +155,7 @@ defmodule Contexted.CRUD do %#{Macro.camelize(resource_name)}{associated: ...} or nil """ - @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: + @spec unquote(function_name)(integer() | String.t(), keyword()) :: %unquote(schema){} | nil def unquote(function_name)(id, opts \\ []) when is_list(opts) do unquote(schema) @@ -186,13 +179,11 @@ defmodule Contexted.CRUD do iex> get_#{resource_name}!(id) %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError - iex> get_#{resource_name}!(id, [:associated]) + iex> get_#{resource_name}!(id, preload: [:associated]) %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError """ - @spec unquote(function_name)(integer() | String.t(), keyword() | atom()) :: %unquote( - schema - ){} + @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) @@ -228,14 +219,29 @@ defmodule Contexted.CRUD do ## Examples - iex> get_#{resource_name}_by(%{status: "active"}) + # Exact match condition list + iex> get_#{resource_name}_by(status: "active") %#{Macro.camelize(resource_name)}{} or nil - iex> get_#{resource_name}_by([status: "active"], preload: :associated) + # 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){} @@ -281,8 +287,29 @@ defmodule Contexted.CRUD do @doc """ Similar to get_#{resource_name}_by/2 but raises Ecto.NoResultsError if no result is found. - See get_#{resource_name}_by/2 for more details on arguments and options. + ## 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 From 50b5a67b6a457ab73561419fbf60b9f90e81dcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 6 Feb 2025 14:42:14 +0100 Subject: [PATCH 22/26] chore: add postrges to CI --- .github/workflows/ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6e26e0..f470f70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 From 6bc91cc032e896010edd1e04c0d383d7277252c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Thu, 6 Feb 2025 15:11:00 +0100 Subject: [PATCH 23/26] chore: fix credo indications --- test/contexted/crud/get_by_test.exs | 24 +++++++++---------- test/contexted/crud/get_test.exs | 2 +- test/contexted/crud/list_test.exs | 6 ++--- test/support/data_case.ex | 2 +- .../test_app/contexts/category_context.ex | 1 + .../support/test_app/contexts/item_context.ex | 1 + .../test_app/contexts/subcategory_context.ex | 1 + test/support/test_app/schemas/category.ex | 1 + test/support/test_app/schemas/item.ex | 1 + test/support/test_app/schemas/subcategory.ex | 1 + test/support/test_records.ex | 3 ++- 11 files changed, 25 insertions(+), 18 deletions(-) diff --git a/test/contexted/crud/get_by_test.exs b/test/contexted/crud/get_by_test.exs index 9ccfec4..5201ef6 100644 --- a/test/contexted/crud/get_by_test.exs +++ b/test/contexted/crud/get_by_test.exs @@ -2,7 +2,7 @@ defmodule Contexted.CRUD.GetByTest do use Contexted.DataCase doctest Contexted.CRUD - alias Contexted.TestApp.{Item, Category, Subcategory} + alias Contexted.TestApp.{Category, Item, Subcategory} alias Contexted.TestApp.Contexts.ItemContext import Contexted.TestRecords @@ -10,17 +10,17 @@ defmodule Contexted.CRUD.GetByTest do setup [:test_records] describe "get_*_by/1" do - test "get_item_by(name: \"Item 1.1.1\")", %{items: items} do + test ~s{get_item_by(name: "Item 1.1.1")}, %{items: items} do assert ItemContext.get_item_by(name: "Item 1.1.1") == items |> Enum.find(&(&1.name == "Item 1.1.1")) end - test "get_item_by(name: \"Item 1.1.1\", preload: :subcategory)" do + test ~s{get_item_by(name: "Item 1.1.1", preload: :subcategory)} do assert %Item{subcategory: %Subcategory{name: "Subcategory 1.1"}} = ItemContext.get_item_by(name: "Item 1.1.1", preload: :subcategory) end - test "get_item_by(name: \"Item 1.1.1\", preload: [subcategory: :category])" do + test ~s{get_item_by(name: "Item 1.1.1", preload: [subcategory: :category])} do assert %Item{ subcategory: %Subcategory{ name: "Subcategory 1.1", @@ -30,7 +30,7 @@ defmodule Contexted.CRUD.GetByTest do ItemContext.get_item_by(name: "Item 1.1.1", preload: [subcategory: :category]) end - test "get_item_by(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"))" do + test ~s{get_item_by(from(i in Item, where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890"))} do assert %Item{name: "Item 1.1.1"} = ItemContext.get_item_by( from(i in Item, @@ -39,24 +39,24 @@ defmodule Contexted.CRUD.GetByTest do ) end - test "get_item_by(from(i in Item, where: i.serial_number == \"nonexistent\"))" do + test ~s{get_item_by(from(i in Item, where: i.serial_number == "nonexistent"))} do assert nil == ItemContext.get_item_by(from(i in Item, where: i.serial_number == "nonexistent")) end end describe("get_*_by!/1") do - test "get_item_by!(name: \"Item 1.1.1\")", %{items: items} do + test ~s{get_item_by!(name: "Item 1.1.1")}, %{items: items} do assert ItemContext.get_item_by!(name: "Item 1.1.1") == items |> Enum.find(&(&1.name == "Item 1.1.1")) end - test "get_item_by!(name: \"Item 1.1.1\", preload: :subcategory)" do + test ~s{get_item_by!(name: "Item 1.1.1", preload: :subcategory)} do assert %Item{subcategory: %Subcategory{name: "Subcategory 1.1"}} = ItemContext.get_item_by!(name: "Item 1.1.1", preload: :subcategory) end - test "get_item_by!(name: \"Item 1.1.1\", preload: [subcategory: :category])" do + test ~s{get_item_by!(name: "Item 1.1.1", preload: [subcategory: :category])} do assert %Item{ subcategory: %Subcategory{ name: "Subcategory 1.1", @@ -66,7 +66,7 @@ defmodule Contexted.CRUD.GetByTest do ItemContext.get_item_by!(name: "Item 1.1.1", preload: [subcategory: :category]) end - test "get_item_by!(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"))" do + test ~s{get_item_by!(from(i in Item, where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890"))} do assert %Item{name: "Item 1.1.1"} = ItemContext.get_item_by!( from(i in Item, @@ -83,7 +83,7 @@ defmodule Contexted.CRUD.GetByTest do end describe("get_*_by/2") do - test "get_item_by(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"), preload: [subcategory: :category])" do + test ~s{get_item_by(from(i in Item, where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890"), preload: [subcategory: :category])} do assert %Item{ name: "Item 1.1.1", subcategory: %Subcategory{ @@ -101,7 +101,7 @@ defmodule Contexted.CRUD.GetByTest do end describe("get_*_by!/2") do - test "get_item_by!(from(i in Item, where: like(i.name, \"Item 1.1.%\") and i.serial_number == \"1234567890\"), preload: [subcategory: :category])" do + test ~s{get_item_by!(from(i in Item, where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890"), preload: [subcategory: :category])} do assert %Item{ name: "Item 1.1.1", subcategory: %Subcategory{ diff --git a/test/contexted/crud/get_test.exs b/test/contexted/crud/get_test.exs index c9a44eb..5bbadff 100644 --- a/test/contexted/crud/get_test.exs +++ b/test/contexted/crud/get_test.exs @@ -2,7 +2,7 @@ defmodule Contexted.CRUD.GetTest do use Contexted.DataCase doctest Contexted.CRUD - alias Contexted.TestApp.{Item, Category, Subcategory} + alias Contexted.TestApp.{Category, Item, Subcategory} alias Contexted.TestApp.Contexts.ItemContext import Contexted.TestRecords diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs index 6aba2f6..66f86f1 100644 --- a/test/contexted/crud/list_test.exs +++ b/test/contexted/crud/list_test.exs @@ -3,7 +3,7 @@ defmodule Contexted.CRUD.ListTest do doctest Contexted.CRUD alias Contexted.TestApp.Item - alias Contexted.TestApp.Contexts.{CategoryContext, SubcategoryContext, ItemContext} + alias Contexted.TestApp.Contexts.{CategoryContext, ItemContext, SubcategoryContext} import Contexted.TestRecords import Ecto.Query @@ -38,7 +38,7 @@ defmodule Contexted.CRUD.ListTest do |> MapSet.new() end - test "list_items(subcategory: [name: \"Subcategory 1.1\"])", %{items: items} do + test ~s{list_items(subcategory: [name: "Subcategory 1.1"])}, %{items: items} do loaded_items = ItemContext.list_items(subcategory: [name: "Subcategory 1.1"]) assert loaded_items |> length == @@ -48,7 +48,7 @@ defmodule Contexted.CRUD.ListTest do |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) end - test "list_items(subcategory: [category: [name: \"Category 1\"]])", %{items: items} do + test ~s{list_items(subcategory: [category: [name: "Category 1"]])}, %{items: items} do loaded_items = ItemContext.list_items(subcategory: [category: [name: "Category 2"]]) diff --git a/test/support/data_case.ex b/test/support/data_case.ex index b0528a3..00915fa 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -2,8 +2,8 @@ defmodule Contexted.DataCase do @moduledoc false use ExUnit.CaseTemplate - alias Ecto.Adapters.SQL.Sandbox alias Contexted.TestApp.Repo + alias Ecto.Adapters.SQL.Sandbox setup tags do :ok = Sandbox.checkout(Repo) diff --git a/test/support/test_app/contexts/category_context.ex b/test/support/test_app/contexts/category_context.ex index 0faa004..3b5bce1 100644 --- a/test/support/test_app/contexts/category_context.ex +++ b/test/support/test_app/contexts/category_context.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Contexts.CategoryContext do + @moduledoc false use Contexted.CRUD, repo: Contexted.TestApp.Repo, schema: Contexted.TestApp.Category, diff --git a/test/support/test_app/contexts/item_context.ex b/test/support/test_app/contexts/item_context.ex index b56fc1b..147f2ea 100644 --- a/test/support/test_app/contexts/item_context.ex +++ b/test/support/test_app/contexts/item_context.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Contexts.ItemContext do + @moduledoc false use Contexted.CRUD, repo: Contexted.TestApp.Repo, schema: Contexted.TestApp.Item, diff --git a/test/support/test_app/contexts/subcategory_context.ex b/test/support/test_app/contexts/subcategory_context.ex index 8098e5d..8f93021 100644 --- a/test/support/test_app/contexts/subcategory_context.ex +++ b/test/support/test_app/contexts/subcategory_context.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Contexts.SubcategoryContext do + @moduledoc false use Contexted.CRUD, repo: Contexted.TestApp.Repo, schema: Contexted.TestApp.Subcategory, diff --git a/test/support/test_app/schemas/category.ex b/test/support/test_app/schemas/category.ex index d5ca65e..790f128 100644 --- a/test/support/test_app/schemas/category.ex +++ b/test/support/test_app/schemas/category.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Category do + @moduledoc false use Ecto.Schema import Ecto.Changeset diff --git a/test/support/test_app/schemas/item.ex b/test/support/test_app/schemas/item.ex index ca02521..13e2f3d 100644 --- a/test/support/test_app/schemas/item.ex +++ b/test/support/test_app/schemas/item.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Item do + @moduledoc false use Ecto.Schema import Ecto.Changeset diff --git a/test/support/test_app/schemas/subcategory.ex b/test/support/test_app/schemas/subcategory.ex index 4fea9d7..6555c67 100644 --- a/test/support/test_app/schemas/subcategory.ex +++ b/test/support/test_app/schemas/subcategory.ex @@ -1,4 +1,5 @@ defmodule Contexted.TestApp.Subcategory do + @moduledoc false use Ecto.Schema import Ecto.Changeset diff --git a/test/support/test_records.ex b/test/support/test_records.ex index 324b02c..83759ee 100644 --- a/test/support/test_records.ex +++ b/test/support/test_records.ex @@ -1,8 +1,9 @@ defmodule Contexted.TestRecords do + @moduledoc false alias Contexted.TestApp.Category - alias Contexted.TestApp.Subcategory alias Contexted.TestApp.Item alias Contexted.TestApp.Repo + alias Contexted.TestApp.Subcategory def test_records(context) do [category1, category2] = From 321a6e350dc15475d8eb2a13c102964f736569f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Fri, 7 Feb 2025 13:37:06 +0100 Subject: [PATCH 24/26] remove unused functions --- lib/contexted/crud.ex | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 608484d..9088937 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -274,14 +274,6 @@ defmodule Contexted.CRUD do |> 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 """ @@ -339,14 +331,6 @@ defmodule Contexted.CRUD do |> 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 unless :create in exclude do From 60b1c97947eac4e72617d248d8b27b170239b361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Fri, 7 Feb 2025 14:00:02 +0100 Subject: [PATCH 25/26] update readme --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bb3202..2c04a37 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,8 @@ Read more about `Contexted.Delegator` and its options in [docs](https://hexdocs. ### Don't repeat yourself with CRUD operations -In most web apps CRUD operations are very common. Most of these, have the same pattern. Why not autogenerate them? +In most web apps CRUD operations are very common. Most of these, have the same pattern. Most of the time, they are used with preloading associated resources as well as filtering based on conditions such as search, pagination, etc. +Why not autogenerate them? Here is how you can generate common CRUD operations for `App.Account.Users`: @@ -242,7 +243,13 @@ iex> App.Accounts.Users.__info__(:functions) delete_user!: 1, get_user: 1, get_user!: 1, + get_user_by: 1, + get_user_by!: 1, + get_user_by: 2, + get_user_by!: 2, list_users: 0, + list_users: 1, + list_users: 2, update_user: 1, update_user: 2, update_user!: 1, @@ -250,6 +257,8 @@ iex> App.Accounts.Users.__info__(:functions) ] ``` +Generated creation and updating functions default to the corresponding schema's `changeset/1` and `changeset/2` functions, respectively, whereas list and get functions provide a means to quickly specify filtering conditions (via plain exact match condition lists or by passing an Ecto.Query) and preloads. + Read more about `Contexted.CRUD` and its options in [docs](https://hexdocs.pm/contexted/Contexted.CRUD.html).
From dd56b7cfea1334f601b73285a2593e2b92397d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Buszkiewicz?= Date: Mon, 10 Feb 2025 12:04:16 +0100 Subject: [PATCH 26/26] feat: add order_by, limit and offset options --- README.md | 40 ++++++++++++++++++- lib/contexted/crud.ex | 17 ++++---- lib/contexted/query_builder.ex | 11 +++++- test/contexted/crud/get_by_test.exs | 60 +++++++++++++++++++++++++++++ test/contexted/crud/list_test.exs | 51 ++++++++++++++++++++---- 5 files changed, 162 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2c04a37..ce9c91b 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,45 @@ iex> App.Accounts.Users.__info__(:functions) ] ``` -Generated creation and updating functions default to the corresponding schema's `changeset/1` and `changeset/2` functions, respectively, whereas list and get functions provide a means to quickly specify filtering conditions (via plain exact match condition lists or by passing an Ecto.Query) and preloads. +Generated creation and updating functions default to the corresponding schema's `changeset/1` and `changeset/2` functions, respectively, whereas list and get functions provide a means to manipulate the result by: + +* filtering conditions (via plain exact match condition lists or by passing an Ecto.Query) +* preloads +* orderings +* limits +* offsets + +Examples: + +```elixir +# List all users with posts preloaded +iex> App.Accounts.Users.list_users(preload: [:posts]) + +# Use an Ecto.Query to filter users, and a keyword list of options to manipulate the result +iex> App.Accounts.Users.list_users( + App.Accounts.User |> where([u], u.status == "active"), + preload: [:posts], + order_by: [desc: :inserted_at], + limit: 10, + offset: 0 +) + +# Use a keyword list of exact match conditions and manipulation options +iex> App.Accounts.Users.list_users( + status: "active", + subscription: [plan: "free"], + order_by: [desc: :inserted_at] +) + +# Get a user by ID with subscription preloaded +iex> App.Accounts.Users.get_user!(10, preload: [:subscription]) + +# Get a user by profile email with profile and subscription preloaded +iex> App.Accounts.Users.get_user_by!(profile: [email: "user@example.com"], preload: [:profile, :subscription]) + +# Use an Ecto.Query to get a specific user +iex> App.Accounts.Users.get_user_by!(App.Accounts.User |> where([u], u.id == 10), preload: [:profile, :subscription]) +``` Read more about `Contexted.CRUD` and its options in [docs](https://hexdocs.pm/contexted/Contexted.CRUD.html). diff --git a/lib/contexted/crud.ex b/lib/contexted/crud.ex index 9088937..b79247a 100644 --- a/lib/contexted/crud.ex +++ b/lib/contexted/crud.ex @@ -67,10 +67,13 @@ defmodule Contexted.CRUD do plural_resource_name: plural_resource_name ] do import Contexted.QueryBuilder + import Ecto.Query unless :list in exclude do function_name = String.to_atom("list_#{plural_resource_name}") + @opt_keys [:preload, :order_by, :limit, :offset] + @doc """ Returns a list of all #{plural_resource_name} from the database. @@ -121,9 +124,9 @@ defmodule Contexted.CRUD do 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]) + {opts, conditions} = Keyword.split(conditions_and_opts, @opt_keys) - build_query(unquote(schema), conditions) + build_query(unquote(schema), conditions, opts) |> unquote(repo).all() |> unquote(repo).preload(opts[:preload] || []) end @@ -131,7 +134,7 @@ defmodule Contexted.CRUD do 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 + build_query(query, [], opts) |> unquote(repo).all() |> unquote(repo).preload(opts[:preload] || []) end @@ -266,10 +269,10 @@ defmodule Contexted.CRUD do # One arg: get resource with conditions and preloads {opts, conditions} = if is_list(conditions_and_opts), - do: Keyword.split(conditions_and_opts, [:preload]), + do: Keyword.split(conditions_and_opts, @opt_keys), else: {[], conditions_and_opts} - build_query(unquote(schema), conditions) + build_query(unquote(schema), conditions, opts) |> unquote(repo).one() |> maybe_preload(opts[:preload]) end @@ -324,10 +327,10 @@ defmodule Contexted.CRUD do # One arg: get resource with conditions and preloads {opts, conditions} = if is_list(conditions_and_opts), - do: Keyword.split(conditions_and_opts, [:preload]), + do: Keyword.split(conditions_and_opts, @opt_keys), else: {[], conditions_and_opts} - build_query(unquote(schema), conditions) + build_query(unquote(schema), conditions, opts) |> unquote(repo).one!() |> maybe_preload(opts[:preload]) end diff --git a/lib/contexted/query_builder.ex b/lib/contexted/query_builder.ex index fd16cc1..380f431 100644 --- a/lib/contexted/query_builder.ex +++ b/lib/contexted/query_builder.ex @@ -41,8 +41,17 @@ defmodule Contexted.QueryBuilder do # where: i.category == "electronics" and m.name == "Acme" and i.part_number == "1234567890" ``` """ - def build_query(schema, conditions) do + def build_query(schema, conditions, opts \\ []) + + def build_query(schema, conditions, opts) when is_map(conditions) do + build_query(schema, Map.to_list(conditions), opts) + end + + def build_query(schema, conditions, opts) do from(r in schema) + |> then(&if opts[:order_by], do: order_by(&1, ^opts[:order_by]), else: &1) + |> then(&if opts[:limit], do: limit(&1, ^opts[:limit]), else: &1) + |> then(&if opts[:offset], do: offset(&1, ^opts[:offset]), else: &1) |> traverse_conditions(conditions, []) end diff --git a/test/contexted/crud/get_by_test.exs b/test/contexted/crud/get_by_test.exs index 5201ef6..9c8fe94 100644 --- a/test/contexted/crud/get_by_test.exs +++ b/test/contexted/crud/get_by_test.exs @@ -43,6 +43,30 @@ defmodule Contexted.CRUD.GetByTest do assert nil == ItemContext.get_item_by(from(i in Item, where: i.serial_number == "nonexistent")) end + + test "get_item_by with empty conditions raises Ecto.MultipleResultsError" do + assert_raise Ecto.MultipleResultsError, fn -> + ItemContext.get_item_by(%{}) + end + end + + test "get_item_by with non-unique condition raises Ecto.MultipleResultsError", %{ + subcategories: [subcategory1 | _] + } do + assert_raise Ecto.MultipleResultsError, fn -> + ItemContext.get_item_by(subcategory_id: subcategory1.id) + end + end + + test "get_item_by with high offset returns nil" do + assert nil == ItemContext.get_item_by(name: "Item 1.1.1", offset: 100) + end + + test "get_item_by with unknown field raises an error" do + assert_raise Ecto.QueryError, fn -> + ItemContext.get_item_by(foo: "bar") + end + end end describe("get_*_by!/1") do @@ -80,6 +104,18 @@ defmodule Contexted.CRUD.GetByTest do ItemContext.get_item_by!(from(i in Item, where: i.serial_number == "nonexistent")) end end + + test "get_item_by! with empty conditions raises Ecto.MultipleResultsError" do + assert_raise Ecto.MultipleResultsError, fn -> + ItemContext.get_item_by!(%{}) + end + end + + test "get_item_by! with high offset raises Ecto.NoResultsError" do + assert_raise Ecto.NoResultsError, fn -> + ItemContext.get_item_by!(name: "Item 1.1.1", offset: 1) + end + end end describe("get_*_by/2") do @@ -117,4 +153,28 @@ defmodule Contexted.CRUD.GetByTest do ) end end + + test "get_item_by with order, limit, and offset", %{subcategories: [subcategory1 | _]} do + item = + ItemContext.get_item_by( + subcategory_id: subcategory1.id, + order_by: [desc: :name], + limit: 1, + offset: 1 + ) + + assert item.name == "Item 1.1.1" + end + + test "get_item_by! with order, limit, and offset", %{subcategories: [subcategory1 | _]} do + item = + ItemContext.get_item_by!( + subcategory_id: subcategory1.id, + order_by: [desc: :name], + limit: 1, + offset: 1 + ) + + assert item.name == "Item 1.1.1" + end end diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs index 66f86f1..9d6adbe 100644 --- a/test/contexted/crud/list_test.exs +++ b/test/contexted/crud/list_test.exs @@ -26,10 +26,19 @@ defmodule Contexted.CRUD.ListTest do end describe "list_*/1" do - test "list_items(%Item{})", %{items: items} do + test "list_items(Item)", %{items: items} do assert ItemContext.list_items(Item) |> MapSet.new() == items |> MapSet.new() end + test "list_items(Item, limit: 2, offset: 1, order_by: [desc: :name])" do + assert ItemContext.list_items(Item, limit: 2, offset: 1, order_by: [desc: :name]) + |> Enum.map(& &1.name) == + [ + "Item 2.2.1", + "Item 2.1.2" + ] + end + test "list_items(Ecto.Query.t())", %{items: items} do assert ItemContext.list_items(from(i in Item, where: like(i.name, "Item 1.2.%"))) |> MapSet.new() == @@ -59,15 +68,20 @@ defmodule Contexted.CRUD.ListTest do |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 2.") end) end - test "list_items(subcategory_id: 1, preload: [subcategory: :category])", %{ - items: items, - subcategories: [subcategory1 | _] - } do + test "list_items(subcategory_id: 1, preload: [subcategory: :category], limit: 1, offset: 1, order_by: [desc: :name])", + %{ + subcategories: [subcategory1 | _] + } do loaded_items = - ItemContext.list_items(subcategory_id: subcategory1.id, preload: [subcategory: :category]) + ItemContext.list_items( + subcategory_id: subcategory1.id, + preload: [subcategory: :category], + limit: 1, + offset: 1, + order_by: [desc: :name] + ) - assert loaded_items |> length == - items |> Enum.count(&String.starts_with?(&1.name, "Item 1.1.")) + assert loaded_items |> length == 1 assert loaded_items |> Enum.all?(fn item -> String.starts_with?(item.name, "Item 1.1.") end) @@ -94,6 +108,27 @@ defmodule Contexted.CRUD.ListTest do assert loaded_items |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) end + test "list_items(Ecto.Query.t(), preload: :subcategory, limit: 1, offset: 1, order_by: [desc: :name])" do + loaded_items = + ItemContext.list_items( + from(i in Item, + where: like(i.name, "Item 1.2.%"), + preload: :subcategory, + limit: 1, + offset: 1, + order_by: [desc: :name] + ) + ) + + assert loaded_items |> length == 1 + + assert loaded_items + |> Enum.map(& &1.name) + |> Enum.all?(fn name -> String.starts_with?(name, "Item 1.2.") end) + + assert loaded_items |> Enum.all?(fn item -> item.subcategory.name == "Subcategory 1.2" end) + end + test "list_items(Ecto.Query.t(), preload: [:subcategory])", %{items: items} do loaded_items = ItemContext.list_items(