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] ] 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 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/README.md b/README.md index 8bb3202..ce9c91b 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,46 @@ 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 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/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/lib/contexted/crud.ex b/lib/contexted/crud.ex index 372baae..b79247a 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. @@ -64,22 +66,78 @@ defmodule Contexted.CRUD do resource_name: resource_name, 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. + ## Arguments + + The function accepts several argument patterns: + + - No arguments: Returns all records + - Single argument: + - `Ecto.Query` or schema module: Uses this as the base query + - Keyword list: Exact, possibly nested, match conditions (translated to Ecto queries under the hood) and options (e.g. preload) + - Two arguments: + - Query + options: Uses query and applies options like preloads + + ## Options + + - `:preload` - Preloads associations. Can be an atom or list of atoms. + ## Examples iex> list_#{plural_resource_name}() [%#{Macro.camelize(resource_name)}{}, ...] + + iex> list_#{plural_resource_name}(from r in #{schema}, limit: 10) + [%#{Macro.camelize(resource_name)}{}, ...] + + iex> list_#{plural_resource_name}(preload: :associated) + [%#{Macro.camelize(resource_name)}{associated: ...}, ...] + + iex> list_#{plural_resource_name}(status: :active, preload: [:associated]) + [%#{Macro.camelize(resource_name)}{associated: ...}, ...] + + iex> list_#{plural_resource_name}(#{schema} |> limit(10), preload: [:associated]) + [%#{Macro.camelize(resource_name)}{associated: ...}, ...] """ - @spec unquote(function_name)() :: [%unquote(schema){}] + def unquote(function_name)() do + # No args: list all resources based on the schema unquote(schema) |> unquote(repo).all() end + + def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do + # One arg: list all resources based on the query + query + |> unquote(repo).all() + end + + def unquote(function_name)(conditions_and_opts) do + # One arg: list all resources based on the query + {opts, conditions} = Keyword.split(conditions_and_opts, @opt_keys) + + build_query(unquote(schema), conditions, opts) + |> unquote(repo).all() + |> unquote(repo).preload(opts[:preload] || []) + end + + def unquote(function_name)(query, opts) + when (is_struct(query, Ecto.Query) or is_atom(query)) and is_list(opts) do + # Two args: list all resources based on the query and opts + build_query(query, [], opts) + |> unquote(repo).all() + |> unquote(repo).preload(opts[:preload] || []) + end end unless :get in exclude do @@ -88,16 +146,27 @@ defmodule Contexted.CRUD do @doc """ Retrieves a single #{resource_name} by its ID from the database. Returns nil if the #{resource_name} is not found. + If a list of preloads is provided, it will be used to preload the #{resource_name}. + Preloads can be an atom or a list of atoms. + ## Examples iex> get_#{resource_name}(id) %#{Macro.camelize(resource_name)}{} or nil + + iex> get_#{resource_name}(id, preload: [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or nil """ - @spec unquote(function_name)(integer() | String.t()) :: %unquote(schema){} | nil - def unquote(function_name)(id) do + @spec unquote(function_name)(integer() | String.t(), keyword()) :: + %unquote(schema){} | nil + def unquote(function_name)(id, opts \\ []) when is_list(opts) do unquote(schema) |> unquote(repo).get(id) + |> case do + nil -> nil + record -> unquote(repo).preload(record, opts[:preload] || []) + end end function_name = String.to_atom("get_#{resource_name}!") @@ -105,16 +174,165 @@ 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, preload: [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError """ - @spec unquote(function_name)(integer() | String.t()) :: %unquote(schema){} - def unquote(function_name)(id) do + @spec unquote(function_name)(integer() | String.t(), keyword()) :: %unquote(schema){} + def unquote(function_name)(id, preloads \\ []) + when is_list(preloads) or is_atom(preloads) do unquote(schema) |> unquote(repo).get!(id) + |> unquote(repo).preload(preloads) + end + end + + unless :get_by in exclude do + function_name = String.to_atom("get_#{resource_name}_by") + + defp maybe_preload(nil, _preload), do: nil + defp maybe_preload(record, nil), do: record + defp maybe_preload(record, preload), do: unquote(repo).preload(record, preload) + + @doc """ + Retrieves a single #{resource_name} by either an Ecto.Query or a map/keyword list of conditions from the database. Returns nil if the #{resource_name} is not found. + + ## Arguments + + The function accepts several argument patterns: + + - Single argument: + - `Ecto.Query` or schema module: Uses this as the base query + - Keyword list or map: Used as conditions and options (e.g. preload) + - Two arguments: + - Query + options: Uses query and applies options like preloads + - Conditions + options: Applies conditions and options separately + + ## Options + + - `:preload` - Preloads associations. Can be an atom or list of atoms. + + ## Examples + + # Exact match condition list + iex> get_#{resource_name}_by(status: "active") + %#{Macro.camelize(resource_name)}{} or nil + + # Exact match condition list + preload + iex> get_#{resource_name}_by(status: "active", preload: :associated) + %#{Macro.camelize(resource_name)}{associated: ...} or nil + + # Exact match condition list with a nested condition in a join table + preload + iex> get_#{resource_name}_by(associated: [id: 1], preload: :associated) + %#{Macro.camelize(resource_name)}{associated: ...} or nil + + # Query + iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or nil + + # Query + preload + iex> get_#{resource_name}_by(from r in #{schema}, where: r.status == "active", preload: :associated) + %#{Macro.camelize(resource_name)}{associated: ...} or nil + + # Query + preload + iex> get_#{resource_name}_by(#{schema} |> where(...), preload: [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or nil + """ + + @spec unquote(function_name)(Ecto.Queryable.t(), keyword()) :: %unquote(schema){} + def unquote(function_name)(query, opts) + when is_struct(query, Ecto.Query) or is_atom(query) do + # Two args: get resource based on the query, with preloads + query + |> unquote(repo).one() + |> maybe_preload(opts[:preload]) + end + + @spec unquote(function_name)(Ecto.Queryable.t()) :: %unquote(schema){} + def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do + # One arg: get resource based on the query + query + |> unquote(repo).one() + end + + @spec unquote(function_name)(map() | keyword()) :: %unquote(schema){} + def unquote(function_name)(conditions_and_opts) + when is_list(conditions_and_opts) or is_map(conditions_and_opts) do + # One arg: get resource with conditions and preloads + {opts, conditions} = + if is_list(conditions_and_opts), + do: Keyword.split(conditions_and_opts, @opt_keys), + else: {[], conditions_and_opts} + + build_query(unquote(schema), conditions, opts) + |> unquote(repo).one() + |> maybe_preload(opts[:preload]) + end + + function_name = String.to_atom("get_#{resource_name}_by!") + + @doc """ + Similar to get_#{resource_name}_by/2 but raises Ecto.NoResultsError if no result is found. + + ## Examples + + # Exact match condition list + iex> get_#{resource_name}_by!(status: "active") + %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError + + # Exact match condition list + preload + iex> get_#{resource_name}_by!(status: "active", preload: :associated) + %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError + + # Query + iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active") + %#{Macro.camelize(resource_name)}{} or raises Ecto.NoResultsError + + # Query + preload + iex> get_#{resource_name}_by!(from r in #{schema}, where: r.status == "active", preload: :associated) + %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError + + # Query + preload + iex> get_#{resource_name}_by!(#{schema} |> where(...), preload: [:associated]) + %#{Macro.camelize(resource_name)}{associated: ...} or raises Ecto.NoResultsError + """ + + @spec unquote(function_name)(Ecto.Queryable.t(), keyword()) :: %unquote(schema){} + def unquote(function_name)(query, opts) + when is_struct(query, Ecto.Query) or is_atom(query) do + # Two args: get resource based on the query, with preloads + query + |> unquote(repo).one!() + |> maybe_preload(opts[:preload]) + end + + @spec unquote(function_name)(Ecto.Queryable.t()) :: %unquote(schema){} + def unquote(function_name)(query) when is_struct(query, Ecto.Query) or is_atom(query) do + # One arg: get resource based on the query + query + |> unquote(repo).one!() + end + + @spec unquote(function_name)(map() | keyword()) :: %unquote(schema){} + def unquote(function_name)(conditions_and_opts) + when is_list(conditions_and_opts) or is_map(conditions_and_opts) do + # One arg: get resource with conditions and preloads + {opts, conditions} = + if is_list(conditions_and_opts), + do: Keyword.split(conditions_and_opts, @opt_keys), + else: {[], conditions_and_opts} + + build_query(unquote(schema), conditions, opts) + |> unquote(repo).one!() + |> maybe_preload(opts[:preload]) end end diff --git a/lib/contexted/query_builder.ex b/lib/contexted/query_builder.ex new file mode 100644 index 0000000..380f431 --- /dev/null +++ b/lib/contexted/query_builder.ex @@ -0,0 +1,110 @@ +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, 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 + + 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, value} do + {[], nil} -> + from r in query, where: is_nil(field(r, ^field)) + + {[], _} -> + from 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 + + defp join_parent_path(parent_path) do + Enum.map_join(parent_path, "_", &"#{&1}") + |> String.to_atom() + end +end diff --git a/lib/contexted/tracer.ex b/lib/contexted/tracer.ex index 8523692..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 @@ -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() diff --git a/mix.exs b/mix.exs index 69355b0..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() @@ -26,10 +27,17 @@ 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"}, + {: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 0d065e7..edf5100 100644 --- a/mix.lock +++ b/mix.lock @@ -1,13 +1,19 @@ %{ - "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"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, - "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"}, - "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"}, + "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"}, + "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"}, + "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..9c8fe94 --- /dev/null +++ b/test/contexted/crud/get_by_test.exs @@ -0,0 +1,180 @@ +defmodule Contexted.CRUD.GetByTest do + use Contexted.DataCase + doctest Contexted.CRUD + + alias Contexted.TestApp.{Category, Item, Subcategory} + alias Contexted.TestApp.Contexts.ItemContext + + import Contexted.TestRecords + import Ecto.Query + setup [:test_records] + + describe "get_*_by/1" 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 ~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 ~s{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 ~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, + where: like(i.name, "Item 1.1.%") and i.serial_number == "1234567890" + ) + ) + end + + 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 + + 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 + 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 ~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 ~s{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 ~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, + 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 + + 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 + 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{ + 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 ~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{ + 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 + + 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/get_test.exs b/test/contexted/crud/get_test.exs new file mode 100644 index 0000000..5bbadff --- /dev/null +++ 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.{Category, Item, 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 diff --git a/test/contexted/crud/list_test.exs b/test/contexted/crud/list_test.exs new file mode 100644 index 0000000..9d6adbe --- /dev/null +++ b/test/contexted/crud/list_test.exs @@ -0,0 +1,186 @@ +defmodule Contexted.CRUD.ListTest do + use Contexted.DataCase + doctest Contexted.CRUD + + alias Contexted.TestApp.Item + alias Contexted.TestApp.Contexts.{CategoryContext, ItemContext, SubcategoryContext} + + import Contexted.TestRecords + import Ecto.Query + + setup [:test_records] + + describe "list_*/0" do + test "list_categories/0", %{categories: categories} do + assert CategoryContext.list_categories() |> MapSet.new() == categories |> MapSet.new() + end + + test "list_subcategories/0", %{subcategories: subcategories} do + assert SubcategoryContext.list_subcategories() |> MapSet.new() == + subcategories |> MapSet.new() + end + + test "list_items/0", %{items: items} do + assert ItemContext.list_items() |> MapSet.new() == items |> MapSet.new() + end + end + + describe "list_*/1" 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() == + items + |> Enum.filter(&String.starts_with?(&1.name, "Item 1.2.")) + |> MapSet.new() + end + + 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 == + 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 ~s{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], limit: 1, offset: 1, order_by: [desc: :name])", + %{ + subcategories: [subcategory1 | _] + } do + loaded_items = + ItemContext.list_items( + subcategory_id: subcategory1.id, + preload: [subcategory: :category], + limit: 1, + offset: 1, + order_by: [desc: :name] + ) + + assert loaded_items |> length == 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( + 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, 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( + 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 = + 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 = + 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 + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..00915fa --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,17 @@ +defmodule Contexted.DataCase do + @moduledoc false + use ExUnit.CaseTemplate + + alias Contexted.TestApp.Repo + alias Ecto.Adapters.SQL.Sandbox + + 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..3b5bce1 --- /dev/null +++ b/test/support/test_app/contexts/category_context.ex @@ -0,0 +1,7 @@ +defmodule Contexted.TestApp.Contexts.CategoryContext do + @moduledoc false + 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..147f2ea --- /dev/null +++ b/test/support/test_app/contexts/item_context.ex @@ -0,0 +1,7 @@ +defmodule Contexted.TestApp.Contexts.ItemContext do + @moduledoc false + 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..8f93021 --- /dev/null +++ b/test/support/test_app/contexts/subcategory_context.ex @@ -0,0 +1,7 @@ +defmodule Contexted.TestApp.Contexts.SubcategoryContext do + @moduledoc false + 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..790f128 --- /dev/null +++ b/test/support/test_app/schemas/category.ex @@ -0,0 +1,18 @@ +defmodule Contexted.TestApp.Category do + @moduledoc false + 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..13e2f3d --- /dev/null +++ b/test/support/test_app/schemas/item.ex @@ -0,0 +1,21 @@ +defmodule Contexted.TestApp.Item do + @moduledoc false + 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..6555c67 --- /dev/null +++ b/test/support/test_app/schemas/subcategory.ex @@ -0,0 +1,20 @@ +defmodule Contexted.TestApp.Subcategory do + @moduledoc false + 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..83759ee --- /dev/null +++ b/test/support/test_records.ex @@ -0,0 +1,73 @@ +defmodule Contexted.TestRecords do + @moduledoc false + alias Contexted.TestApp.Category + alias Contexted.TestApp.Item + alias Contexted.TestApp.Repo + alias Contexted.TestApp.Subcategory + + 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)