Packages

Flexible ordering for Ecto schemas. Supports belongs-to, many-to-many, and global sets with fractional indexing for efficient reordering. Integrates with Phoenix LiveView and Sortable.js for drag-and-drop interfaces.

Current section

Files

Jump to
ecto_orderable guides howtos Many-To-Many Sets.md
Raw

guides/howtos/Many-To-Many Sets.md

# Many-To-Many Sets
A "many-to-many" set is useful in team environments, as it allows the same records to exist in multiple ordered sets simultaneously.
For example, `TaskUser` might be used to join between `Task` and `User` records. Placing the `:position` field on `TaskUser` will define a set of tasks each belonging to specific users. In doing so, we allow tasks to be ordered independently by each user in an organization.
## Schema
```elixir
defmodule Task do
use Ecto.Schema
schema "tasks" do
field :title, :string
many_to_many :users, User, join_through: TaskUser
end
end
defmodule TaskUser do
use Ecto.Schema
@primary_key false
schema "task_users" do
field :position, :float
belongs_to :task, Task, primary_key: true
belongs_to :user, User, primary_key: true
end
end
```
Note: Join tables often use composite primary keys (`task_id` + `user_id`) rather than a separate `id` field.
Key characteristics:
- The `position` field lives on the **join table**, not on the task itself
- The same task can have different positions for different users
- The "item" being moved is the `TaskUser` join record, not the `Task`
## Order Module
```elixir
defmodule TaskUserOrder do
use EctoOrderable,
repo: MyRepo,
schema: TaskUser,
scope: [:user_id]
end
```
The `scope: [:user_id]` means each user has their own ordered list of tasks.
## Usage
When reordering, you pass the join record, not the task:
```elixir
# Get the join record
task_user = Repo.get_by!(TaskUser, task_id: task.id, user_id: user.id)
# Move it
TaskUserOrder.move(task_user, direction: :up)
# For between, just pass the task_ids - the scope (user_id) is inherited from the item
TaskUserOrder.move(task_user, between: {above_task.id, below_task.id})
# Get next order for assigning a new task to a user
order = TaskUserOrder.next_order(user)
Repo.insert!(%TaskUser{task_id: task.id, user_id: user.id, position: order})
```
The `between:` option is smart about composite keys. Since the `user_id` is already known from the `task_user` being moved, you only need to specify the `task_id` of the neighboring items. The library figures out that `task_id` is the "identity" field (primary key minus scope).
This can feel indirect. The user thinks "I'm reordering my tasks" but the code operates on join records.
## Alternative Perspective: "Tasks per User" vs "Users per Task"
The same join table could support two different orderings:
- **Tasks per user**: Each user has their own ordered list of tasks (`scope: [:user_id]`)
- **Users per task**: Each task has an ordered list of assigned users (`scope: [:task_id]`)
These would be two separate Order modules with different scopes:
```elixir
defmodule TaskUserOrder do
use EctoOrderable, repo: MyRepo, schema: TaskUser, scope: [:user_id]
end
defmodule UserTaskOrder do
use EctoOrderable, repo: MyRepo, schema: TaskUser, scope: [:task_id]
end
```