diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b7a6e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 3ab3a1e..483acea 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/config/config.exs b/config/config.exs index f2da33e..c4f4b17 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config if Mix.env() == :test do import_config "test.exs" diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..2f250a8 --- /dev/null +++ b/config/runtime.exs @@ -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 diff --git a/config/test.exs b/config/test.exs index a2f9c76..1d1d0af 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :ecto_position, EctoPosition.Test.Repo, adapter: Ecto.Adapters.Postgres, diff --git a/mix.exs b/mix.exs index f3faa90..a27aa68 100644 --- a/mix.exs +++ b/mix.exs @@ -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 diff --git a/mix.lock b/mix.lock index 0446151..d4fc073 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, } diff --git a/test/test_helper.exs b/test/test_helper.exs index 3306144..fe0e3a6 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,3 @@ -ExUnit.start() - defmodule EctoPosition.TestCase do use ExUnit.CaseTemplate @@ -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)