EctoContext

Scoped CRUD with permission layer via macro DSL for Ecto schemas.

Write the declaration block once — ecto_context generates the full set of data access functions at compile time, each threading a scope through for authorization. No hidden behaviour: import EctoContext, declare what you need, and the generated code is visible in the compiled module.

Part of the ExFoundry family. Pairs well with static_context for in-memory lookup data that plugs into Ecto schemas via static_belongs_to.

Why scope is mandatory

ecto_context is designed for codebases where LLMs generate and modify application code. When an LLM writes a new context or adds a query, the scope parameter is always there — it cannot be forgotten or skipped. There is no unscoped escape hatch and there never will be.

This is a deliberate design choice: in AI-assisted development, the path of least resistance must be the secure path. If a convenience function without authorization exists, an LLM will eventually use it. By making scope the only option, every generated data access function is authorized by default.

Installation

def deps do
  [{:ecto_context, "~> 0.1"}]
end

Usage

defmodule MyApp.Articles do
  import Ecto.Query
  import EctoContext

  alias MyApp.Article
  alias MyApp.Scope

  ecto_context schema: Article, scope: &__MODULE__.scope/2 do
    list()
    list_by()
    list_for()
    get!()
    get_by!()
    create()
    update()
    delete()
    change()
    count()
    paginate()
  end

  def scope(query, %Scope{admin: true}), do: query
  def scope(query, %Scope{user_id: uid}), do: where(query, user_id: ^uid)

  def permission(:create, _article, %Scope{user_id: uid}) when not is_nil(uid), do: true
  def permission(:update, article, %Scope{user_id: uid}), do: article.user_id == uid
  def permission(_, _, _), do: false
end

Every generated function receives the scope as its first argument, which is passed to your scope/2 callback to apply query-level filtering (e.g. multi-tenancy, ownership). Write operations call permission/3 on the module to authorize the action before executing.

Generated functions

Function Signature Runtime opts
listlist(scope, opts \\ []) :preload, :order_by, :limit, :select, :query
list_forlist_for(scope, assoc_atom, parent_id, opts \\ []) :preload, :order_by, :limit, :select, :query
list_bylist_by(scope, clauses, opts \\ []) :preload, :order_by, :limit, :select, :query
getget(scope, id, opts \\ []) :preload
get!get!(scope, id, opts \\ []) :preload, :query
get_byget_by(scope, clauses, opts \\ []) :preload
get_by!get_by!(scope, clauses, opts \\ []) :preload, :query
createcreate(scope, attrs, opts \\ []) :changeset (default: :changeset)
create_forcreate_for(scope, assoc_atom, parent_id, attrs, opts \\ []) :changeset (default: :changeset)
updateupdate(scope, record, attrs, opts \\ []) :changeset (default: :changeset)
deletedelete(scope, record)
changechange(scope, record, attrs \\ %{}, opts \\ []) :changeset (default: :changeset)
countcount(scope, opts \\ []) :query
paginatepaginate(scope, opts \\ []) :page, :per_page, :order_by, :preload, :query
subscribesubscribe(scope)
broadcastbroadcast(scope, message)

Only declare the functions you need — nothing else is generated.

Configuration

ecto_context auto-detects :repo, :endpoint, and :pubsub_server from your application config. Override any of these at the declaration level:

ecto_context schema: Article,
             scope: &__MODULE__.scope/2,
             repo: MyApp.Repo,
             pubsub_server: MyApp.PubSub do
  list()
  subscribe()
  broadcast()
end

Library-wide defaults can be set in config:

config :ecto_context, :defaults,
  repo: MyApp.Repo,
  pubsub_server: MyApp.PubSub

How it works

Each function declaration in the do block maps to an EEx template in priv/templates/ecto_context/. At compile time the macro renders the template, converts the result to AST via Code.string_to_quoted!/1, and injects it into the calling module. No runtime overhead, no hidden middleware — the generated functions are plain Elixir.

License

MIT