ContextKit.CRUD.Scoped (ContextKit v0.5.0)

View Source

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:

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

# 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.

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.

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:

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:

scope: [
  module: MyApp.Accounts.Scope,
  access_path: [:user, :id],
  schema_key: :user_id
]

Subscription Example

# 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

# 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:

{:created, %Comment{}}
{:updated, %Comment{}}
{:deleted, %Comment{}}