├── config ├── dev.exs ├── prod.exs ├── test.exs └── config.exs ├── test ├── ecto_crdt_types_test.exs ├── test_helper.exs ├── support │ └── test_app │ │ ├── repo.ex │ │ ├── schema.ex │ │ └── test_app.ex ├── types │ ├── state │ │ ├── lwwregister_test.exs │ │ ├── dwflag_test.exs │ │ ├── ewflag_test.exs │ │ └── awset_test.exs │ └── crdt_test.exs ├── fields_test.exs └── changeset_test.exs ├── lib ├── ecto_crdt_types.ex └── ecto_crdt_types │ ├── types │ ├── pure │ │ ├── dwflag.ex │ │ ├── ewflag.ex │ │ ├── gset.ex │ │ ├── awset.ex │ │ ├── gcounter.ex │ │ ├── pncounter.ex │ │ ├── rwset.ex │ │ ├── twopset.ex │ │ └── mvregister.ex │ ├── state │ │ ├── rwset.ex │ │ ├── max_int.ex │ │ ├── boolean.ex │ │ ├── gset.ex │ │ ├── lexcounter.ex │ │ ├── mvregister.ex │ │ ├── ivar.ex │ │ ├── awmap.ex │ │ ├── awset_ps.ex │ │ ├── gmap.ex │ │ ├── bcounter.ex │ │ ├── helpers.ex │ │ ├── twopset.ex │ │ ├── gcounter.ex │ │ ├── pncounter.ex │ │ ├── ewflag.ex │ │ ├── dwflag.ex │ │ ├── lwwregister.ex │ │ └── awset.ex │ └── crdt.ex │ ├── fields.ex │ └── changeset.ex ├── priv └── repo │ └── migrations │ └── 20170530201724_create_entity.exs ├── .formatter.exs ├── .travis.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── mix.exs └── mix.lock /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /test/ecto_crdt_types_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypesTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | TestApp.start([], []) 4 | Ecto.Adapters.SQL.Sandbox.mode(TestApp.Repo, :manual) 5 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes do 2 | @moduledoc """ 3 | Documentation for EctoCrdtTypes. 4 | """ 5 | end 6 | -------------------------------------------------------------------------------- /test/support/test_app/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule TestApp.Repo do 2 | use Ecto.Repo, otp_app: :ecto_crdt_types, adapter: Ecto.Adapters.Postgres 3 | end 4 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/dwflag.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.DWFlag do 2 | @crdt_type :pure_dwflag 3 | @crdt_value_type :boolean 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/ewflag.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.EWFlag do 2 | @crdt_type :pure_ewflag 3 | @crdt_value_type :boolean 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/gset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.GSet do 2 | @crdt_type :pure_gset 3 | @crdt_value_type {:array, :string} 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/awset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.AWSet do 2 | @crdt_type :pure_awset 3 | @crdt_value_type {:array, :string} 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/gcounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.GCounter do 2 | @crdt_type :pure_gcounter 3 | @crdt_value_type :integer 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/pncounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.PNCounter do 2 | @crdt_type :pure_pncounter 3 | @crdt_value_type :integer 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/rwset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.RWSet do 2 | @crdt_type :pure_rwset 3 | @crdt_value_type {:array, :string} 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/twopset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.TWOPSet do 2 | @crdt_type :pure_twopset 3 | @crdt_value_type {:array, :string} 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/pure/mvregister.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.Pure.MVRegister do 2 | @crdt_type :pure_mvregister 3 | @crdt_value_type {:array, :string} 4 | use EctoCrdtTypes.Types.CRDT 5 | end 6 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/rwset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.RWSet do 2 | @moduledoc """ 3 | 4 | """ 5 | @crdt_type :state_awset 6 | @crdt_value_type {:array, :string} 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/max_int.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.MaxInt do 2 | @moduledoc """ 3 | Max Int CRDT. 4 | """ 5 | @crdt_type :state_max_int 6 | @crdt_value_type :integer 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/boolean.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.Boolean do 2 | @moduledoc """ 3 | Boolean primitive CRDT. 4 | """ 5 | @crdt_type :state_boolean 6 | @crdt_value_type :boolean 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/gset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.GSet do 2 | @moduledoc """ 3 | GSet CRDT: grow only set. 4 | """ 5 | @crdt_type :state_gset 6 | @crdt_value_type {:array, :string} 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/lexcounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.LEXCounter do 2 | @moduledoc """ 3 | Lexicographic Counter. 4 | """ 5 | @crdt_type :state_lexcounter 6 | @crdt_value_type :string 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/mvregister.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.MVRegister do 2 | @moduledoc """ 3 | Multi-Value Register CRDT. 4 | """ 5 | @crdt_type :state_mvregister 6 | @crdt_value_type {:array, :string} 7 | use EctoCrdtTypes.Types.CRDT 8 | end 9 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/ivar.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.IVar do 2 | @moduledoc """ 3 | Single-assignment variable. 4 | Write once register. 5 | """ 6 | @crdt_type :state_ivar 7 | @crdt_value_type :string 8 | use EctoCrdtTypes.Types.CRDT 9 | end 10 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/awmap.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.AWMap do 2 | @moduledoc """ 3 | AWMap CRDT. Modeled as a dictionary where keys can be anything and the 4 | values are causal-CRDTs. 5 | """ 6 | @crdt_type :state_awmap 7 | @crdt_value_type :map 8 | use EctoCrdtTypes.Types.CRDT 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170530201724_create_entity.exs: -------------------------------------------------------------------------------- 1 | defmodule TestApp.Repo.Migrations.CreateEntity do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:entities) do 6 | add :counter, :integer 7 | add :test, {:array, :string} 8 | add :test_crdt, :binary 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | structure: 1, 3 | field: 1, 4 | field: 2, 5 | field: 3, 6 | include: 1 7 | ] 8 | 9 | [ 10 | locals_without_parens: locals_without_parens, 11 | export: [locals_without_parens: locals_without_parens], 12 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 13 | ] 14 | 15 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/awset_ps.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.AWSetPS do 2 | @moduledoc """ 3 | Add-Wins Set CRDT with the provenance semiring: 4 | add-wins set without tombstones. 5 | """ 6 | @crdt_type :state_awset_ps 7 | @crdt_value_type {:array, :string} 8 | use EctoCrdtTypes.Types.CRDT 9 | end 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | dist: trusty 4 | sudo: false 5 | 6 | matrix: 7 | include: 8 | - elixir: 1.9.1 9 | otp_release: 22.1 10 | - elixir: 1.8.2 11 | otp_release: 22.1 12 | - elixir: 1.7.2 13 | otp_release: 21.0 14 | - elixir: 1.7.2 15 | otp_release: 20.3.1 16 | - elixir: 1.6.6 17 | otp_release: 19.3 18 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/gmap.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.GMap do 2 | @moduledoc """ 3 | GMap CRDT: grow only map. 4 | Modeled as a dictionary where keys can be anything and the 5 | values are join-semilattices. 6 | """ 7 | @crdt_type :state_gmap 8 | @crdt_value_type :map 9 | use EctoCrdtTypes.Types.CRDT 10 | end 11 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/bcounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.BCounter do 2 | @moduledoc """ 3 | Bounded Counter CRDT. 4 | Modeled as a pair where the first component is a 5 | PNCounter and the second component is a GMap. 6 | """ 7 | @crdt_type :state_bcounter 8 | @crdt_value_type :integer 9 | use EctoCrdtTypes.Types.CRDT 10 | end 11 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.Helpers do 2 | alias EctoCrdtTypes.Types.State.AWSet 3 | 4 | def array_to_awset([], _actor_id) do 5 | AWSet.crdt_type().new() 6 | end 7 | 8 | def array_to_awset(array, actor_id) when is_list(array) do 9 | set = AWSet.crdt_type().new() 10 | 11 | {:ok, set} = AWSet.crdt_type().mutate({:add_all, array}, actor_id, set) 12 | set 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/twopset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.TWOPSet do 2 | @moduledoc """ 3 | 2PSet CRDT: two-phased set. 4 | Once removed, elements cannot be added again. 5 | Also, this is not an observed removed variant. 6 | This means elements can be removed before being 7 | in the set. 8 | """ 9 | @crdt_type :state_twopset 10 | @crdt_value_type {:array, :string} 11 | use EctoCrdtTypes.Types.CRDT 12 | end 13 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto_crdt_types, 4 | ecto_repos: [TestApp.Repo] 5 | 6 | config :ecto_crdt_types, TestApp.Repo, 7 | adapter: Ecto.Adapters.Postgres, 8 | username: System.get_env("POSTGRES_USER") || "postgres", 9 | password: System.get_env("POSTGRES_PASSWORD") || "postgres", 10 | database: "ecto_crdt_types_test", 11 | hostname: System.get_env("POSTGRES_HOST") || "localhost", 12 | pool: Ecto.Adapters.SQL.Sandbox 13 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/gcounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.GCounter do 2 | @moduledoc """ 3 | GCounter CRDT: grow only counter. 4 | Modeled as a dictionary where keys are replicas ids and 5 | values are the correspondent count. 6 | An actor may only update its own entry in the dictionary. 7 | The value of the counter is the sum all values in the dictionary. 8 | """ 9 | @crdt_type :state_gcounter 10 | @crdt_value_type :integer 11 | use EctoCrdtTypes.Types.CRDT 12 | end 13 | -------------------------------------------------------------------------------- /test/support/test_app/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule TestApp.Schema do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | import EctoCrdtTypes.Changeset 6 | 7 | use EctoCrdtTypes.Fields 8 | alias EctoCrdtTypes.Types.State.AWSet 9 | 10 | schema "entities" do 11 | crdt_field(:test, AWSet) 12 | field :counter, :integer, default: 10 13 | end 14 | 15 | def changeset(model, params \\ %{}) do 16 | model 17 | |> cast(params, [:counter]) 18 | |> cast_crdt([:test]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/test_app/test_app.ex: -------------------------------------------------------------------------------- 1 | defmodule TestApp do 2 | use Application 3 | alias TestApp.Repo 4 | 5 | def start(_type, _args) do 6 | import Supervisor.Spec 7 | 8 | # Define workers and child supervisors to be supervised 9 | children = [ 10 | # Start the Ecto repository 11 | supervisor(Repo, []) 12 | ] 13 | 14 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 15 | # for other strategies and supported options 16 | opts = [strategy: :one_for_one, name: TestApp.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/pncounter.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.PNCounter do 2 | @moduledoc """ 3 | PNCounter CRDT: counter that allows both increments and decrements. 4 | Modeled as a dictionary where keys are replicas ids and 5 | values are pairs where the first component is the number of 6 | increments and the second component is the number of 7 | decrements. 8 | An actor may only update its own entry in the dictionary. 9 | The value of the counter is the sum of all first components minus the sum of all second components. 10 | """ 11 | @crdt_type :state_pncounter 12 | @crdt_value_type :integer 13 | use EctoCrdtTypes.Types.CRDT 14 | end 15 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/ewflag.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.EWFlag do 2 | @moduledoc """ 3 | Enable-Wins Flag CRDT. 4 | Starts disabled. 5 | """ 6 | @crdt_type :state_ewflag 7 | @crdt_value_type :boolean 8 | use EctoCrdtTypes.Types.CRDT 9 | 10 | def new, do: @crdt_type.new() 11 | def new(true, actor), do: enable(new(), actor) 12 | def new(false, actor), do: disable(new(), actor) 13 | 14 | def enable(nil, actor) do 15 | enable(new(), actor) 16 | end 17 | def enable(crdt, actor) do 18 | {:ok, crdt} = @crdt_type.mutate(:enable, actor, crdt) 19 | crdt 20 | end 21 | 22 | def disable(nil, actor) do 23 | disable(new(), actor) 24 | end 25 | def disable(crdt, actor) do 26 | {:ok, crdt} = @crdt_type.mutate(:disable, actor, crdt) 27 | crdt 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/dwflag.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.DWFlag do 2 | @moduledoc """ 3 | Disable-Wins Flag CRDT. 4 | Starts enabled. 5 | 6 | Follows the same strategy used in Enable-Wins Flag but, 7 | instead of creating a new dot when enabling the flag, 8 | we create a new dot when disabling it. 9 | """ 10 | @crdt_type :state_dwflag 11 | @crdt_value_type :boolean 12 | use EctoCrdtTypes.Types.CRDT 13 | 14 | def new, do: @crdt_type.new() 15 | def new(true, actor), do: enable(new(), actor) 16 | def new(false, actor), do: disable(new(), actor) 17 | 18 | def enable(nil, actor) do 19 | enable(new(), actor) 20 | end 21 | def enable(crdt, actor) do 22 | {:ok, crdt} = @crdt_type.mutate(:enable, actor, crdt) 23 | crdt 24 | end 25 | 26 | def disable(nil, actor) do 27 | disable(new(), actor) 28 | end 29 | def disable(crdt, actor) do 30 | {:ok, crdt} = @crdt_type.mutate(:disable, actor, crdt) 31 | crdt 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Unlimited Technologies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :ecto_crdt_types, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:ecto_crdt_types, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/lwwregister.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.LWWRegister do 2 | @moduledoc """ 3 | LWWRegister. 4 | We assume timestamp are unique, totally ordered and consistent 5 | with causal order. We use integers as timestamps. 6 | When using this, make sure you provide globally unique 7 | timestamps. 8 | """ 9 | @crdt_type :state_lwwregister 10 | @crdt_value_type :string 11 | @default_value nil 12 | use EctoCrdtTypes.Types.CRDT 13 | 14 | def new do 15 | {@crdt_type, {0, @default_value}} 16 | end 17 | def new(value, timestamp_fn \\ ×tamp/0) do 18 | crdt = @crdt_type.new() 19 | set(crdt, value, timestamp_fn) 20 | end 21 | 22 | def default(default) do 23 | {@crdt_type, {0, default}} 24 | end 25 | 26 | def set(crdt, value, timestamp_fn \\ ×tamp/0) 27 | def set(nil, value, timestamp_fn) do 28 | set(@crdt_type.new(), value, timestamp_fn) 29 | end 30 | def set(crdt, value, timestamp_fn) do 31 | {:ok, crdt} = @crdt_type.mutate({:set, timestamp_fn.(), value}, :unused, crdt) 32 | crdt 33 | end 34 | 35 | def empty_values, do: [default(nil), default(:undefined)] 36 | 37 | defp timestamp do 38 | DateTime.utc_now() |> DateTime.to_unix(:microsecond) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/state/awset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.AWSet do 2 | @moduledoc """ 3 | Add-Wins ORSet CRDT: observed-remove set without tombstones. 4 | """ 5 | @crdt_type :state_awset 6 | @crdt_value_type {:array, :string} 7 | use EctoCrdtTypes.Types.CRDT 8 | 9 | def new, do: @crdt_type.new() 10 | def new(value, actor) when is_list(value), do: add_all(new(), value, actor) 11 | def new(value, actor), do: add(new(), value, actor) 12 | 13 | def add(nil, value, actor) do 14 | add(@crdt_type.new(), value, actor) 15 | end 16 | def add(crdt, value, actor) do 17 | {:ok, crdt} = @crdt_type.mutate({:add, value}, actor, crdt) 18 | crdt 19 | end 20 | 21 | def add_all(nil, value, actor) do 22 | add_all(@crdt_type.new(), value, actor) 23 | end 24 | def add_all(crdt, value, actor) do 25 | {:ok, crdt} = @crdt_type.mutate({:add_all, value}, actor, crdt) 26 | crdt 27 | end 28 | 29 | def rmv(nil, value, actor) do 30 | rmv(@crdt_type.new(), value, actor) 31 | end 32 | def rmv(crdt, value, actor) do 33 | {:ok, crdt} = @crdt_type.mutate({:rmv, value}, actor, crdt) 34 | crdt 35 | end 36 | 37 | def rmv_all(nil, value, actor) do 38 | rmv_all(@crdt_type.new(), value, actor) 39 | end 40 | def rmv_all(crdt, value, actor) do 41 | {:ok, crdt} = @crdt_type.mutate({:rmv_all, value}, actor, crdt) 42 | crdt 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/types/state/lwwregister_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.LWWRegisterTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoCrdtTypes.Types.State.LWWRegister 5 | 6 | describe "#new/2" do 7 | test "creates new lwwregister with default value" do 8 | assert {:state_lwwregister, {_timestamp, nil}} = LWWRegister.new() 9 | end 10 | 11 | test "creates new lwwregister with passed value" do 12 | assert {:state_lwwregister, {_timestamp, "value"}} = LWWRegister.new("value") 13 | end 14 | 15 | test "creates new lwwregister with custom timestamp function" do 16 | timestamp_fun = fn -> 1234 end 17 | assert {:state_lwwregister, {1234, "value"}} = LWWRegister.new("value", timestamp_fun) 18 | end 19 | end 20 | 21 | describe "#set/3" do 22 | test "sets new value to crdt" do 23 | crdt = LWWRegister.new("old value") 24 | assert {:state_lwwregister, {_timestamp, "new value"}} = LWWRegister.set(crdt, "new value") 25 | end 26 | 27 | test "sets new value using custom timestamp_fun" do 28 | crdt = LWWRegister.new("old value", fn -> 1 end) 29 | result = LWWRegister.set(crdt, "new value", fn -> 2 end) 30 | assert {:state_lwwregister, {2, "new value"}} = result 31 | end 32 | 33 | test "sets new value when crdt is null" do 34 | result = LWWRegister.set(nil, "new value") 35 | assert {:state_lwwregister, {_timestamp, "new value"}} = result 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/fields.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Fields do 2 | defmacro __using__(_opts \\ []) do 3 | quote do 4 | import EctoCrdtTypes.Fields 5 | end 6 | end 7 | 8 | defmacro crdt_field(name, type, opts \\ []) do 9 | crdt_opts = Keyword.get(opts, :crdt, []) 10 | value_opts = Keyword.get(opts, :value, []) 11 | 12 | name_crdt = String.to_atom("#{name}_crdt") 13 | 14 | quote do 15 | EctoCrdtTypes.Fields.__crdt_value_field__( 16 | __MODULE__, 17 | unquote(name), 18 | unquote(type), 19 | unquote(value_opts) 20 | ) 21 | 22 | EctoCrdtTypes.Fields.__crdt_field__( 23 | __MODULE__, 24 | unquote(name_crdt), 25 | unquote(type), 26 | unquote(crdt_opts), 27 | unquote(value_opts) 28 | ) 29 | end 30 | end 31 | 32 | def __crdt_field__(module, name, type, crdt_opts, value_opts) do 33 | default_value = case Keyword.get(value_opts, :default) do 34 | nil -> type.default() 35 | value -> type.default(value) 36 | end 37 | 38 | opts = Keyword.put_new(crdt_opts, :default, default_value) 39 | 40 | Ecto.Schema.__field__(module, name, type, opts) 41 | end 42 | 43 | def __crdt_value_field__(module, name, type, opts) do 44 | opts = Keyword.put_new(opts, :default, type.default_value()) 45 | {type, opts} = Keyword.pop(opts, :type, type.crdt_value_type) 46 | 47 | Ecto.Schema.__field__(module, name, type, opts) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/types/state/dwflag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.DWFlagTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoCrdtTypes.Types.State.DWFlag 5 | 6 | describe "#new/0" do 7 | test "creates new ewflag" do 8 | assert {:state_dwflag, {[], {[],[]}}} = DWFlag.new() 9 | end 10 | end 11 | 12 | describe "#new/2" do 13 | test "creates new awset with init value" do 14 | assert {:state_dwflag, {[a: 1], {[a: 1],[]}}} = DWFlag.new(false, :a) 15 | assert {:state_dwflag, {[], {[],[]}}} = DWFlag.new(true, :a) 16 | end 17 | end 18 | 19 | describe "#enable/2" do 20 | test "enables flag" do 21 | init_state = DWFlag.new() 22 | state1 = DWFlag.enable(init_state, :a) 23 | state2 = DWFlag.enable(state1, :b) 24 | assert {:state_dwflag, {[], {[], []}}} = state1 25 | assert {:state_dwflag, {[], {[], []}}} = state2 26 | 27 | assert DWFlag.value(state2) == true 28 | end 29 | 30 | test "enables flag for nil crdt" do 31 | state = DWFlag.enable(nil, :a) 32 | assert {:state_dwflag, {[], {[], []}}} = state 33 | assert DWFlag.value(state) == true 34 | end 35 | end 36 | 37 | describe "#disable/2" do 38 | test "disables flag" do 39 | initial_state = DWFlag.new(true, :a) 40 | state1 = DWFlag.disable(initial_state, :b) 41 | state2 = DWFlag.disable(state1, :a) 42 | 43 | assert {:state_dwflag, {[b: 1], {[b: 1], []}}} = state1 44 | assert {:state_dwflag, {[a: 1], {[a: 1, b: 1], []}}} = state2 45 | 46 | assert DWFlag.value(state2) == false 47 | end 48 | 49 | test "disables flag for nil crdt" do 50 | state = DWFlag.disable(nil, :a) 51 | assert {:state_dwflag, {[a: 1], {[a: 1], []}}} = state 52 | assert DWFlag.value(state) == false 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/types/state/ewflag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.EWFlagTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoCrdtTypes.Types.State.EWFlag 5 | 6 | describe "#new/0" do 7 | test "creates new ewflag" do 8 | assert {:state_ewflag, {[], {[],[]}}} = EWFlag.new() 9 | end 10 | end 11 | 12 | describe "#new/2" do 13 | test "creates new awset with init value" do 14 | assert {:state_ewflag, {[a: 1], {[a: 1],[]}}} = EWFlag.new(true, :a) 15 | assert {:state_ewflag, {[], {[],[]}}} = EWFlag.new(false, :a) 16 | end 17 | end 18 | 19 | describe "#enable/2" do 20 | test "enables flag" do 21 | init_state = EWFlag.new() 22 | state1 = EWFlag.enable(init_state, :a) 23 | state2 = EWFlag.enable(state1, :b) 24 | assert {:state_ewflag, {[a: 1], {[a: 1], []}}} = state1 25 | assert {:state_ewflag, {[b: 1], {[a: 1, b: 1], []}}} = state2 26 | 27 | assert EWFlag.value(state2) == true 28 | end 29 | 30 | test "enables flag for nil crdt" do 31 | state = EWFlag.enable(nil, :a) 32 | assert {:state_ewflag, {[a: 1], {[a: 1], []}}} = state 33 | assert EWFlag.value(state) == true 34 | end 35 | end 36 | 37 | describe "#disable/2" do 38 | test "disables flag" do 39 | initial_state = EWFlag.new(true, :a) 40 | state1 = EWFlag.disable(initial_state, :b) 41 | state2 = EWFlag.disable(state1, :a) 42 | 43 | assert {:state_ewflag, {[], {[a: 1], []}}} = state1 44 | assert {:state_ewflag, {[], {[a: 1], []}}} = state2 45 | 46 | assert EWFlag.value(state2) == false 47 | end 48 | 49 | test "disables flag for nil crdt" do 50 | state = EWFlag.disable(nil, :a) 51 | assert {:state_ewflag, {[], {[], []}}} = state 52 | assert EWFlag.value(state) == false 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoCrdtTypes [![Build Status](https://travis-ci.org/ExpressApp/ecto_crdt_types.svg?branch=master)](https://travis-ci.org/ExpressApp/ecto_crdt_types) [![Hex.pm](https://img.shields.io/hexpm/v/ecto_crdt_types.svg)](https://hex.pm/packages/ecto_crdt_types) 2 | 3 | --- 4 | 5 | Libary provides support for saving CRDT data and values to db using Ecto. 6 | 7 | It provides: 8 | - changeset function `cast_crdt`, 9 | - Ecto.Schema macro `crdt_field` 10 | - custom Ecto types, with generic support of [lasp-lang/types](https://github.com/lasp-lang/types) library underneath. 11 | 12 | Currently we actively use the following types from `lasp-lang`: 13 | - :state_awset 14 | - :state_lwwregistry 15 | 16 | Other types have very basic support. So feel free to contribute! 17 | 18 | --- 19 | 20 | 21 | ## Installation 22 | 23 | 1. Add `ecto_crdt_types` to your list of dependencies in `mix.exs`: 24 | 25 | ```elixir 26 | def deps do 27 | [{:ecto_crdt_types, "~> 0.4.0"}] 28 | end 29 | ``` 30 | 31 | 2. Ensure `ecto_crdt_types` is started before your application: 32 | 33 | ```elixir 34 | def application do 35 | [applications: [:ecto_crdt_types]] 36 | end 37 | ``` 38 | 39 | ## Usage 40 | 41 | Define Ecto schema and changeset: 42 | 43 | ```elixir 44 | defmodule User do 45 | use Ecto.Schema 46 | import EctoCrdtTypes.Fields 47 | 48 | alias EctoCrdtTypes.Types.State.AWSet 49 | 50 | schema "users" do 51 | field :name, :string 52 | crdt_field :devices, AWSet 53 | end 54 | 55 | def changeset(model, params) do 56 | params 57 | |> cast(params, [:name]) 58 | |> cast_crdt([:devices]) 59 | end 60 | end 61 | ``` 62 | 63 | Initialize new `User` changeset: 64 | 65 | ```elixir 66 | iex> alias EctoCrdtTypes.Types.State.AWSet 67 | 68 | iex> user = 69 | %User{} 70 | |> User.changeset(%{"name" => "My Name", "devices_crdt" => AWSet.new([])) 71 | |> Repo.insert(user) 72 | ``` 73 | 74 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_crdt_types, 7 | version: "0.4.3", 8 | elixir: "~> 1.6", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | build_embedded: Mix.env() == :prod, 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | 14 | # Hex 15 | description: description(), 16 | package: package(), 17 | 18 | # Docs 19 | name: "EctoCrdtTypes", 20 | docs: docs() 21 | ] 22 | end 23 | 24 | # Configuration for the OTP application 25 | # 26 | # Type "mix help compile.app" for more information 27 | def application do 28 | # Specify extra applications you'll use from Erlang/Elixir 29 | [extra_applications: [:logger]] 30 | end 31 | 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | # Dependencies can be Hex packages: 36 | # 37 | # {:my_dep, "~> 0.3.0"} 38 | # 39 | # Or git/path repositories: 40 | # 41 | # {:my_dep, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 42 | # 43 | # Type "mix help deps" for more examples and options 44 | defp deps do 45 | [ 46 | {:types, "~> 0.1.6"}, 47 | {:ecto_sql, "~> 3.5"}, 48 | {:postgrex, ">= 0.0.0", only: :test}, 49 | {:ex_doc, "~> 0.21", only: :dev, runtime: false} 50 | ] 51 | end 52 | 53 | defp description do 54 | "Library providing support for saving CRDT data and values to db using Ecto." 55 | end 56 | 57 | defp package do 58 | [ 59 | name: :ecto_crdt_types, 60 | maintainers: ["Yuri Artemev", "Alexander Malaev"], 61 | licenses: ["MIT"], 62 | links: %{"GitHub" => "https://github.com/ExpressApp/ecto_crdt_types"} 63 | ] 64 | end 65 | 66 | defp docs do 67 | [ 68 | main: "readme", 69 | source_url: "https://github.com/ExpressApp/ecto_crdt_types", 70 | extras: ["README.md"] 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/types/state/awset_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.State.AWSetTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoCrdtTypes.Types.State.AWSet 5 | 6 | describe "#new/0" do 7 | test "creates new empty awset" do 8 | assert {:state_awset, {[], {[], []}}} = AWSet.new() 9 | end 10 | end 11 | 12 | describe "#new/2 with list value" do 13 | test "creates new awset with list as init values" do 14 | assert {:state_awset, {[{1, [a: 1]}, {2, [a: 2]}], {[a: 2], []}}} = AWSet.new([1, 2], :a) 15 | end 16 | end 17 | 18 | describe "#new/2 with single value" do 19 | test "creates new awset with value as init value" do 20 | assert {:state_awset, {[{1, [a: 1]}], {[a: 1], []}}} = AWSet.new(1, :a) 21 | end 22 | end 23 | 24 | describe "#add/3" do 25 | test "adds value to crdt" do 26 | assert {:state_awset, {[{1, [a: 1]}], {[a: 1], []}}} = AWSet.new() |> AWSet.add(1, :a) 27 | end 28 | 29 | test "adds value to nil crdt" do 30 | assert {:state_awset, {[{1, [a: 1]}], {[a: 1], []}}} = AWSet.add(nil, 1, :a) 31 | end 32 | end 33 | 34 | describe "#add_all/3" do 35 | test "adds values to crdt" do 36 | assert {:state_awset, {[{1, [a: 1]}, {2, [a: 2]}], {[a: 2], []}}} = 37 | AWSet.new() |> AWSet.add_all([1, 2], :a) 38 | end 39 | 40 | test "adds values to nil crdt" do 41 | assert {:state_awset, {[{1, [a: 1]}, {2, [a: 2]}], {[a: 2], []}}} = 42 | AWSet.add_all(nil, [1, 2], :a) 43 | end 44 | end 45 | 46 | describe "#rmv/3" do 47 | test "removes value from crdt" do 48 | assert {:state_awset, {[{2, [a: 2]}], {[a: 2], []}}} = 49 | AWSet.new([1, 2], :a) |> AWSet.rmv(1, :a) 50 | end 51 | 52 | test "removes value from nil crdt" do 53 | assert {:state_awset, {[], {[], []}}} = 54 | AWSet.rmv(nil, 1, :a) 55 | end 56 | end 57 | 58 | describe "#rmv_all/3" do 59 | test "removes value from crdt" do 60 | assert {:state_awset, {[], {[a: 2], []}}} = 61 | AWSet.new([1, 2], :a) |> AWSet.rmv_all([1, 2], :a) 62 | end 63 | 64 | test "removes value from nil crdt" do 65 | assert {:state_awset, {[], {[], []}}} = 66 | AWSet.rmv_all(nil, [1, 2], :a) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/types/crdt.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.CRDT do 2 | defmacro __using__(_opts \\ []) do 3 | quote do 4 | def type(), do: :binary 5 | 6 | def cast({@crdt_type, _data} = data), do: {:ok, data} 7 | def cast(_), do: :error 8 | 9 | def load(data) when is_binary(data) do 10 | {:ok, :erlang.binary_to_term(data, [:safe])} 11 | rescue 12 | ArgumentError -> :error 13 | end 14 | 15 | def load(nil) do 16 | {:ok, @crdt_type.new()} 17 | end 18 | 19 | def load(_) do 20 | :error 21 | end 22 | 23 | def dump(nil), do: {:ok, :erlang.term_to_binary(@crdt_type.new())} 24 | def dump({@crdt_type, _data} = data), do: {:ok, :erlang.term_to_binary(data)} 25 | def dump(_), do: :error 26 | 27 | def new, do: @crdt_type.new() 28 | 29 | def crdt_type, do: @crdt_type 30 | def crdt_value_type, do: @crdt_value_type 31 | 32 | def default, do: __MODULE__.new() 33 | def default(_value), do: default() 34 | def default_value, do: __MODULE__.value(default()) 35 | 36 | def value(crdt), do: cast_value(@crdt_type.query(crdt)) 37 | 38 | def cast_value(crdt_value) do 39 | cond do 40 | :sets.is_set(crdt_value) -> :sets.to_list(crdt_value) 41 | true -> crdt_value 42 | end 43 | end 44 | 45 | def equal?(term1, term2), do: term1 == term2 46 | def embed_as(_), do: :self 47 | 48 | def empty_values, do: [] 49 | 50 | defoverridable value: 1, cast_value: 1, new: 0, default: 1, empty_values: 0, equal?: 2 51 | end 52 | end 53 | 54 | def valid_types do 55 | state_types() ++ pure_types() 56 | end 57 | 58 | def state_types do 59 | [ 60 | :state_awmap, 61 | :state_awset, 62 | :state_awset_ps, 63 | :state_bcounter, 64 | :state_boolean, 65 | :state_dwflag, 66 | :state_ewflag, 67 | :state_gcounter, 68 | :state_gmap, 69 | :state_gset, 70 | :state_ivar, 71 | :state_lexcounter, 72 | :state_lwwregister, 73 | :state_max_int, 74 | :state_mvregister, 75 | :state_mvmap, 76 | :state_orset, 77 | :state_pair, 78 | :state_pncounter, 79 | :state_twopset 80 | ] 81 | end 82 | 83 | def pure_types do 84 | [ 85 | :pure_awset, 86 | :pure_dwflag, 87 | :pure_ewflag, 88 | :pure_gcounter, 89 | :pure_gset, 90 | :pure_mvregister, 91 | :pure_pncounter, 92 | :pure_rwset, 93 | :pure_twopset 94 | ] 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/types/crdt_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Types.CRDTTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule :any_type do 5 | def new() do 6 | {:any_type, %{}} 7 | end 8 | 9 | def query(crdt) do 10 | crdt 11 | end 12 | end 13 | 14 | defmodule CRDTType do 15 | @crdt_type :any_type 16 | @crdt_value_type {:array, :string} 17 | use EctoCrdtTypes.Types.CRDT 18 | end 19 | 20 | describe "#cast" do 21 | test "casts tuple to binary" do 22 | assert CRDTType.cast({:any_type, {}}) == {:ok, {:any_type, {}}} 23 | end 24 | 25 | test "returns error if pass not matching crdt type" do 26 | assert CRDTType.cast({:any_other, {}}) == :error 27 | end 28 | 29 | test "returns error on any other" do 30 | assert CRDTType.cast(:any_other) == :error 31 | end 32 | end 33 | 34 | describe "#load" do 35 | test "returns erlang term from binary" do 36 | assert CRDTType.load( 37 | <<131, 104, 2, 100, 0, 8, 97, 110, 121, 95, 116, 121, 112, 101, 104, 0>> 38 | ) == {:ok, {:any_type, {}}} 39 | end 40 | 41 | test "returns error if binary cant be translated to term" do 42 | assert CRDTType.load(<<131, 104, 2, 100, 0, 8, 97, 110, 121, 95, 116>>) == :error 43 | end 44 | 45 | test "returns error if not binary" do 46 | assert CRDTType.load(:not_binary) == :error 47 | end 48 | 49 | test "returns empty crdt if nil" do 50 | assert CRDTType.load(nil) == {:ok, CRDTType.new()} 51 | end 52 | end 53 | 54 | describe "#dump" do 55 | test "dumps tuple to binary" do 56 | assert CRDTType.dump({:any_type, {}}) == 57 | {:ok, <<131, 104, 2, 100, 0, 8, 97, 110, 121, 95, 116, 121, 112, 101, 104, 0>>} 58 | end 59 | 60 | test "returns error if pass not matching crdt type" do 61 | assert CRDTType.dump({:any_other, {}}) == :error 62 | end 63 | 64 | test "returns error on any other" do 65 | assert CRDTType.dump(:any_other) == :error 66 | end 67 | 68 | test "dumps nil to default crdt" do 69 | assert CRDTType.dump(nil) == CRDTType.dump(CRDTType.new()) 70 | end 71 | end 72 | 73 | describe "#default" do 74 | test "returns new crdt" do 75 | assert CRDTType.default() == {:any_type, %{}} 76 | end 77 | end 78 | 79 | describe "#default_value" do 80 | test "returns default value" do 81 | assert CRDTType.default_value() == {:any_type, %{}} 82 | end 83 | end 84 | 85 | describe "#value/1" do 86 | test "queries value of crdt" do 87 | assert CRDTType.value(%{}) == :any_type.query(%{}) 88 | end 89 | end 90 | 91 | describe "#cast_value" do 92 | test "casts :sets value to list" do 93 | assert CRDTType.cast_value(:sets.new()) == [] 94 | end 95 | 96 | test "returns value as-is by default" do 97 | assert CRDTType.cast_value(%{}) == %{} 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/fields_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.FieldsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias EctoCrdtTypes.Types.State.LWWRegister 5 | 6 | defmodule CRDTType do 7 | @crdt_type :state_awset 8 | @crdt_value_type {:array, :string} 9 | use EctoCrdtTypes.Types.CRDT 10 | end 11 | 12 | defmodule Schema do 13 | use Ecto.Schema 14 | import EctoCrdtTypes.Fields 15 | 16 | schema "entities" do 17 | crdt_field(:test, CRDTType) 18 | crdt_field(:test_value_type, CRDTType, value: [type: :string]) 19 | crdt_field(:test_value_default, CRDTType, value: [default: "test value"]) 20 | end 21 | end 22 | 23 | defmodule LWWRegisterSchema do 24 | use Ecto.Schema 25 | import EctoCrdtTypes.Fields 26 | 27 | schema "lwwregister" do 28 | crdt_field :integer, LWWRegister, value: [type: :string] 29 | crdt_field :integer_nil, LWWRegister, value: [type: :integer, default: nil] 30 | crdt_field :integer_ten, LWWRegister, value: [type: :integer, default: 10] 31 | crdt_field :integer_default, LWWRegister, value: [type: :integer], crdt: [default: LWWRegister.default(10)] 32 | crdt_field :string, LWWRegister, value: [type: :string] 33 | crdt_field :string_nil, LWWRegister, value: [type: :string, default: nil] 34 | crdt_field :string_some, LWWRegister, value: [type: :string, default: "some"] 35 | end 36 | end 37 | 38 | test "schema metadata" do 39 | assert Schema.__schema__(:fields) == [ 40 | :id, 41 | :test, 42 | :test_crdt, 43 | :test_value_type, 44 | :test_value_type_crdt, 45 | :test_value_default, 46 | :test_value_default_crdt 47 | ] 48 | end 49 | 50 | test "types metadata" do 51 | assert Schema.__schema__(:type, :id) == :id 52 | assert Schema.__schema__(:type, :test) == {:array, :string} 53 | assert Schema.__schema__(:type, :test_crdt) == CRDTType 54 | assert Schema.__schema__(:type, :test_value_type) == :string 55 | assert Schema.__schema__(:type, :test_value_type_crdt) == CRDTType 56 | assert Schema.__schema__(:type, :test_value_default) == {:array, :string} 57 | assert Schema.__schema__(:type, :test_value_default_crdt) == CRDTType 58 | end 59 | 60 | test "schema default" do 61 | assert %Schema{}.test == [] 62 | assert %Schema{}.test_crdt == CRDTType.default() 63 | assert %Schema{}.test_value_default == "test value" 64 | end 65 | 66 | describe "lwwregister" do 67 | test "schema default" do 68 | schema = %LWWRegisterSchema{} 69 | 70 | assert schema.integer_crdt == {:state_lwwregister, {0, nil}} 71 | assert schema.integer_nil_crdt == {:state_lwwregister, {0, nil}} 72 | assert schema.integer_default_crdt == {:state_lwwregister, {0, 10}} 73 | assert schema.integer_ten == 10 74 | assert schema.integer_ten_crdt == {:state_lwwregister, {0, 10}} 75 | assert schema.string_crdt == {:state_lwwregister, {0, nil}} 76 | assert schema.string_nil_crdt == {:state_lwwregister, {0, nil}} 77 | assert schema.string_some == "some" 78 | assert schema.string_some_crdt == {:state_lwwregister, {0, "some"}} 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 3 | "db_connection": {:hex, :db_connection, "2.3.0", "d56ef906956a37959bcb385704fc04035f4f43c0f560dd23e00740daf8028c49", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "dcc082b8f723de9a630451b49fdbd7a59b065c4b38176fb147aaf773574d4520"}, 4 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 5 | "earmark": {:hex, :earmark, "1.4.1", "07bb382826ee8d08d575a1981f971ed41bd5d7e86b917fd012a93c51b5d28727", [:mix], [], "hexpm", "cdfa03374331187c7b9e86d971423a19138dc1cf9902b26923a657c789673876"}, 6 | "ecto": {:hex, :ecto, "3.5.4", "73ee115deb10769c73fd2d27e19e36bc4af7c56711ad063616a86aec44f80f6f", [: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", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7f13f9c9c071bd2ca04652373ff3edd1d686364de573255096872a4abc471807"}, 7 | "ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"}, 8 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, 9 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, 11 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm", "00e3ebdc821fb3a36957320d49e8f4bfa310d73ea31c90e5f925dc75e030da8f"}, 12 | "postgrex": {:hex, :postgrex, "0.15.7", "724410acd48abac529d0faa6c2a379fb8ae2088e31247687b16cacc0e0883372", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {: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]}], "hexpm", "88310c010ff047cecd73d5ceca1d99205e4b1ab1b9abfdab7e00f5c9d20ef8f9"}, 13 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 14 | "types": {:hex, :types, "0.1.8", "5782b67231e8c174fe2835395e71e669fe0121076779d2a09f1c0d58ee0e2f13", [:rebar3], [], "hexpm", "04285239f4954c5ede56f78ed7778ede24e3f2e997f7b16402a167af0cc2658a"}, 15 | } 16 | -------------------------------------------------------------------------------- /lib/ecto_crdt_types/changeset.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.Changeset do 2 | alias Ecto.Changeset 3 | 4 | @spec cast_crdt( 5 | Ecto.Changeset.t(), 6 | [String.t() | atom], 7 | Keyword.t() 8 | ) :: Ecto.Changeset.t() | no_return 9 | def cast_crdt(changeset, crdt_fields, opts \\ []) 10 | 11 | def cast_crdt(%Changeset{data: data, types: types}, _crdt_fields, _opts) 12 | when data == nil or types == nil do 13 | raise ArgumentError, 14 | "cast_crdt/3 expects the changeset to be cast. " <> 15 | "Please call cast/4 before calling cast_crdt/3" 16 | end 17 | 18 | def cast_crdt( 19 | %Changeset{ 20 | changes: changes, 21 | data: data, 22 | types: types, 23 | params: params, 24 | empty_values: empty_values 25 | } = changeset, 26 | crdt_fields, 27 | opts 28 | ) do 29 | opts = Keyword.put_new(opts, :empty_values, empty_values) 30 | 31 | {changes, errors, valid?} = 32 | Enum.reduce( 33 | crdt_fields, 34 | {changes, [], true}, 35 | &process_crdt(&1, params, types, data, opts, &2) 36 | ) 37 | 38 | new_changeset = %Changeset{ 39 | changeset 40 | | changes: changes, 41 | errors: Enum.reverse(errors), 42 | valid?: valid? 43 | } 44 | 45 | Changeset.merge(changeset, new_changeset) 46 | end 47 | 48 | defp process_crdt(key, params, types, data, opts, {changes, errors, valid?}) do 49 | {empty_values, _opts} = Keyword.pop(opts, :empty_values, [""]) 50 | 51 | value_key = cast_key(key) 52 | crdt_key = cast_key("#{key}_crdt") 53 | crdt_param_key = Atom.to_string(crdt_key) 54 | 55 | crdt_type = type!(types, crdt_key) 56 | value_type = type!(types, value_key) 57 | empty_values = empty_values ++ crdt_type.empty_values() 58 | 59 | defaults = 60 | case data do 61 | %{__struct__: struct} -> struct.__struct__() 62 | %{} -> %{} 63 | end 64 | 65 | current = 66 | case changes do 67 | %{^crdt_key => value} -> value 68 | _ -> Map.get(data, crdt_key) || crdt_type.new() 69 | end 70 | 71 | case cast_field( 72 | crdt_param_key, 73 | crdt_key, 74 | crdt_type, 75 | params, 76 | current, 77 | empty_values, 78 | defaults, 79 | valid? 80 | ) do 81 | {:ok, {crdt_value, value}, valid?} -> 82 | case Ecto.Type.cast(value_type, value) do 83 | {:ok, value} -> 84 | changes = 85 | changes 86 | |> Map.put(crdt_key, crdt_value) 87 | |> Map.put(value_key, value) 88 | 89 | {changes, errors, valid?} 90 | 91 | :error -> 92 | {changes, [{key, {"is invalid", [type: value_type, validation: :cast]}} | errors], 93 | false} 94 | end 95 | 96 | :missing -> 97 | {changes, errors, valid?} 98 | 99 | :invalid -> 100 | {changes, [{key, {"is invalid", [type: crdt_type, validation: :cast]}} | errors], false} 101 | end 102 | end 103 | 104 | defp cast_field(crdt_param_key, crdt_key, type, params, current, empty_values, defaults, valid?) do 105 | case params do 106 | %{^crdt_param_key => value} -> 107 | value = if value in empty_values, do: Map.get(defaults, crdt_key), else: value 108 | 109 | case Ecto.Type.cast(type, value) do 110 | {:ok, ^current} -> 111 | :missing 112 | 113 | {:ok, nil} -> 114 | :missing 115 | 116 | {:ok, value} -> 117 | crdt_value = type.crdt_type.merge(current, value) 118 | value = type.value(crdt_value) 119 | 120 | {:ok, {crdt_value, value}, valid?} 121 | 122 | :error -> 123 | :invalid 124 | end 125 | 126 | _ -> 127 | :missing 128 | end 129 | end 130 | 131 | defp type!(types, key) do 132 | case types do 133 | %{^key => type} -> 134 | type 135 | 136 | _ -> 137 | raise ArgumentError, "unknown field `#{key}`." 138 | end 139 | end 140 | 141 | defp cast_key(key) when is_binary(key) do 142 | try do 143 | String.to_existing_atom(key) 144 | rescue 145 | ArgumentError -> 146 | raise ArgumentError, 147 | "could not convert the parameter `#{key}` into an atom, `#{key}` is not a schema field" 148 | end 149 | end 150 | 151 | defp cast_key(key) when is_atom(key), 152 | do: key 153 | end 154 | -------------------------------------------------------------------------------- /test/changeset_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCrdtTypes.ChangesetTest do 2 | use ExUnit.Case, async: true 3 | 4 | import EctoCrdtTypes.Changeset 5 | import Ecto.Changeset 6 | alias EctoCrdtTypes.Types.State.{ 7 | AWSet, 8 | LWWRegister 9 | } 10 | 11 | defmodule Schema do 12 | use Ecto.Schema 13 | use EctoCrdtTypes.Fields 14 | 15 | schema "entities" do 16 | crdt_field :test, AWSet 17 | field :name, :string 18 | end 19 | end 20 | 21 | defmodule LWWRegisterSchema do 22 | use Ecto.Schema 23 | use EctoCrdtTypes.Fields 24 | 25 | schema "lwwregister" do 26 | crdt_field :integer, LWWRegister, value: [type: :integer, default: nil] 27 | crdt_field :integer_ten, LWWRegister, value: [type: :integer, default: 10] 28 | end 29 | end 30 | 31 | describe "lwwregister integer" do 32 | test "#cast_crdt/2 with empty params" do 33 | changeset = %LWWRegisterSchema{} |> cast(%{}, []) |> cast_crdt([:integer]) 34 | assert changeset.changes == %{} 35 | assert changeset.valid? == true 36 | 37 | assert Ecto.Changeset.apply_changes(changeset) 38 | end 39 | 40 | test "#cast_crdt/2 with nil crdt param" do 41 | changeset = 42 | %LWWRegisterSchema{} 43 | |> cast(%{integer_crdt: nil}, []) 44 | |> cast_crdt([:integer]) 45 | 46 | assert changeset.changes == %{} 47 | assert changeset.valid? == true 48 | end 49 | 50 | test "#cast_crdt/2 with empty crdt param" do 51 | changeset = 52 | %LWWRegisterSchema{} 53 | |> cast(%{integer_crdt: ""}, []) 54 | |> cast_crdt([:integer]) 55 | 56 | assert changeset.changes == %{} 57 | assert changeset.valid? == true 58 | end 59 | 60 | test "#cast_crdt/2 with crdt field nil and crdt value is nil in params" do 61 | changeset = 62 | %LWWRegisterSchema{integer_crdt: nil} 63 | |> cast(%{integer_crdt: LWWRegister.new(nil)}, []) 64 | |> cast_crdt([:integer]) 65 | 66 | assert %{integer: value, integer_crdt: crdt} = changeset.changes 67 | assert value == nil 68 | assert {:state_lwwregister, {_ts, nil}} = crdt 69 | assert changeset.valid? == true 70 | end 71 | 72 | test "#cast_crdt/2 with crdt field default and crdt value is nil in params" do 73 | changeset = 74 | %LWWRegisterSchema{integer_crdt: LWWRegister.default()} 75 | |> cast(%{integer_crdt: LWWRegister.new(nil)}, []) 76 | |> cast_crdt([:integer]) 77 | 78 | assert changeset.valid? == true 79 | end 80 | 81 | test "#cast_crdt/2 with crdt field nil and crdt undefined in params" do 82 | changeset = 83 | %LWWRegisterSchema{integer_crdt: nil} 84 | |> cast(%{integer_crdt: LWWRegister.default(:undefined)}, []) 85 | |> cast_crdt([:integer]) 86 | 87 | assert changeset.valid? == true 88 | end 89 | 90 | test "#cast_crdt/2 with crdt field undefined and crdt undefined in params" do 91 | changeset = 92 | %LWWRegisterSchema{integer_crdt: {:state_lwwregister, {0, :undefined}}} 93 | |> cast(%{integer_crdt: LWWRegister.default(:undefined)}, []) 94 | |> cast_crdt([:integer]) 95 | 96 | assert changeset.valid? == true 97 | # this causes lwwregister with old default :undefined migrate to new nil default 98 | assert changeset.changes == %{integer: nil, integer_crdt: {:state_lwwregister, {0, nil}}} 99 | end 100 | 101 | test "#cast_crdt/2 with crdt field undefined and crdt nil in params" do 102 | changeset = 103 | %LWWRegisterSchema{integer_crdt: {:state_lwwregister, {0, :undefined}}} 104 | |> cast(%{integer_crdt: LWWRegister.default(nil)}, []) 105 | |> cast_crdt([:integer]) 106 | 107 | assert changeset.valid? == true 108 | assert changeset.changes == %{ 109 | integer: nil, 110 | integer_crdt: {:state_lwwregister, {0, nil}} 111 | } 112 | end 113 | 114 | test "#cast_crdt/2 with crdt field with nil default and crdt undefined default in params" do 115 | changeset = 116 | %LWWRegisterSchema{integer_crdt: {:state_lwwregister, {0, nil}}} 117 | |> cast(%{integer_crdt: LWWRegister.default(:undefined)}, []) 118 | |> cast_crdt([:integer]) 119 | 120 | assert changeset.valid? == true 121 | assert changeset.changes == %{} 122 | end 123 | end 124 | 125 | test "#cast_crdt/2 with empty params" do 126 | changeset = %Schema{} |> cast(%{}, [:name]) |> cast_crdt([:test]) 127 | assert changeset.changes == %{} 128 | assert changeset.valid? == true 129 | end 130 | 131 | test "#cast_crdt/2 with empty crdt param" do 132 | changeset = %Schema{} |> cast(%{name: "test", test_crdt: ""}, [:name]) |> cast_crdt([:test]) 133 | assert changeset.changes == %{name: "test"} 134 | assert changeset.valid? == true 135 | end 136 | 137 | test "#cast_crdt/2 with nil crdt param" do 138 | changeset = %Schema{} |> cast(%{name: "test", test_crdt: nil}, [:name]) |> cast_crdt([:test]) 139 | assert changeset.changes == %{name: "test"} 140 | assert changeset.valid? == true 141 | end 142 | 143 | test "#cast_crdt/2 merges crdt and sets value" do 144 | crdt_to_merge = AWSet.crdt_type().new() 145 | {:ok, crdt_to_merge} = AWSet.crdt_type().mutate({:add, "a"}, :a, crdt_to_merge) 146 | 147 | expected_test = AWSet.crdt_type().merge(AWSet.crdt_type().new(), crdt_to_merge) 148 | 149 | changeset = %Schema{} |> cast(%{"test_crdt" => crdt_to_merge}, [:name]) |> cast_crdt([:test]) 150 | 151 | assert changeset.changes[:test_crdt] == expected_test 152 | assert changeset.changes[:test] == ["a"] 153 | end 154 | 155 | test "#cast_crdt/2 with atoms as keys in params merges crdt and sets value" do 156 | crdt_to_merge = AWSet.crdt_type().new() 157 | {:ok, crdt_to_merge} = AWSet.crdt_type().mutate({:add, "a"}, :a, crdt_to_merge) 158 | 159 | expected_test = AWSet.crdt_type().merge(AWSet.crdt_type().new(), crdt_to_merge) 160 | 161 | changeset = %Schema{} |> cast(%{test_crdt: crdt_to_merge}, [:name]) |> cast_crdt([:test]) 162 | 163 | assert changeset.changes[:test_crdt] == expected_test 164 | assert changeset.changes[:test] == ["a"] 165 | end 166 | 167 | test "#cast_crdt/2 merges crdt and sets value for existing crdt field" do 168 | crdt1 = AWSet.new(["a"], :a) 169 | crdt1_1 = AWSet.add(crdt1, "c", :a) 170 | crdt2 = AWSet.add(crdt1, "b", :b) 171 | 172 | expected_test = AWSet.crdt_type().merge(crdt1_1, crdt2) 173 | 174 | changeset = 175 | %Schema{test_crdt: crdt1_1, test: ["a", "c"]} 176 | |> cast(%{test_crdt: crdt2}, [:name]) 177 | |> cast_crdt([:test]) 178 | 179 | assert changeset.changes[:test_crdt] == expected_test 180 | assert Enum.sort(changeset.changes[:test]) == Enum.sort(["a", "b", "c"]) 181 | end 182 | 183 | test "#cast_crdt/2 with nil crdt field in schema merges crdt and sets value" do 184 | crdt_to_merge = AWSet.crdt_type().new() 185 | {:ok, crdt_to_merge} = AWSet.crdt_type().mutate({:add, "a"}, :a, crdt_to_merge) 186 | 187 | expected_test = AWSet.crdt_type().merge(AWSet.crdt_type().new(), crdt_to_merge) 188 | 189 | changeset = 190 | %Schema{test_crdt: nil} 191 | |> cast(%{"test_crdt" => crdt_to_merge}, [:name]) 192 | |> cast_crdt([:test]) 193 | 194 | assert changeset.changes[:test_crdt] == expected_test 195 | assert changeset.changes[:test] == ["a"] 196 | end 197 | 198 | test "#cast_crdt/2 with nil crdt field in schema merges undefined crdt" do 199 | changeset = 200 | %Schema{test_crdt: nil} 201 | |> cast(%{"test_crdt" => {:state_lwwregister, {0, :undefined}}}, []) 202 | |> cast_crdt([:test]) 203 | 204 | assert changeset.changes == %{} 205 | end 206 | end 207 | --------------------------------------------------------------------------------