Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: CI

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

env:
MIX_ENV: test

jobs:
lint-and-test:
runs-on: ubuntu-latest
name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}

services:
db:
image: postgres:14-alpine
ports: ["5432:5432"]
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ma_crud_test
options: >-
--health-cmd pg_isready
--health-interval 1s
--health-timeout 1s
--health-retries 20

strategy:
matrix:
otp: ["25.0.4"]
elixir: ["1.14.2"]

steps:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
otp-version: ${{matrix.otp}}
elixir-version: ${{matrix.elixir}}
- name: Checkout code
uses: actions/checkout@v3
- name: Cache deps
id: cache-deps
uses: actions/cache@v3
env:
cache-name: cache-elixir-deps
with:
path: deps
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
- name: Cache compiled build
id: cache-build
uses: actions/cache@v3
env:
cache-name: cache-compiled-build
with:
path: _build
key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-mix-${{ env.cache-name }}-
${{ runner.os }}-mix-
- run: mix deps.get
- run: mix deps.compile
- run: mix format --check-formatted
- run: mix test
144 changes: 136 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
# EctoPosition

A package to manage a position field in an Ecto schema.
[![Hex.pm](https://img.shields.io/hexpm/v/ecto_position.svg)](https://hex.pm/packages/ecto_position)
[![Documentation](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/ecto_position)

[Documentation](https://hexdocs.pm/ecto_position)
A powerful Elixir package for managing position fields in Ecto schemas. Easily add drag-and-drop ordering, list reordering, and position management to your Phoenix applications.

## Installation
## ✨ Features

- 🎯 **Precise Positioning** - Insert records at specific positions with automatic repositioning
- 🔄 **Flexible Movement** - Move records up, down, to top, bottom, or relative to other records
- 🎌 **Scoped Operations** - Limit positioning operations to specific query scopes
- 🗂️ **Multi-tenant Support** - Full database prefix support for multi-tenant applications
- 🔧 **Automatic Cleanup** - Smart repositioning when removing records
- 🔄 **Position Reset** - Bulk reset positions for data cleanup

## 🚀 Installation

Add `ecto_position` to your list of dependencies in `mix.exs`:

```elixir
def deps do
Expand All @@ -14,9 +26,125 @@ def deps do
end
```

## Roadmap
## 📖 Usage

### Adding Records with Position

```elixir
# Add at specific position (repositions existing records)
{:ok, todo} = EctoPosition.add(Repo, new_todo, 1)

# Add at top (position 0)
{:ok, todo} = EctoPosition.add(Repo, new_todo, :top)

# Add at bottom (last position)
{:ok, todo} = EctoPosition.add(Repo, new_todo, :bottom)

# Add relative to another record
{:ok, todo} = EctoPosition.add(Repo, new_todo, {:above, existing_todo})
{:ok, todo} = EctoPosition.add(Repo, new_todo, {:below, existing_todo})
```

### Moving Records

```elixir
# Move to specific position
{:ok, todo} = EctoPosition.move(Repo, todo, 2)

# Move up/down one position
{:ok, todo} = EctoPosition.move(Repo, todo, :up)
{:ok, todo} = EctoPosition.move(Repo, todo, :down)

# Move to top/bottom
{:ok, todo} = EctoPosition.move(Repo, todo, :top)
{:ok, todo} = EctoPosition.move(Repo, todo, :bottom)

# Move relative to another record
{:ok, todo} = EctoPosition.move(Repo, todo, {:above, other_todo})
{:ok, todo} = EctoPosition.move(Repo, todo, {:below, other_todo})
```

### Removing Records

```elixir
# Remove and reposition remaining records
{:ok, todo} = EctoPosition.remove(Repo, todo)
Repo.delete(todo)
```

### Resetting Positions

```elixir
# Reset all positions in order (useful for data cleanup)
{:ok, todos} = EctoPosition.reset(Repo, Todo)
```

## 🎯 Scoped Operations

Limit operations to specific subsets of records:

```elixir
# Only affect todos in a specific category
scope = from(t in Todo, where: t.category == ^"Work")

{:ok, todo} = EctoPosition.add(Repo, new_todo, :top, scope: scope)
{:ok, todo} = EctoPosition.move(Repo, todo, :bottom, scope: scope)
{:ok, todo} = EctoPosition.remove(Repo, todo, scope: scope)
{:ok, todos} = EctoPosition.reset(Repo, scope)
```

## 🏢 Multi-tenant Support

Works seamlessly with database prefixes:

```elixir
# Operations automatically respect the record's prefix
{:ok, todo} = EctoPosition.add(Repo, tenant_todo, :top)
{:ok, todo} = EctoPosition.move(Repo, tenant_todo, :bottom)
```

## 🔧 Position Options

| Option | Description | Example |
|--------|-------------|---------|
| `Integer` | Specific position (0-based) | `0`, `1`, `5` |
| `:top` | Move to first position | `:top` |
| `:bottom` | Move to last position | `:bottom` |
| `:up` | Move up one position | `:up` |
| `:down` | Move down one position | `:down` |
| `{:above, record}` | Position above another record | `{:above, todo}` |
| `{:below, record}` | Position below another record | `{:below, todo}` |

## 🎪 Smart Behavior

- **Negative positions** are automatically set to 0
- **Positions beyond range** are set to the last position
- **Edge case handling** - moving up from top or down from bottom is safe
- **Automatic repositioning** - other records adjust automatically
- **Nil record handling** - `{:above, nil}` moves to top, `{:below, nil}` moves to bottom

## 📚 API Reference

### Core Functions

- `EctoPosition.add(repo, record, position, opts \\ [])` - Add record at position
- `EctoPosition.move(repo, record, position, opts \\ [])` - Move existing record
- `EctoPosition.remove(repo, record, opts \\ [])` - Remove record and reposition others
- `EctoPosition.reset(repo, queryable, opts \\ [])` - Reset all positions

### Options

- `:scope` - Limit operations to a specific query scope
- Database prefixes are automatically handled

## 🤝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## 📄 License

This project is licensed under the MIT License.

---

- [x] Add tests
- [ ] Add documentation
- [x] Add support for `:before` and `:after` options
- [ ] Add support for alternate storage strategies (maybe)
Made with ❤️ for the Elixir community
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

if Mix.env() == :test do
import_config "test.exs"
Expand Down
21 changes: 21 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Config

if config_env() == :test do
# for default values look into:
# - https://github.com/maxohq/maxo_test_iex/blob/main/lib/test_iex/config.ex

# NO need to watch for tests on CI
if System.get_env("CI"), do: config(:maxo_test_iex, watcher_enable: false)

# how long multiple consecutive events on the same file should be considered duplicates?
config :maxo_test_iex, watcher_dedup_timeout: 500

# which file changes should trigger a test re-run?
config :maxo_test_iex, watcher_args: [dirs: ["lib/", "test/"], latency: 0]

# which file extensions are relevant to trigger a test re-run?
config :maxo_test_iex, watcher_extensions: [".ex", ".exs"]

# should we log debug messages?
config :maxo_test_iex, debug: false
end
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

config :ecto_position, EctoPosition.Test.Repo,
adapter: Ecto.Adapters.Postgres,
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ defmodule EctoPosition.MixProject do
[
{:ecto_sql, ">= 3.0.0"},
{:postgrex, ">= 0.0.0"},
{:ex_doc, ">= 0.0.0", only: :dev}
{:ex_doc, ">= 0.0.0", only: :dev},
{:maxo_test_iex, "~> 0.1", only: :test}
]
end
end
27 changes: 15 additions & 12 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
%{
"db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"},
"ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"},
"ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"},
"ex_doc": {:hex, :ex_doc, "0.30.0", "ed94bf5183f559d2f825e4f866cc0eab277bbb17da76aff40f8e0f149656943e", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "6743fe46704fe27e2f2558faa61f00e5356528768807badb2092d38476d6dac2"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
"postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"maxo_test_iex": {:hex, :maxo_test_iex, "0.1.7", "cc8ad43afab1284fce607dd9d5419b4d8e6fc66a5da5972ba1aa966cdca581ac", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f15ffb1d736b8d9a762a9c2db71ca1afb79e75a4c037f0ade0dd688f17ff5a2a"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
}
7 changes: 3 additions & 4 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
ExUnit.start()

defmodule EctoPosition.TestCase do
use ExUnit.CaseTemplate

Expand All @@ -9,5 +7,6 @@ defmodule EctoPosition.TestCase do
end
end

{:ok, _pid} = EctoPosition.Test.Repo.start_link()
Ecto.Adapters.SQL.Sandbox.mode(EctoPosition.Test.Repo, {:shared, self()})
EctoPosition.Test.Repo.start_link()
Ecto.Adapters.SQL.Sandbox.mode(EctoPosition.Test.Repo, :manual)
ExUnit.start(timeout: :infinity)