defmodule ContextKit.CRUD.Scoped do @moduledoc """ The `ContextKit.CRUD.Scoped` module provides a convenient way to generate standard CRUD (Create, Read, Update, Delete) operations for your Ecto schemas. It reduces boilerplate code by automatically generating commonly used database interaction functions. Additionally, it supports scopes from Phoenix 1.8. ## Setup Add the following to your context module: ```elixir defmodule MyApp.Blog do use ContextKit.CRUD.Scoped, repo: MyApp.Repo, schema: MyApp.Blog.Comment, queries: MyApp.Blog.CommentQueries, pubsub: MyApp.PubSub, # For realtime notifications via PubSub scope: Application.compile_env(:my_app, :scopes)[:user], # To gain support for Phoenix 1.8 scopes. except: [:delete], # Optional: exclude specific operations plural_resource_name: "comments" # Optional: customize plural name end ``` ## Required Options * `:repo` - The Ecto repository module to use for database operations * `:schema` - The Ecto schema module that defines your resource * `:queries` - Module containing query-building functions for advanced filtering * `:pubsub` - The Phoenix.PubSub module to use for real-time features (required for subscription features) * `:scope` - Configuration for scoping resources to specific contexts (e.g., user) ## Optional Options * `:except` - List of operation types to exclude (`:new`, `:list`, `:get`, `:one`, `:delete`, `:create`, `:update`, `:change`, `:subscribe`, `:broadcast`) * `:plural_resource_name` - Custom plural name for list functions (defaults to singular + "s") ## Generated Functions For a schema named `Comment`, the following functions are generated: ### Query Operations * `query_comments/0` - Returns a base query for all comments * `query_comments/1` - Returns a filtered query based on options (without executing) * `query_comments/2` - Returns a scoped and filtered query if `:scope` is configured ### New Operations * `new_comment/0` - Returns a new comment * `new_comment/1` - Returns a new comment with params * `new_comment/2` - Returns a new comment with params and opts ### List Operations * `list_comments/0` - Returns all comments * `list_comments/1` - Returns filtered comments based on options * `list_comments/2` - Returns scoped and filtered comments if `:scope` is configured ### Get Operations * `get_comment/1` - Fetches a single comment by ID * `get_comment/2` - Fetches a comment by ID with additional filters * `get_comment/3` - Fetches a scoped comment by ID with additional filters if `:scope` is configured * `get_comment!/1` - Like `get_comment/1` but raises if not found * `get_comment!/2` - Like `get_comment/2` but raises if not found * `get_comment!/3` - Like `get_comment/3` but raises if not found (with scope) ### Single Record Operations * `one_comment/1` - Fetches a single comment matching the criteria * `one_comment/2` - Fetches a scoped single comment if `:scope` is configured * `one_comment!/1` - Like `one_comment/1` but raises if not found * `one_comment!/2` - Like `one_comment/2` but raises if not found (with scope) ### Save Operations * `save_comment/1` - Saves (inserts or updates) a comment * `save_comment/2` - Saves a comment with the provided attributes * `save_comment/3` - Saves a scoped comment with attributes if `:scope` is configured * `save_comment!/1` - Like `save_comment/1` but raises on invalid attributes * `save_comment!/2` - Like `save_comment/2` but raises on invalid attributes * `save_comment!/3` - Like `save_comment/3` but raises on invalid attributes (with scope) ### Create Operations * `create_comment/1` - Creates a new comment with provided attributes * `create_comment/2` - Creates a scoped comment if `:scope` is configured * `create_comment!/1` - Like `create_comment/1` but raises on invalid attributes * `create_comment!/2` - Like `create_comment/2` but raises on invalid attributes (with scope) ### Update Operations * `update_comment/2` - Updates comment with provided attributes * `update_comment/3` - Updates scoped comment if `:scope` is configured * `update_comment!/2` - Like `update_comment/2` but raises on invalid attributes * `update_comment!/3` - Like `update_comment/3` but raises on invalid attributes (with scope) ### Change Operations * `change_comment/1` - Returns a changeset for the comment * `change_comment/2` - Returns a changeset for the comment with changes * `change_comment/3` - Returns a changeset for the scoped comment with changes if `:scope` is configured ### Delete Operations * `delete_comment/1` - Deletes a comment struct or by query criteria * `delete_comment/2` - Deletes a scoped comment if `:scope` is configured ### PubSub Operations (if `:pubsub` and `:scope` are configured) * `subscribe_comments/1` - Subscribes to the scoped comments topic * `broadcast_comment/2` - Broadcasts a message to the scoped comments topic ## Query Options All functions that accept options support: * Basic filtering with field-value pairs * Complex queries via `Ecto.Query` * Pagination via `paginate: true` or `paginate: [page: 1, per_page: 20]` * Custom query options defined in your queries module * Scoping via `scope` when using scoped functions ## Examples ```elixir # Get a query for comments (for use with Repo.aggregate, etc.) query = MyApp.Blog.query_comments(status: :published) MyApp.Repo.aggregate(query, :count) # Get a query for comments scoped to current user query = MyApp.Blog.query_comments(socket.assigns.current_scope) MyApp.Repo.aggregate(query, :count) # Get a query for comments scoped to current user with additional filters query = MyApp.Blog.query_comments(socket.assigns.current_scope, status: :published) MyApp.Repo.aggregate(query, :count) # New comment MyApp.Blog.new_comment() # List all comments MyApp.Blog.list_comments() # List all comments belonging to current user MyApp.Blog.list_comments(socket.assigns.current_scope) # List published comments with pagination MyApp.Blog.list_comments(status: :published, paginate: [page: 1]) # Get comment by ID with preloads MyApp.Blog.get_comment(123, preload: [:user]) # Get comment by ID that belongs to current user MyApp.Blog.get_comment(socket.assigns.current_scope, 123) # Save a new comment (will be inserted) MyApp.Blog.save_comment(%Comment{}, %{body: "Great post!", user_id: 42}) # Save an existing comment (will be updated) MyApp.Blog.save_comment(existing_comment, %{body: "Updated content"}) # Save a comment with scope (will insert or update depending on the record's state) MyApp.Blog.save_comment(socket.assigns.current_scope, comment, %{body: "Content"}) # Save a comment or raise on errors MyApp.Blog.save_comment!(comment, %{body: "Content that must be saved"}) # Create a new comment (manually specifying user_id) MyApp.Blog.create_comment(%{body: "Great post!", user_id: 42}) # Create a new comment that automatically belongs to current user MyApp.Blog.create_comment(socket.assigns.current_scope, %{body: "Great post!"}) # Update a comment MyApp.Blog.update_comment(comment, %{body: "Updated content"}) # Update a comment that belongs to current user MyApp.Blog.update_comment(socket.assigns.current_scope, comment, %{body: "Updated content"}) # Get a changeset for updates MyApp.Blog.change_comment(comment, %{body: "Changed content"}) # Get a changeset for updates with scope MyApp.Blog.change_comment(socket.assigns.current_scope, comment, %{body: "Changed content"}) # Delete comment MyApp.Blog.delete_comment(comment) # Delete comment that belongs to current user MyApp.Blog.delete_comment(socket.assigns.current_scope, comment) # Delete comment matching criteria MyApp.Blog.delete_comment(body: "Specific content to delete") ``` Each generated function can be overridden in your context module if you need custom behavior. ## Queries Module The required `:queries` module should implement `apply_query_option/2`, which receives a query option and the current query and returns a modified query. This allows for custom filtering, sorting, and other query modifications. ```elixir defmodule MyApp.Blog.CommentQueries do import Ecto.Query def apply_query_option({:with_user_name, name}, query) do query |> join(:inner, [c], u in assoc(c, :user)) |> where([_, u], ilike(u.name, ^"%\#{name}%")) end def apply_query_option({:recent_first, true}, query) do order_by(query, [c], desc: c.inserted_at) end def apply_query_option(_, query), do: query end ``` ## Scope [Read more about scopes](https://hexdocs.pm/phoenix/1.8.0-rc.0/scopes.html). A scope is a data structure used to keep information about the current request or session, such as the current user logged in, permissions, and so on. By using scopes, you have a single data structure that contains all relevant information, which is then passed around so all of your operations are properly scoped. Usually you configure it with the same scope that you use in your Phoenix application: ```elixir scope: Application.compile_env(:my_app, :scopes)[:user], pubsub: MyApp.PubSub # pubsub config is required for scopes ``` When a scope is configured, all relevant CRUD functions take an additional scope parameter as their first argument, ensuring that operations only affect records that belong to that scope. For our comments example, the scope makes sure that comments are only listed, edited, or deleted by the user who created them. ### Scope Configuration If you pass in the scope with `Application.compile_env(:my_app, :scopes)[:user]` - it will work automatically. You can also configure the scope manually. The scope configuration should be a keyword list with the following keys: * `:module` - The module that defines the scope struct * `:access_path` - Path to access the scoping value (e.g., `[:user, :id]` for user ID) * `:schema_key` - The field in the schema that corresponds to the scope (e.g., `:user_id`) For our comments example, this would typically be: ```elixir scope: [ module: MyApp.Accounts.Scope, access_path: [:user, :id], schema_key: :user_id ] ``` ### Subscription Example ```elixir # Subscribe to comments created, updated, or deleted by the current user MyApp.Blog.subscribe_comments(socket.assigns.current_scope) # Now the current process will receive messages like: # {:created, %Comment{}} # {:updated, %Comment{}} # {:deleted, %Comment{}} ``` ### Broadcasting Example ```elixir # Broadcast a custom message to all subscribers for current user's comments MyApp.Blog.broadcast_comment(socket.assigns.current_scope, {:custom_event, comment}) ``` Following messages are also broadcasted automatically for all create, update, delete operations: ```elixir {:created, %Comment{}} {:updated, %Comment{}} {:deleted, %Comment{}} ``` """ alias ContextKit.Paginator alias ContextKit.Query defmacro __using__(opts) do repo = Keyword.fetch!(opts, :repo) schema = Keyword.fetch!(opts, :schema) queries = Keyword.fetch!(opts, :queries) scope = Keyword.fetch!(opts, :scope) {scope_evaled, _} = Code.eval_quoted(scope) scope_module = Keyword.get(scope_evaled, :module) scope_access_path = Keyword.get(scope_evaled, :access_path) scope_schema_key = Keyword.get(scope_evaled, :schema_key) pubsub = Keyword.fetch!(opts, :pubsub) except = Keyword.get(opts, :except, []) plural_resource_name = Keyword.get(opts, :plural_resource_name, nil) schema_name = schema |> Macro.expand(__CALLER__) |> Module.split() |> List.last() resource_name = Macro.underscore(schema_name) plural_resource_name = plural_resource_name || "#{resource_name}s" quote do import Ecto.Changeset import Ecto.Query alias unquote(schema) alias unquote(scope_module) if :subscribe not in unquote(except) do @doc """ Subscribes to the scoped #{unquote(schema_name)} topic via PubSub. ## Examples iex> subscribe_#{unquote(plural_resource_name)}(socket.assigns.current_scope) :ok # This subscribes to something like `user:123:#{unquote(plural_resource_name)}`, assuming # that `scope` is based on the `:user`. """ @spec unquote(:"subscribe_#{plural_resource_name}")(scope :: unquote(scope_module).t()) :: :ok | {:error, {:already_registered, pid()}} def unquote(:"subscribe_#{plural_resource_name}")(%unquote(scope_module){} = scope) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) key = get_in(scope, access) top_level_key = List.first(unquote(scope_access_path)) pubsub_key = "#{top_level_key}:#{key}:#{unquote(plural_resource_name)}" Phoenix.PubSub.subscribe( unquote(pubsub), pubsub_key ) end end if :broadcast not in unquote(except) do @doc """ Broadcasts a message to the scoped #{unquote(schema_name)} topic via PubSub. ## Examples iex> boradcast_#{unquote(resource_name)}(socket.assigns.current_scope, {:created, #{unquote(resource_name)}}) :ok # This broadcasts the message `{created, #{unquote(resource_name)}}` to the topic like `user:123:#{unquote(plural_resource_name)}`, assuming # that `scope` is based on the `:user`. """ @spec unquote(:"broadcast_#{resource_name}")( scope :: unquote(scope_module).t(), message :: term() ) :: :ok | {:error, term()} def unquote(:"broadcast_#{resource_name}")(%unquote(scope_module){} = scope, message) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) key = get_in(scope, access) top_level_key = List.first(unquote(scope_access_path)) pubsub_key = "#{top_level_key}:#{key}:#{unquote(plural_resource_name)}" Phoenix.PubSub.broadcast( unquote(pubsub), pubsub_key, message ) end end if :query not in unquote(except) do @doc """ Returns the query of `%#{unquote(schema_name)}{}`. Useful for passing the query into `Repo.aggregate/2`. ## Examples iex> query_#{unquote(plural_resource_name)}() %Ecto.Query{} iex> query_#{unquote(plural_resource_name)}() |> Repo.aggregate(:count) 123 """ @spec unquote(:"query_#{plural_resource_name}")() :: Ecto.Query.t() def unquote(:"query_#{plural_resource_name}")() do unquote(:"query_#{plural_resource_name}")([]) end @doc """ Returns the query of `%#{unquote(schema_name)}{}`. Options can be passed as a keyword list or map. Useful for passing the query into `Repo.aggregate/2`. ## Examples iex> query_#{unquote(plural_resource_name)}(field: 123) %Ecto.Query{} iex> query_#{unquote(plural_resource_name)}(field: 123) |> Repo.aggregate(:count) 123 """ @spec unquote(:"query_#{plural_resource_name}")(opts :: keyword()) :: Ecto.Query.t() def unquote(:"query_#{plural_resource_name}")(opts) when is_list(opts) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) query end @doc """ Returns the scoped query of `%#{unquote(schema_name)}{}`. Options can be passed as a keyword list or map. Useful for passing the query into `Repo.aggregate/2`. ## Examples iex> query_#{unquote(plural_resource_name)}(socket.assigns.current_scope, field: 123) %Ecto.Query{} iex> query_#{unquote(plural_resource_name)}(socket.assigns.current_scope, field: 123) |> Repo.aggregate(:count) 123 """ @spec unquote(:"query_#{plural_resource_name}")(unquote(scope_module).t(), opts :: keyword()) :: Ecto.Query.t() def unquote(:"query_#{plural_resource_name}")(%unquote(scope_module){} = scope, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"query_#{plural_resource_name}")(opts) end defoverridable [ {unquote(:"query_#{plural_resource_name}"), 0}, {unquote(:"query_#{plural_resource_name}"), 1}, {unquote(:"query_#{plural_resource_name}"), 2} ] end if :new not in unquote(except) do @doc """ Returns a `%#{unquote(schema_name)}{}`. Fields can be passed as a map. Optionally, you can pass preloads via the opts keyword list to preload associations on the returned struct. ## Examples iex> new_#{unquote(resource_name)}() %#{unquote(schema_name)}{} iex> new_#{unquote(resource_name)}(%{foo: "bar"}) %#{unquote(schema_name)}{foo: "bar"} iex> new_#{unquote(resource_name)}(%{assoc_id: 123}, preload: [:assoc]) %#{unquote(schema_name)}{assoc_id: 123, assoc: %Assoc{}} """ @spec unquote(:"new_#{resource_name}")() :: unquote(schema).t() def unquote(:"new_#{resource_name}")() do unquote(:"new_#{resource_name}")(%{}, []) end @spec unquote(:"new_#{resource_name}")(params :: map()) :: unquote(schema).t() def unquote(:"new_#{resource_name}")(params) when is_map(params) do unquote(:"new_#{resource_name}")(params, []) end @spec unquote(:"new_#{resource_name}")(params :: map(), opts :: keyword()) :: unquote(schema).t() def unquote(:"new_#{resource_name}")(params, opts) when is_map(params) and is_list(opts) do record = unquote(schema).__struct__() |> Ecto.Changeset.change() |> Ecto.Changeset.cast(params, unquote(schema).__schema__(:fields)) |> Ecto.Changeset.apply_changes() if opts[:preload], do: unquote(repo).preload(record, opts[:preload]), else: record end defoverridable [ {unquote(:"new_#{resource_name}"), 0}, {unquote(:"new_#{resource_name}"), 1}, {unquote(:"new_#{resource_name}"), 2} ] end if :list not in unquote(except) do @doc """ Returns the list of `%#{unquote(schema_name)}{}`. ## Examples iex> list_#{unquote(plural_resource_name)}() [%#{unquote(schema_name)}{}, ...] iex> list_#{unquote(plural_resource_name)}(field: "value") [%#{unquote(schema_name)}{}, ...] """ @spec unquote(:"list_#{plural_resource_name}")() :: [unquote(schema).t()] | {[unquote(schema).t()], ContextKit.Paginator.t()} def unquote(:"list_#{plural_resource_name}")() do unquote(:"list_#{plural_resource_name}")([]) end @doc """ Returns the list of `%#{unquote(schema_name)}{}`. Options can be passed as a keyword list. ## Examples iex> list_#{unquote(plural_resource_name)}() [%#{unquote(schema_name)}{}, ...] iex> list_#{unquote(plural_resource_name)}(field: "value") [%#{unquote(schema_name)}{}, ...] """ @spec unquote(:"list_#{plural_resource_name}")(opts :: keyword() | map()) :: [unquote(schema).t()] | {[unquote(schema).t()], ContextKit.Paginator.t()} def unquote(:"list_#{plural_resource_name}")(opts) when is_list(opts) or is_non_struct_map(opts) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) if paginate = get_in(opts, [:paginate]) do paginate = if Keyword.keyword?(paginate) or is_map(paginate), do: paginate, else: [] {unquote(repo).all(query), Paginator.new(query, paginate, repo: unquote(repo))} else unquote(repo).all(query) end end @doc """ Returns the list of `%#{unquote(schema_name)}{}`. Options can be passed as `%Ecto.Query{}`. ## Examples iex> list_#{unquote(plural_resource_name)}() [%#{unquote(schema_name)}{}, ...] iex> list_#{unquote(plural_resource_name)}(field: "value") [%#{unquote(schema_name)}{}, ...] """ @spec unquote(:"list_#{plural_resource_name}")(opts :: Ecto.Query.t()) :: [unquote(schema).t()] | {[unquote(schema).t()], ContextKit.Paginator.t()} def unquote(:"list_#{plural_resource_name}")(opts) when is_struct(opts, Ecto.Query) do unquote(repo).all(opts) end @doc """ Returns the scoped list of `%#{unquote(schema_name)}{}`. Options can be passed as a keyword list. ## Examples iex> list_#{unquote(plural_resource_name)}(socket.assigns.current_scope) [%#{unquote(schema_name)}{}, ...] iex> list_#{unquote(plural_resource_name)}(socket.assigns.current_scope, field: "value") [%#{unquote(schema_name)}{}, ...] """ @spec unquote(:"list_#{plural_resource_name}")(unquote(scope_module).t(), opts :: keyword()) :: [unquote(schema).t()] | {[unquote(schema).t()], ContextKit.Paginator.t()} def unquote(:"list_#{plural_resource_name}")(%unquote(scope_module){} = scope, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"list_#{plural_resource_name}")(opts) end defoverridable [ {unquote(:"list_#{plural_resource_name}"), 0}, {unquote(:"list_#{plural_resource_name}"), 1}, {unquote(:"list_#{plural_resource_name}"), 2} ] end if :get not in unquote(except) do @doc """ Returns a `%#{unquote(schema_name)}{}` by id. Returns `nil` if no result was found. ## Examples iex> get_#{unquote(resource_name)}(id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}(1, field: "test") nil """ @spec unquote(:"get_#{resource_name}")(id :: term()) :: unquote(schema).t() | nil def unquote(:"get_#{resource_name}")(id) do unquote(:"get_#{resource_name}")(id, []) end @doc """ Returns a `%#{unquote(schema_name)}{}` by id. Can be optionally filtered by `opts`. Returns `nil` if no result was found. ## Examples iex> get_#{unquote(resource_name)}(id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}(1, field: "test") nil """ @spec unquote(:"get_#{resource_name}")(id :: term(), opts :: keyword() | Ecto.Query.t()) :: unquote(schema).t() | nil def unquote(:"get_#{resource_name}")(id, opts) when is_list(opts) or is_struct(opts, Ecto.Query) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) unquote(repo).get(query, id) end @doc """ Returns a scoped `%#{unquote(schema_name)}{}` by id. Can be optionally filtered by `opts`. Returns `nil` if no result was found. ## Examples iex> get_#{unquote(resource_name)}(socket.assigns.current_scope, id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}(socket.assigns.current_scope, 1, field: "test") nil """ @spec unquote(:"get_#{resource_name}")( scope :: unquote(scope_module).t(), id :: term(), opts :: keyword() ) :: unquote(schema).t() | nil def unquote(:"get_#{resource_name}")(%unquote(scope_module){} = scope, id, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"get_#{resource_name}")(id, opts) end defoverridable [ {unquote(:"get_#{resource_name}"), 1}, {unquote(:"get_#{resource_name}"), 2}, {unquote(:"get_#{resource_name}"), 3} ] @doc """ Returns a `%#{unquote(schema_name)}{}` by id. Raises `Ecto.NoResultsError` if no result was found. ## Examples iex> get_#{unquote(resource_name)}!(id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}!(1, field: "test") Ecto.NoResultsError """ @spec unquote(:"get_#{resource_name}!")(id :: term()) :: unquote(schema).t() def unquote(:"get_#{resource_name}!")(id) do unquote(:"get_#{resource_name}!")(id, []) end @doc """ Returns a `%#{unquote(schema_name)}{}` by id. Can be optionally filtered by `opts`. Raises `Ecto.NoResultsError` if no result was found. ## Examples iex> get_#{unquote(resource_name)}!(id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}!(1, field: "test") Ecto.NoResultsError """ @spec unquote(:"get_#{resource_name}!")( id :: term(), opts :: keyword() | Ecto.Query.t() ) :: unquote(schema).t() def unquote(:"get_#{resource_name}!")(id, opts) when is_list(opts) or is_struct(opts, Ecto.Query) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) unquote(repo).get!(query, id) end @doc """ Returns a scoped `%#{unquote(schema_name)}{}` by id. Can be optionally filtered by `opts`. Raises `Ecto.NoResultsError` if no result was found. ## Examples iex> get_#{unquote(resource_name)}!(socket.assigns.current_scope, id) %#{unquote(schema_name)}{} iex> get_#{unquote(resource_name)}!(socket.assigns.current_scope, 1, field: "test") Ecto.NoResultsError """ @spec unquote(:"get_#{resource_name}!")( scope :: unquote(scope_module).t(), id :: term(), opts :: keyword() ) :: unquote(schema).t() def unquote(:"get_#{resource_name}!")(%unquote(scope_module){} = scope, id, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"get_#{resource_name}!")(id, opts) end defoverridable [ {unquote(:"get_#{resource_name}!"), 1}, {unquote(:"get_#{resource_name}!"), 2}, {unquote(:"get_#{resource_name}!"), 3} ] end if :one not in unquote(except) do @doc """ Fetches a single `%#{unquote(schema_name)}{}` from the `opts` query via `Repo.one/2`. Returns nil if no result was found. Raises if more than one entry. ## Examples iex> one_#{unquote(resource_name)}(opts) %#{unquote(schema_name)}{} iex> one_#{unquote(resource_name)}(opts) nil """ @spec unquote(:"one_#{resource_name}")(opts :: keyword() | Ecto.Query.t()) :: unquote(schema).t() | nil def unquote(:"one_#{resource_name}")(opts) when is_list(opts) or is_struct(opts, Ecto.Query) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) unquote(repo).one(query) end @doc """ Fetches a single scoped `%#{unquote(schema_name)}{}` from the `opts` query via `Repo.one/2`. Returns nil if no result was found. Raises if more than one entry. ## Examples iex> one_#{unquote(resource_name)}(socket.assigns.current_scope, opts) %#{unquote(schema_name)}{} iex> one_#{unquote(resource_name)}(socket.assigns.current_scope, opts) nil """ @spec unquote(:"one_#{resource_name}")( scope :: unquote(scope_module).t(), opts :: keyword() ) :: unquote(schema).t() | nil def unquote(:"one_#{resource_name}")(%unquote(scope_module){} = scope, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"one_#{resource_name}")(opts) end defoverridable [ {unquote(:"one_#{resource_name}"), 1}, {unquote(:"one_#{resource_name}"), 2} ] @doc """ Fetches a single `%#{unquote(schema_name)}{}` from the `opts` query via `Repo.one!/2`. Raises `Ecto.NoResultsError` if no record was found. Raises if more than one entry. ## Examples iex> one_#{unquote(resource_name)}!(opts) %#{unquote(schema_name)}{} iex> one_#{unquote(resource_name)}!(opts) nil """ @spec unquote(:"one_#{resource_name}!")(opts :: keyword() | Ecto.Query.t()) :: unquote(schema).t() def unquote(:"one_#{resource_name}!")(opts) when is_list(opts) or is_struct(opts, Ecto.Query) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) unquote(repo).one!(query) end @doc """ Fetches a single scoped `%#{unquote(schema_name)}{}` from the `opts` query via `Repo.one!/2`. Raises `Ecto.NoResultsError` if no record was found. Raises if more than one entry. ## Examples iex> one_#{unquote(resource_name)}!(socket.assigns.current_scope, opts) %#{unquote(schema_name)}{} iex> one_#{unquote(resource_name)}!(socket.assigns.current_scope, opts) nil """ @spec unquote(:"one_#{resource_name}!")( scope :: unquote(scope_module).t(), opts :: keyword() ) :: unquote(schema).t() def unquote(:"one_#{resource_name}!")(%unquote(scope_module){} = scope, opts \\ []) do opts = Keyword.put(opts, :scope, scope) unquote(:"one_#{resource_name}!")(opts) end defoverridable [ {unquote(:"one_#{resource_name}!"), 1}, {unquote(:"one_#{resource_name}!"), 2} ] end if :delete not in unquote(except) do @doc """ Deletes a single `%#{unquote(schema_name)}{}` by query via `opts`. Returns `{:ok, %#{unquote(schema_name)}{}}` if successful or `{:error, changeset}` if the resource could not be deleted. ## Examples iex> delete_#{unquote(resource_name)}(id: 1) {:ok, %#{unquote(schema_name)}{}} """ @spec unquote(:"delete_#{resource_name}")(opts :: keyword() | map() | Ecto.Query.t()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"delete_#{resource_name}")(opts) when is_list(opts) do {query, custom_query_options} = Query.build(Query.new(unquote(schema)), unquote(schema), opts) query = Enum.reduce(custom_query_options, query, fn query_option, query_acc -> apply(unquote(queries), :apply_query_option, [query_option, query_acc]) end) result = unquote(repo).one(query) case result do nil -> {:error, :not_found} entity -> unquote(repo).delete(entity) end rescue Ecto.MultipleResultsError -> {:error, :multiple_entries_found} end @doc """ Deletes a single `%#{unquote(schema_name)}{}`. Returns `{:ok, %#{unquote(schema_name)}{}}` if successful or `{:error, changeset}` if the resource could not be deleted. ## Examples iex> delete_#{unquote(resource_name)}(#{unquote(resource_name)}) {:ok, %#{unquote(schema_name)}{}} """ @spec unquote(:"delete_#{resource_name}")(resource :: unquote(schema).t()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"delete_#{resource_name}")(%unquote(schema){} = resource) do unquote(repo).delete(resource) end @doc """ Deletes a single `%#{unquote(schema_name)}{}` with a scope and broadcasts the message `{:deleted, #{unquote(resource_name)}}`. Returns `{:ok, %#{unquote(schema_name)}{}}` if successful or `{:error, changeset}` if the resource could not be deleted. ## Examples iex> delete_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}) {:ok, %#{unquote(schema_name)}{}} """ @spec unquote(:"delete_#{resource_name}")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t() ) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"delete_#{resource_name}")(%unquote(scope_module){} = scope, %unquote(schema){} = resource) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = Access.key!(unquote(scope_schema_key)) if get_in(resource, [schema_access_key]) != scope_value do raise "Record not in scope" end with {:ok, %unquote(schema){} = resource} <- unquote(repo).delete(resource) do unquote(:"broadcast_#{resource_name}")(scope, {:deleted, resource}) {:ok, resource} end end defoverridable [ {unquote(:"delete_#{resource_name}"), 1}, {unquote(:"delete_#{resource_name}"), 2} ] end if :change not in unquote(except) do @doc """ Returns a changeset for the specified resource with the given parameters. ## Examples iex> change_#{unquote(resource_name)}(#{unquote(resource_name)}) %Ecto.Changeset{} """ @spec unquote(:"change_#{resource_name}")(resource :: unquote(schema).t()) :: Ecto.Changeset.t() def unquote(:"change_#{resource_name}")(%unquote(schema){} = resource) do unquote(schema).changeset(resource, %{}) end @doc """ Returns a changeset for the specified resource with the given parameters. ## Examples iex> change_#{unquote(resource_name)}(#{unquote(resource_name)}, params) %Ecto.Changeset{} """ @spec unquote(:"change_#{resource_name}")( resource :: unquote(schema).t(), params :: map() ) :: Ecto.Changeset.t() def unquote(:"change_#{resource_name}")(%unquote(schema){} = resource, params) when is_map(params) do unquote(schema).changeset(resource, params) end @doc """ Returns a scoped changeset for the specified resource with the given parameters. ## Examples iex> change_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}, params) %Ecto.Changeset{} """ @spec unquote(:"change_#{resource_name}")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t(), params :: map() ) :: Ecto.Changeset.t() def unquote(:"change_#{resource_name}")( %unquote(scope_module){} = scope, %unquote(schema){} = resource, params \\ %{} ) when is_map(params) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = Access.key!(unquote(scope_schema_key)) if get_in(resource, [schema_access_key]) != scope_value do raise "Record not in scope" end unquote(schema).changeset(resource, params, scope) end defoverridable [ {unquote(:"change_#{resource_name}"), 2}, {unquote(:"change_#{resource_name}"), 3} ] end if :save not in unquote(except) do @doc """ Saves a `%#{unquote(schema_name)}{}`. Resource can be either new or persisted. ## Examples iex> save_#{unquote(resource_name)}(#{unquote(resource_name)}) {:ok, %#{unquote(schema_name)}{}} iex> save_#{unquote(resource_name)}(#{unquote(resource_name)}) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"save_#{resource_name}")(resource :: unquote(schema).t()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"save_#{resource_name}")(%unquote(schema){} = resource) do unquote(:"save_#{resource_name}")(resource, %{}) end @doc """ Saves a `%#{unquote(schema_name)}{}` with provided attributes. Resource can be either new or persisted. ## Examples iex> save_#{unquote(resource_name)}(#{unquote(resource_name)}, params) {:ok, %#{unquote(schema_name)}{}} iex> save_#{unquote(resource_name)}(invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"save_#{resource_name}")(resource :: unquote(schema).t(), params :: map()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"save_#{resource_name}")(%unquote(schema){} = resource, params) do resource |> unquote(schema).changeset(params) |> unquote(repo).insert_or_update() end @doc """ Saves a scoped `%#{unquote(schema_name)}{}`. Resource can be either new or persisted. Broadcasts the message `{:created, #{unquote(resource_name)}}` or `{:updated, #{unquote(resource_name)}}`. ## Examples iex> save_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}) {:ok, %#{unquote(schema_name)}{}} iex> save_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"save_#{resource_name}")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t() ) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"save_#{resource_name}")(%unquote(scope_module){} = scope, %unquote(schema){} = resource) do unquote(:"save_#{resource_name}")(scope, resource, %{}) end @doc """ Saves a scoped `%#{unquote(schema_name)}{}` with provided attributes. Resource can be either new or persisted. Broadcasts the message `{:created, #{unquote(resource_name)}}` or `{:updated, #{unquote(resource_name)}}`. ## Examples iex> save_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}, params) {:ok, %#{unquote(schema_name)}{}} iex> save_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}, invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"save_#{resource_name}")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t(), params :: map() ) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"save_#{resource_name}")(%unquote(scope_module){} = scope, %unquote(schema){} = resource, params) do loaded? = Ecto.get_meta(resource, :state) == :loaded if loaded?, do: unquote(:"validate_#{resource_name}_scope!")(scope, resource) changeset = unquote(schema).changeset(resource, params, scope) with {:ok, resource} <- unquote(repo).insert_or_update(changeset) do if loaded?, do: unquote(:"broadcast_#{resource_name}")(scope, {:updated, resource}), else: unquote(:"broadcast_#{resource_name}")(scope, {:created, resource}) {:ok, resource} end end defoverridable [ {unquote(:"save_#{resource_name}"), 2}, {unquote(:"save_#{resource_name}"), 3} ] @doc """ Saves a `%#{unquote(schema_name)}{}`. Resource can be either new or persisted. ## Examples iex> save_#{unquote(resource_name)}!(#{unquote(resource_name)}) %#{unquote(schema_name)}{} """ @spec unquote(:"save_#{resource_name}!")(resource :: unquote(schema).t()) :: unquote(schema).t() def unquote(:"save_#{resource_name}!")(%unquote(schema){} = resource) do unquote(:"save_#{resource_name}!")(resource, %{}) end @doc """ Saves a `%#{unquote(schema_name)}{}` with provided attributes. Resource can be either new or persisted. ## Examples iex> save_#{unquote(resource_name)}!(#{unquote(resource_name)}, params) %#{unquote(schema_name)}{} """ @spec unquote(:"save_#{resource_name}!")(resource :: unquote(schema).t(), params :: map()) :: unquote(schema).t() def unquote(:"save_#{resource_name}!")(%unquote(schema){} = resource, params) do resource |> unquote(schema).changeset(params) |> unquote(repo).insert_or_update!() end @doc """ Saves a scoped `%#{unquote(schema_name)}{}`. Resource can be either new or persisted. Broadcasts the message `{:created, #{unquote(resource_name)}}` or `{:updated, #{unquote(resource_name)}}`. ## Examples iex> save_#{unquote(resource_name)}!(socket.assigns.current_scope, #{unquote(resource_name)}) %#{unquote(schema_name)}{} """ @spec unquote(:"save_#{resource_name}!")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t() ) :: unquote(schema).t() def unquote(:"save_#{resource_name}!")(%unquote(scope_module){} = scope, %unquote(schema){} = resource) do unquote(:"save_#{resource_name}!")(scope, resource, %{}) end @doc """ Saves a scoped `%#{unquote(schema_name)}{}` with provided attributes. Resource can be either new or persisted. Broadcasts the message `{:created, #{unquote(resource_name)}}` or `{:updated, #{unquote(resource_name)}}`. ## Examples iex> save_#{unquote(resource_name)}!(socket.assigns.current_scope, #{unquote(resource_name)}, params) %#{unquote(schema_name)}{} """ @spec unquote(:"save_#{resource_name}!")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t(), params :: map() ) :: unquote(schema).t() def unquote(:"save_#{resource_name}!")(%unquote(scope_module){} = scope, %unquote(schema){} = resource, params) do loaded? = Ecto.get_meta(resource, :state) == :loaded if loaded?, do: unquote(:"validate_#{resource_name}_scope!")(scope, resource) changeset = unquote(schema).changeset(resource, params, scope) with %unquote(schema){} = resource <- unquote(repo).insert_or_update!(changeset) do if loaded?, do: unquote(:"broadcast_#{resource_name}")(scope, {:updated, resource}), else: unquote(:"broadcast_#{resource_name}")(scope, {:created, resource}) resource end end defoverridable [ {unquote(:"save_#{resource_name}!"), 2}, {unquote(:"save_#{resource_name}!"), 3} ] end if :create not in unquote(except) do @doc """ Creates a new `%#{unquote(schema_name)}{}` with provided attributes. ## Examples iex> create_#{unquote(resource_name)}(params) {:ok, %#{unquote(schema_name)}{}} iex> create_#{unquote(resource_name)}(invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"create_#{resource_name}")(params :: map()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"create_#{resource_name}")(params) when is_map(params) do %unquote(schema){} |> unquote(schema).changeset(params) |> unquote(repo).insert() end @doc """ Creates a new scoped `%#{unquote(schema_name)}{}` with provided attributes and broadcasts the message `{:created, #{unquote(resource_name)}}`. ## Examples iex> create_#{unquote(resource_name)}(socket.assigns.current_scope, params) {:ok, %#{unquote(schema_name)}{}} iex> create_#{unquote(resource_name)}(socket.assigns.current_scope, invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"create_#{resource_name}")(scope :: unquote(scope_module).t(), params :: map()) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"create_#{resource_name}")(%unquote(scope_module){} = scope, params) when is_map(params) do changeset = unquote(schema).changeset(%unquote(schema){}, params, scope) with {:ok, %unquote(schema){} = resource} <- unquote(repo).insert(changeset) do unquote(:"broadcast_#{resource_name}")(scope, {:created, resource}) {:ok, resource} end end defoverridable [ {unquote(:"create_#{resource_name}"), 1}, {unquote(:"create_#{resource_name}"), 2} ] @doc """ Creates a new `%#{unquote(schema_name)}{}` with provided attributes. Returns the `%#{unquote(schema_name)}{}` if successful, or raises an error if not. ## Examples iex> create_#{unquote(resource_name)}!(params) %#{unquote(schema_name)}{} iex> create_#{unquote(resource_name)}!(invalid_params) Ecto.StaleEntryError """ @spec unquote(:"create_#{resource_name}!")(params :: map()) :: unquote(schema).t() def unquote(:"create_#{resource_name}!")(params) do %unquote(schema){} |> unquote(schema).changeset(params) |> unquote(repo).insert!() end @doc """ Creates a new scoped `%#{unquote(schema_name)}{}` with provided attributes and broadcasts the message `{:created, #{unquote(resource_name)}}`. Returns the `%#{unquote(schema_name)}{}` if successful, or raises an error if not. ## Examples iex> create_#{unquote(resource_name)}!(socket.assigns.current_scope, params) %#{unquote(schema_name)}{} iex> create_#{unquote(resource_name)}!(socket.assigns.current_scope, invalid_params) Ecto.StaleEntryError """ @spec unquote(:"create_#{resource_name}!")(scope :: unquote(scope_module).t(), params :: map()) :: unquote(schema).t() def unquote(:"create_#{resource_name}!")(%unquote(scope_module){} = scope, params) when is_map(params) do changeset = unquote(schema).changeset(%unquote(schema){}, params, scope) with %unquote(schema){} = resource <- unquote(repo).insert!(changeset) do unquote(:"broadcast_#{resource_name}")(scope, {:created, resource}) resource end end defoverridable [ {unquote(:"create_#{resource_name}!"), 1}, {unquote(:"create_#{resource_name}!"), 2} ] end if :update not in unquote(except) do @doc """ Updates the `%#{unquote(schema_name)}{}` with provided attributes. ## Examples iex> update_#{unquote(resource_name)}(#{unquote(resource_name)}, params) {:ok, %#{unquote(schema_name)}{}} iex> update_#{unquote(resource_name)}(#{unquote(resource_name)}, invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"update_#{resource_name}")( resource :: unquote(schema).t(), params :: map() ) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"update_#{resource_name}")(%unquote(schema){} = resource, params) do resource |> unquote(schema).changeset(params) |> unquote(repo).update() end @doc """ Updates the `%#{unquote(schema_name)}{}` with provided scope and attributes and broadcasts the message `{:updated, #{unquote(resource_name)}}`.. ## Examples iex> update_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}, params) {:ok, %#{unquote(schema_name)}{}} iex> update_#{unquote(resource_name)}(socket.assigns.current_scope, #{unquote(resource_name)}, invalid_params) {:ok, %Ecto.Changeset{}} """ @spec unquote(:"update_#{resource_name}")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t(), params :: map() ) :: {:ok, unquote(schema).t()} | {:error, Ecto.Changeset.t()} def unquote(:"update_#{resource_name}")(%unquote(scope_module){} = scope, %unquote(schema){} = resource, params) when is_map(params) do changeset = unquote(schema).changeset(resource, params, scope) access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = Access.key!(unquote(scope_schema_key)) if get_in(resource, [schema_access_key]) != scope_value do raise "Record not in scope" end with {:ok, %unquote(schema){} = resource} <- unquote(repo).update(changeset) do unquote(:"broadcast_#{resource_name}")(scope, {:updated, resource}) {:ok, resource} end end defoverridable [ {unquote(:"update_#{resource_name}"), 2}, {unquote(:"update_#{resource_name}"), 3} ] @doc """ Updates the `%#{unquote(schema_name)}{}` with provided attributes. Returns the `%#{unquote(schema_name)}{}` if successful, or raises an error if not. ## Examples iex> update_#{unquote(resource_name)}!(#{unquote(resource_name)}, params) %#{unquote(schema_name)}{} iex> update_#{unquote(resource_name)}!(#{unquote(resource_name)}, invalid_params) Ecto.StaleEntryError """ @spec unquote(:"update_#{resource_name}!")( resource :: unquote(schema).t(), params :: map() ) :: unquote(schema).t() def unquote(:"update_#{resource_name}!")(%unquote(schema){} = resource, params) do resource |> unquote(schema).changeset(params) |> unquote(repo).update!() end @doc """ Updates the `%#{unquote(schema_name)}{}` with provided scope and attributes and broadcasts the message `{:updated, #{unquote(resource_name)}}`.. Returns the `%#{unquote(schema_name)}{}` if successful, or raises an error if not. ## Examples iex> update_#{unquote(resource_name)}!(socket.assigns.current_scope, #{unquote(resource_name)}, params) %#{unquote(schema_name)}{} iex> update_#{unquote(resource_name)}!(socket.assigns.current_scope, #{unquote(resource_name)}, invalid_params) Ecto.StaleEntryError """ @spec unquote(:"update_#{resource_name}!")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t(), params :: map() ) :: unquote(schema).t() def unquote(:"update_#{resource_name}!")(%unquote(scope_module){} = scope, %unquote(schema){} = resource, params) do changeset = unquote(schema).changeset(resource, params, scope) access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = Access.key!(unquote(scope_schema_key)) if get_in(resource, [schema_access_key]) != scope_value do raise "Record not in scope" end with %unquote(schema){} = resource <- unquote(repo).update!(changeset) do unquote(:"broadcast_#{resource_name}")(scope, {:updated, resource}) resource end end defoverridable [ {unquote(:"update_#{resource_name}!"), 2}, {unquote(:"update_#{resource_name}!"), 3} ] end @doc """ Validates that a resource belongs to the given scope. This function checks if the resource is within the scope specified by the scope struct. It compares the resource's scope field value (e.g., `:user_id`) with the scope's value (e.g., the current user's ID). ## Parameters * `scope` - The scope struct that contains the authorization context * `resource` - The resource struct to validate against the scope ## Examples iex> validate_#{unquote(resource_name)}_scope!(socket.assigns.current_scope, comment) :ok ## Raises * `RuntimeError` with message "Record not in scope" if the resource does not belong to the scope """ @spec unquote(:"validate_#{resource_name}_scope!")( scope :: unquote(scope_module).t(), resource :: unquote(schema).t() ) :: :ok def unquote(:"validate_#{resource_name}_scope!")(%unquote(scope_module){} = scope, %unquote(schema){} = resource) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = Access.key!(unquote(scope_schema_key)) if get_in(resource, [schema_access_key]) != scope_value do raise "Record not in scope" end :ok end @doc """ Applies the scope to the query. """ def apply_query_option({:scope, %unquote(scope_module){} = scope}, query) do access = Enum.map(unquote(scope_access_path), &Access.key!(&1)) scope_value = get_in(scope, access) schema_access_key = unquote(scope_schema_key) where(query, [record], field(record, ^schema_access_key) == ^scope_value) end end end end