README.md

# 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](https://github.com/exfoundry) family.
Pairs well with [`static_context`](https://github.com/exfoundry/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

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

## Usage

```elixir
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                                      |
|--------------|-----------------------------------------------------------------|---------------------------------------------------|
| `list`       | `list(scope, opts \\ [])`                                      | :preload, :order_by, :limit, :select, :query      |
| `list_for`   | `list_for(scope, assoc_atom, parent_id, opts \\ [])`           | :preload, :order_by, :limit, :select, :query      |
| `list_by`    | `list_by(scope, clauses, opts \\ [])`                          | :preload, :order_by, :limit, :select, :query      |
| `get`        | `get(scope, id, opts \\ [])`                                   | :preload                                          |
| `get!`       | `get!(scope, id, opts \\ [])`                                  | :preload, :query                                  |
| `get_by`     | `get_by(scope, clauses, opts \\ [])`                           | :preload                                          |
| `get_by!`    | `get_by!(scope, clauses, opts \\ [])`                          | :preload, :query                                  |
| `create`     | `create(scope, attrs, opts \\ [])`                             | :changeset (default: `:changeset`)                |
| `create_for` | `create_for(scope, assoc_atom, parent_id, attrs, opts \\ [])` | :changeset (default: `:changeset`)                |
| `update`     | `update(scope, record, attrs, opts \\ [])`                     | :changeset (default: `:changeset`)                |
| `delete`     | `delete(scope, record)`                                        | —                                                 |
| `change`     | `change(scope, record, attrs \\ %{}, opts \\ [])`             | :changeset (default: `:changeset`)                |
| `count`      | `count(scope, opts \\ [])`                                     | :query                                            |
| `paginate`   | `paginate(scope, opts \\ [])`                                  | :page, :per_page, :order_by, :preload, :query     |
| `subscribe`  | `subscribe(scope)`                                             | —                                                 |
| `broadcast`  | `broadcast(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:

```elixir
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:

```elixir
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