README.md

# Cinema

Cinema is a simple Elixir framework for managing incremental materialized views entirely in Elixir/Ecto.

## Installation

Cinema can be installed by adding `cinema` to your list of dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:cinema, "~> 0.1.0"}
  ]
end
```

Cinema has an optional dependency on [Oban Pro](https://getoban.pro) as an alternate runtime for materializing projection graphs. Oban Pro support is automatically enabled if `Hex` detects the `oban` repo in your global setup.

Please see the [Oban Pro](https://getoban.pro) documentation for more information on how to install and configure Oban Pro.

## Usage

Cinema introduces two basic concepts:

- **Projections**: A projection is a behaviour that allows you to declaratively define instructions in Elixir for how to derive rows to write into your materialized views. They statically define all `c:inputs/0` (other projections, if needed), `c:output/1` (usually an `Ecto.Query` or stream passed into subsequent projections), and a `c:derivation/2` callback which is run in tandem with all inputs to produce output.
- **Lenses**: A lens is a struct which contains filters and other options which can be used to modify the "scope" of what a projection is required to derive. In simple use cases, you can think of lenses as automatically applying filters such as: `where: x.org_id == ^org_id` to the outputs of all input projections automatically.

When you want to actually incrementally rematerialize a view, you create a `Cinema.Lens.t()` (or a simple keyword list for simple filters), and pass that into the `Cinema.project/3` function like so:

```elixir
iex> Cinema.project(MyApp.Projections.AccountsReceivable, [org_id: 123, date: ~D[2022-01-01]])
[
  %MyApp.Projections.AccountsReceivable{
    org_id: 123,
    date: ~D[2022-01-01],
    ...
  },
  ...
]
```

Projections generally define their own `Ecto.Schema` internally and can also be queried directly -- note that this will not rematerialize any dependencies or rows in the table you're querying:

```elixir
iex> MyApp.Repo.all(MyApp.Projections.AccountsReceivable)
[
  %MyApp.Projections.AccountsReceivable{
    org_id: 123,
    date: ~D[2022-01-01],
    ...
  },
  ...
]
```

Projections can include other projections as inputs, and Cinema will automatically rematerialize those projections as needed. For example, if `AccountsReceivable` depends on `Invoices`, Cinema will automatically rematerialize `Invoices` before rematerializing `AccountsReceivable`.

Projection graphs usually begin with "virtual" projections that have no inputs or `derivation/2` callback, instead only outputting either an `Ecto.Query` or stream which is passed directly through any `Cinema.Lens.t()` and into the next projection in the graph.

Cinema does this by building a DAG of all projections and their dependencies. Cinema will likewise try to run any projections in parallel where possible. A minimal example of a projection looks like the following:

```elixir
defmodule MyApp.Projections.Accounts do
  use Cinema.Projection, virtual?: true

  @impl Cinema.Projection
  def inputs, do: []

  @impl Cinema.Projection
  def output, do: from(a in "accounts", select: a.id)
end

defmodule MyApp.Projections.AccountsReceivable do
  use Cinema.Projection,
    conflict_target: [:account_id],
    required_fields: [:account_id],
    on_conflict: :replace_all,
    read_repo: MyApp.Repo.Replica,
    write_repo: MyApp.Repo,
    timeout: :timer.minutes(5),

  alias Cinema.Projection
  alias MyApp.Projections.Accounts

  @primary_key false
  schema "accounts_receivable" do
    field(account_id:, :id)
    field(total, :integer)

    timestamps()
  end

  @impl Cinema.Projection
  def inputs, do: [Accounts]

  @impl Cinema.Projection
  def output, do: from(a in "accounts_receivable", select: a)

  @impl Cinema.Projection
  def derivation({Accounts, stream}, lens) do
    Projection.dematerialize(lens)

    stream
    |> Stream.chunk_every(2000)
    |> Stream.map(&from x in MyApp.Invoice, where: x.account_id in ^&1, select: %{account_id: x.account_id, total: sum(x.total)})
    |> Stream.map(&Projection.materialize/1)
    |> Stream.run()
  end
end
```

### Configuration

Currently, Cinema lets you configure the following options:

- `:engine` - The runtime to use for executing projection graphs. Defaults to `Cinema.Engine.Task`.
- `:async` - Whether to run projections asynchronously. Defaults to `true`.

Additional configuration options can be implemented on a projection-by-projection basis, please see the docs for the `Cinema.Projection` behaviour for more information.

## License

Cinema is released under the [MIT License](LICENSE.md).