From c634c0582fa3f42c7faa71b3d7f327772cbacf4f Mon Sep 17 00:00:00 2001 From: satoren Date: Sat, 5 Oct 2024 10:55:47 +0900 Subject: [PATCH] feat: implement observe/observe_deep Unlike the original observe/observe_deep, it is not called in the middle of a transaction. It is called in the form of a process message after the transaction is completed. --- lib/nif.ex | 34 ++ lib/protocols/awareness.ex | 5 +- lib/shared_type/array.ex | 25 ++ lib/shared_type/event.ex | 116 ++++++ lib/shared_type/map.ex | 24 +- lib/shared_type/shared_type.ex | 121 +++++++ lib/shared_type/text.ex | 23 ++ lib/shared_type/xml_element.ex | 32 ++ lib/shared_type/xml_fragment.ex | 34 ++ lib/shared_type/xml_text.ex | 25 ++ native/yex/src/array.rs | 30 +- native/yex/src/atoms.rs | 8 +- native/yex/src/event.rs | 481 +++++++++++++++++++++++++ native/yex/src/lib.rs | 1 + native/yex/src/map.rs | 33 +- native/yex/src/subscription.rs | 14 +- native/yex/src/text.rs | 28 ++ native/yex/src/xml.rs | 83 ++++- test/shared_type/array_test.exs | 144 +++++++- test/shared_type/map_test.exs | 131 ++++++- test/shared_type/shared_type_test.exs | 58 +++ test/shared_type/text_test.exs | 111 +++++- test/shared_type/xml_element_test.exs | 101 +++++- test/shared_type/xml_fragment_test.exs | 92 ++++- test/shared_type/xml_text_test.exs | 62 +++- 25 files changed, 1795 insertions(+), 21 deletions(-) create mode 100644 lib/shared_type/event.ex create mode 100644 lib/shared_type/shared_type.ex create mode 100644 native/yex/src/event.rs create mode 100644 test/shared_type/shared_type_test.exs diff --git a/lib/nif.ex b/lib/nif.ex index 2083cce..77fe559 100644 --- a/lib/nif.ex +++ b/lib/nif.ex @@ -40,6 +40,11 @@ defmodule Yex.Nif do def text_to_string(_text, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) def text_length(_text, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def text_observe(_text, _cur_txn, _pid, _ref, _metadata), do: :erlang.nif_error(:nif_not_loaded) + + def text_observe_deep(_text, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def array_insert(_array, _cur_txn, _index, _value), do: :erlang.nif_error(:nif_not_loaded) def array_insert_list(_array, _cur_txn, _index, _values), do: :erlang.nif_error(:nif_not_loaded) def array_length(_array, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) @@ -53,6 +58,12 @@ defmodule Yex.Nif do def array_to_json(_array, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def array_observe(_array, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + + def array_observe_deep(_array, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def map_set(_map, _cur_txn, _key, _value), do: :erlang.nif_error(:nif_not_loaded) def map_size(_map, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) def map_get(_map, _cur_txn, _key), do: :erlang.nif_error(:nif_not_loaded) @@ -60,6 +71,11 @@ defmodule Yex.Nif do def map_to_map(_map, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) def map_to_json(_map, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def map_observe(_map, _cur_txn, _pid, _ref, _metadata), do: :erlang.nif_error(:nif_not_loaded) + + def map_observe_deep(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def xml_fragment_insert(_xml_fragment, _cur_txn, _index, _content), do: :erlang.nif_error(:nif_not_loaded) @@ -73,6 +89,12 @@ defmodule Yex.Nif do def xml_fragment_parent(_xml_fragment, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def xml_fragment_observe(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + + def xml_fragment_observe_deep(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def xml_element_insert(_xml_element, _cur_txn, _index, _content), do: :erlang.nif_error(:nif_not_loaded) @@ -99,6 +121,12 @@ defmodule Yex.Nif do def xml_element_parent(_xml_element, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def xml_element_observe(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + + def xml_element_observe_deep(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def xml_text_insert(_xml_text, _cur_txn, _index, _content), do: :erlang.nif_error(:nif_not_loaded) @@ -121,6 +149,12 @@ defmodule Yex.Nif do def xml_text_parent(_xml_text, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) + def xml_text_observe(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + + def xml_text_observe_deep(_map, _cur_txn, _pid, _ref, _metadata), + do: :erlang.nif_error(:nif_not_loaded) + def encode_state_vector_v1(_doc, _cur_txn), do: :erlang.nif_error(:nif_not_loaded) def encode_state_as_update_v1(_doc, _cur_txn, _diff), do: :erlang.nif_error(:nif_not_loaded) def apply_update_v1(_doc, _cur_txn, _update), do: :erlang.nif_error(:nif_not_loaded) diff --git a/lib/protocols/awareness.ex b/lib/protocols/awareness.ex index ce12a81..c18b8bd 100644 --- a/lib/protocols/awareness.ex +++ b/lib/protocols/awareness.ex @@ -1,10 +1,7 @@ defmodule Yex.Awareness do @moduledoc """ + Awareness is an optional feature that works well together with Yjs. - - ## Examples - iex> doc = Yex.Doc.new() - iex> {:ok, _awareness} = Yex.Awareness.new(doc) """ defstruct [ diff --git a/lib/shared_type/array.ex b/lib/shared_type/array.ex index 54054d2..9f80ec4 100644 --- a/lib/shared_type/array.ex +++ b/lib/shared_type/array.ex @@ -158,6 +158,31 @@ defmodule Yex.Array do Yex.Nif.array_to_json(array, cur_txn(array)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = array, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.array_observe(array, cur_txn(array), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + ref + end + + @doc """ + see `Yex.SharedType.observe_deep/2` + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = array, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.array_observe_deep(array, cur_txn(array), self(), ref, Keyword.get(opt, :metadata)) + + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/lib/shared_type/event.ex b/lib/shared_type/event.ex new file mode 100644 index 0000000..9af4e56 --- /dev/null +++ b/lib/shared_type/event.ex @@ -0,0 +1,116 @@ +defmodule Yex.ArrayEvent do + @moduledoc """ + Event when Array type changes + + @see Yex.Array.observe/1 + @see Yex.Array.observe_deep/1 + @see Yex.Map.observe_deep/1 + """ + defstruct [ + :path, + :target, + :change + ] + + @type t :: %__MODULE__{ + path: list(number() | String.t()), + target: Yex.Array.t(), + change: %{insert: list()} | %{delete: number()} | %{} + } +end + +defmodule Yex.MapEvent do + @moduledoc """ + + Event when Map type changes + + @see Yex.Map.observe/1 + @see Yex.Array.observe_deep/1 + @see Yex.Map.observe_deep/1 + """ + defstruct [ + :path, + :target, + :keys + ] + + @type change :: + %{action: :add, new_value: term()} + | %{action: :delete, old_value: term()} + | %{action: :update, old_value: term(), new_value: term()} + @type keys :: %{String.t() => %{}} + + @type t :: %__MODULE__{ + path: list(number() | String.t()), + target: Yex.Map.t(), + keys: keys + } +end + +defmodule Yex.TextEvent do + @moduledoc """ + + Event when Text type changes + + @see Yex.Text.observe/1 + @see Yex.Array.observe_deep/1 + @see Yex.Map.observe_deep/1 + """ + defstruct [ + :path, + :target, + :delta + ] + + @type t :: %__MODULE__{ + path: list(number() | String.t()), + target: Yex.Map.t(), + delta: Yex.Text.delta() + } +end + +defmodule Yex.XmlEvent do + @moduledoc """ + + Event when XMLFragment/Element type changes + + @see Yex.Text.observe/1 + @see Yex.Array.observe_deep/1 + @see Yex.Map.observe_deep/1 + """ + defstruct [ + :path, + :target, + :delta, + :keys + ] + + @type t :: %__MODULE__{ + path: list(number() | String.t()), + target: Yex.Map.t(), + delta: Yex.Text.delta(), + keys: %{insert: list()} | %{delete: number()} | %{} + } +end + +defmodule Yex.XmlTextEvent do + @moduledoc """ + + Event when Text type changes + + @see Yex.Text.observe/1 + @see Yex.Array.observe_deep/1 + @see Yex.Map.observe_deep/1 + """ + defstruct [ + :path, + :target, + :delta + ] + + @type t :: %__MODULE__{ + path: list(number() | String.t()), + target: Yex.Map.t(), + delta: Yex.Text.delta() + } +end diff --git a/lib/shared_type/map.ex b/lib/shared_type/map.ex index 7399701..2f03773 100644 --- a/lib/shared_type/map.ex +++ b/lib/shared_type/map.ex @@ -41,7 +41,7 @@ defmodule Yex.Map do @doc """ get a key from the map. - ## Examples + ## Examples iex> doc = Yex.Doc.new() iex> map = Yex.Doc.get_map(doc, "map") iex> Yex.Map.set(map, "plane", ["Hello", "World"]) @@ -115,6 +115,28 @@ defmodule Yex.Map do Yex.Nif.map_to_json(map, cur_txn(map)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = map, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.map_observe(map, cur_txn(map), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + ref + end + + @doc """ + see `Yex.SharedType.observe_deep/2` + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = map, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.map_observe_deep(map, cur_txn(map), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/lib/shared_type/shared_type.ex b/lib/shared_type/shared_type.ex new file mode 100644 index 0000000..b2fc32b --- /dev/null +++ b/lib/shared_type/shared_type.ex @@ -0,0 +1,121 @@ +defprotocol Yex.SharedType do + @moduledoc """ + The SharedType protocol defines the behavior of shared types in Yex. + + """ + + @doc """ + Registers a change observer that will be message every time this shared type is modified. + + If the shared type changes, a message is delivered to the + monitoring process in the shape of: + {:observe_event, ref, event, origin, metadata} + + where: + * `ref` is a monitor reference returned by this function; + * `event` is a struct that describes the change; + * `origin` is the origin passed to the `Yex.Doc.transaction()` function. + * `metadata` is the metadata passed to the `observe_deep` function. + + + """ + @spec observe(t) :: reference() + def observe(shared_type) + + @doc """ + Registers a change observer that will be message every time this shared type is modified. + + see `observe/1` for more information. + + ## Options + * `:metadata` - provides metadata to be attached to this observe. + + """ + @spec observe(t, keyword()) :: reference() + def observe(shared_type, opt) + + @doc """ + Unobserve the shared type for changes. + + """ + @spec unobserve(reference()) :: :ok + def unobserve(observe_ref) + + @doc """ + Registers a change observer that will be message every time this shared type or any of its children is modified. + + If the shared type changes, a message is delivered to the + monitoring process in the shape of: + {:observe_deep_event, ref, events, origin, metadata} + + + where: + * `ref` is a monitor reference returned by this function; + * `events` is a array of event struct that describes the change; + * `origin` is the origin passed to the `Yex.Doc.transaction()` function. + * `metadata` is the metadata passed to the `observe_deep` function. + """ + @spec observe_deep(t) :: reference() + def observe_deep(shared_type) + + @doc """ + Registers a change observer that will be message every time this shared type or any of its children is modified. + + see `observe_deep/1` for more information. + + ## Options + * `:metadata` - provides metadata to be attached to this observe. + + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(shared_type, opt) + + @doc """ + Unobserve the shared type for changes. + + """ + @spec unobserve_deep(reference()) :: :ok + def unobserve_deep(observe_ref) +end + +defimpl Yex.SharedType, + for: [Yex.Array, Yex.Map, Yex.Text, Yex.XmlElement, Yex.XmlFragment, Yex.XmlText] do + defdelegate observe(shared_type), to: @for + defdelegate observe(shared_type, option), to: @for + defdelegate observe_deep(shared_type), to: @for + defdelegate observe_deep(shared_type, option), to: @for + + def unobserve(reference), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) + + def unobserve_deep(reference), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) +end + +defimpl Yex.SharedType, for: Reference do + def observe(reference), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) + + def observe(reference, _option), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) + + def observe_deep(reference), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) + + def observe_deep(reference, _option), + do: raise(Protocol.UndefinedError, protocol: @protocol, value: reference) + + def unobserve(reference), do: unsubscribe(reference) + def unobserve_deep(reference), do: unsubscribe(reference) + + defp unsubscribe(ref) do + case Process.get(ref) do + nil -> + :ok + + sub -> + Process.delete(ref) + Yex.Nif.sub_unsubscribe(sub) + end + end +end diff --git a/lib/shared_type/text.ex b/lib/shared_type/text.ex index 5389976..3b807f0 100644 --- a/lib/shared_type/text.ex +++ b/lib/shared_type/text.ex @@ -80,6 +80,29 @@ defmodule Yex.Text do Yex.Nif.text_to_delta(text, cur_txn(text)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = text, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.text_observe(text, cur_txn(text), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + + ref + end + + @doc """ + see `Yex.SharedType.observe_deep/2` + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = text, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.text_observe_deep(text, cur_txn(text), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/lib/shared_type/xml_element.ex b/lib/shared_type/xml_element.ex index cc1095e..7bce258 100644 --- a/lib/shared_type/xml_element.ex +++ b/lib/shared_type/xml_element.ex @@ -141,6 +141,38 @@ defmodule Yex.XmlElement do Yex.Nif.xml_element_to_string(xml_element, cur_txn(xml_element)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.xml_element_observe(xml, cur_txn(xml), self(), ref, Keyword.get(opt, :metadata)) + + Process.put(ref, sub) + ref + end + + @doc false + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.xml_element_observe_deep( + xml, + cur_txn(xml), + self(), + ref, + Keyword.get(opt, :metadata) + ) + + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/lib/shared_type/xml_fragment.ex b/lib/shared_type/xml_fragment.ex index df0a001..4f256b1 100644 --- a/lib/shared_type/xml_fragment.ex +++ b/lib/shared_type/xml_fragment.ex @@ -99,6 +99,40 @@ defmodule Yex.XmlFragment do Yex.Nif.xml_fragment_to_string(xml_fragment, cur_txn(xml_fragment)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.xml_fragment_observe(xml, cur_txn(xml), self(), ref, Keyword.get(opt, :metadata)) + + Process.put(ref, sub) + ref + end + + @doc """ + see `Yex.SharedType.observe_deep/2` + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.xml_fragment_observe_deep( + xml, + cur_txn(xml), + self(), + ref, + Keyword.get(opt, :metadata) + ) + + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/lib/shared_type/xml_text.ex b/lib/shared_type/xml_text.ex index c965e7a..14fb8ef 100644 --- a/lib/shared_type/xml_text.ex +++ b/lib/shared_type/xml_text.ex @@ -82,6 +82,31 @@ defmodule Yex.XmlText do Yex.Nif.xml_text_parent(xml_text, cur_txn(xml_text)) end + @doc """ + see `Yex.SharedType.observe/2` + """ + @spec observe(t, keyword()) :: reference() + def observe(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + sub = Yex.Nif.xml_text_observe(xml, cur_txn(xml), self(), ref, Keyword.get(opt, :metadata)) + Process.put(ref, sub) + ref + end + + @doc """ + see `Yex.SharedType.observe_deep/2` + """ + @spec observe_deep(t, keyword()) :: reference() + def observe_deep(%__MODULE__{} = xml, opt \\ []) do + ref = make_ref() + + sub = + Yex.Nif.xml_text_observe_deep(xml, cur_txn(xml), self(), ref, Keyword.get(opt, :metadata)) + + Process.put(ref, sub) + ref + end + defp cur_txn(%__MODULE__{doc: doc_ref}) do Process.get(doc_ref, nil) end diff --git a/native/yex/src/array.rs b/native/yex/src/array.rs index 25c33e7..9f83713 100644 --- a/native/yex/src/array.rs +++ b/native/yex/src/array.rs @@ -1,11 +1,13 @@ -use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc}; +use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc, Term}; use yrs::types::ToJson; use yrs::*; use crate::{ atoms, doc::{DocResource, TransactionResource}, + event::{NifArrayEvent, NifSharedTypeDeepObservable, NifSharedTypeObservable}, shared_type::{NifSharedType, SharedTypeId}, + subscription::SubscriptionResource, yinput::NifYInput, youtput::NifYOut, NifAny, @@ -39,6 +41,10 @@ impl NifSharedType for NifArray { } const DELETED_ERROR: &'static str = "Array has been deleted"; } +impl NifSharedTypeDeepObservable for NifArray {} +impl NifSharedTypeObservable for NifArray { + type Event = NifArrayEvent; +} #[rustler::nif] fn array_insert( @@ -152,3 +158,25 @@ fn array_to_json( Ok(array.to_json(txn).into()) }) } + +#[rustler::nif] +fn array_observe( + array: NifArray, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + array.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn array_observe_deep( + array: NifArray, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + array.observe_deep(current_transaction, pid, ref_term, metadata) +} diff --git a/native/yex/src/atoms.rs b/native/yex/src/atoms.rs index 1a20b2a..bac8360 100644 --- a/native/yex/src/atoms.rs +++ b/native/yex/src/atoms.rs @@ -8,7 +8,8 @@ rustler::atoms! { update_v1, update_v2, - + observe_event, + observe_deep_event, // messages types sync, @@ -24,6 +25,11 @@ rustler::atoms! { awareness_update, awareness_change, + action, + old_value, + new_value, + add, + update, insert, delete, retain, diff --git a/native/yex/src/event.rs b/native/yex/src/event.rs new file mode 100644 index 0000000..a08f78e --- /dev/null +++ b/native/yex/src/event.rs @@ -0,0 +1,481 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use rustler::{Encoder, Env, NifResult, NifStruct, NifUntaggedEnum, ResourceArc, Term}; +use yrs::{ + types::{ + array::ArrayEvent, + map::MapEvent, + text::TextEvent, + xml::{XmlEvent, XmlTextEvent}, + Change, Delta, EntryChange, + }, + DeepObservable, Observable, TransactionMut, +}; + +use crate::{ + any::NifAny, + array::NifArray, + atoms, + doc::{DocResource, TransactionResource}, + map::NifMap, + shared_type::NifSharedType, + subscription::SubscriptionResource, + term_box::TermBox, + text::NifText, + utils::origin_to_term, + wrap::NifWrap, + xml::NifXmlText, + youtput::NifYOut, + ENV, +}; + +#[derive(NifUntaggedEnum)] +pub enum PathSegment { + /// Key segments are used to inform how to access child shared collections within a [Map] types. + Key(String), + + /// Index segments are used to inform how to access child shared collections within an [Array] + /// or [XmlElement] types. + Index(u32), +} + +impl From for PathSegment { + #[inline] + fn from(value: yrs::types::PathSegment) -> Self { + match value { + yrs::types::PathSegment::Key(key) => PathSegment::Key(key.to_string()), + yrs::types::PathSegment::Index(index) => PathSegment::Index(index), + } + } +} + +type NifPath = NifWrap; + +impl rustler::Encoder for NifPath { + fn encode<'a>(&self, env: Env<'a>) -> Term<'a> { + let segments: Vec = self + .0 + .iter() + .map(|segment| match segment { + yrs::types::PathSegment::Key(key) => key.encode(env), + yrs::types::PathSegment::Index(index) => index.encode(env), + }) + .collect(); + segments.encode(env) + } +} + +impl<'a> rustler::Decoder<'a> for NifPath { + fn decode(term: Term<'a>) -> rustler::NifResult { + let segments: Vec = term.decode()?; + let path = segments + .iter() + .map(|segment| { + if let Ok(key) = segment.decode::<&str>() { + Ok(yrs::types::PathSegment::Key(key.into())) + } else if let Ok(index) = segment.decode::() { + Ok(yrs::types::PathSegment::Index(index)) + } else { + Err(rustler::Error::BadArg) + } + }) + .collect::, rustler::Error>>()?; + Ok(NifWrap(yrs::types::Path::from(path))) + } +} + +pub struct NifYArrayChange { + doc: ResourceArc, + change: Vec, +} + +impl rustler::Encoder for NifYArrayChange { + fn encode<'a>(&self, env: Env<'a>) -> Term<'a> { + let v: Vec> = self + .change + .clone() + .into_iter() + .map(|change| match change { + Change::Added(content) => { + let content: Vec> = content + .into_iter() + .map(|item| NifYOut::from_native(item, self.doc.clone()).encode(env)) + .collect(); + + let mut map = Term::map_new(env); + map = map.map_put(atoms::insert(), content).unwrap(); + map + } + Change::Removed(index) => { + let mut map = Term::map_new(env); + map = map.map_put(atoms::delete(), index).unwrap(); + map + } + Change::Retain(index) => { + let mut map = Term::map_new(env); + map = map.map_put(atoms::retain(), index).unwrap(); + map + } + }) + .collect(); + v.encode(env) + } +} + +impl<'a> rustler::Decoder<'a> for NifYArrayChange { + fn decode(_term: Term<'a>) -> rustler::NifResult { + unimplemented!() + } +} + +pub trait NifEventConstructor +where + Self: Sized + Encoder, +{ + fn new(doc: &ResourceArc, event: &Event, txn: &TransactionMut<'_>) -> Self; +} + +#[derive(NifStruct)] +#[module = "Yex.ArrayEvent"] +pub struct NifArrayEvent { + pub path: NifPath, + pub target: NifArray, + pub change: NifYArrayChange, +} + +impl NifEventConstructor for NifArrayEvent { + fn new(doc: &ResourceArc, event: &ArrayEvent, txn: &TransactionMut<'_>) -> Self { + NifArrayEvent { + path: event.path().into(), + target: NifArray::new(doc.clone(), event.target().clone()), + change: NifYArrayChange { + doc: doc.clone(), + change: event.delta(txn).to_vec(), + }, + } + } +} + +pub struct NifYTextDelta { + doc: ResourceArc, + delta: Vec, +} + +impl rustler::Encoder for NifYTextDelta { + fn encode<'a>(&self, env: Env<'a>) -> Term<'a> { + let v: Vec> = self + .delta + .clone() + .into_iter() + .map(|change| match change { + Delta::Inserted(content, attr) => { + let insert = NifYOut::from_native(content, self.doc.clone()); + + let mut map = Term::map_new(env).map_put(atoms::insert(), insert).unwrap(); + if let Some(attr) = attr { + let attribute = attr + .iter() + .map(|(k, v)| (k.to_string(), NifAny::from(v.clone()))) + .collect::>(); + map = map.map_put(atoms::attributes(), attribute).unwrap(); + } + map + } + Delta::Deleted(index) => { + let mut map = Term::map_new(env); + map = map.map_put(atoms::delete(), index).unwrap(); + map + } + Delta::Retain(index, attr) => { + let mut map = Term::map_new(env); + map = map.map_put(atoms::retain(), index).unwrap(); + if let Some(attr) = attr { + let attribute = attr + .iter() + .map(|(k, v)| (k.to_string(), NifAny::from(v.clone()))) + .collect::>(); + map = map.map_put(atoms::attributes(), attribute).unwrap(); + } + map + } + }) + .collect(); + v.encode(env) + } +} + +impl<'a> rustler::Decoder<'a> for NifYTextDelta { + fn decode(_term: Term<'a>) -> rustler::NifResult { + unimplemented!() + } +} + +#[derive(NifStruct)] +#[module = "Yex.TextEvent"] +pub struct NifTextEvent { + pub path: NifPath, + pub target: NifText, + pub delta: NifYTextDelta, +} + +impl NifEventConstructor for NifTextEvent { + fn new(doc: &ResourceArc, event: &TextEvent, txn: &TransactionMut<'_>) -> Self { + NifTextEvent { + path: event.path().into(), + target: NifText::new(doc.clone(), event.target().clone()), + delta: NifYTextDelta { + doc: doc.clone(), + delta: event.delta(txn).to_vec(), + }, + } + } +} + +pub struct NifYMapChange { + doc: ResourceArc, + change: HashMap, EntryChange>, +} + +impl rustler::Encoder for NifYMapChange { + fn encode<'a>(&self, env: Env<'a>) -> Term<'a> { + let v: HashMap = self + .change + .clone() + .into_iter() + .map(|(key, change)| match change { + EntryChange::Inserted(content) => { + let content = NifYOut::from_native(content, self.doc.clone()); + let map = Term::map_new(env) + .map_put(atoms::action(), atoms::add()) + .unwrap() + .map_put(atoms::new_value(), content) + .unwrap(); + (key.to_string(), map) + } + EntryChange::Removed(old_value) => { + let old_value = NifYOut::from_native(old_value, self.doc.clone()); + let map = Term::map_new(env) + .map_put(atoms::action(), atoms::delete()) + .unwrap() + .map_put(atoms::old_value(), old_value) + .unwrap(); + (key.to_string(), map) + } + EntryChange::Updated(old_value, new_value) => { + let old_value = NifYOut::from_native(old_value, self.doc.clone()); + let new_value = NifYOut::from_native(new_value, self.doc.clone()); + let map = Term::map_new(env) + .map_put(atoms::action(), atoms::update()) + .unwrap() + .map_put(atoms::old_value(), old_value) + .unwrap() + .map_put(atoms::new_value(), new_value) + .unwrap(); + (key.to_string(), map) + } + }) + .collect(); + v.encode(env) + } +} + +impl<'a> rustler::Decoder<'a> for NifYMapChange { + fn decode(_term: Term<'a>) -> rustler::NifResult { + unimplemented!() + } +} + +#[derive(NifStruct)] +#[module = "Yex.MapEvent"] +pub struct NifMapEvent { + pub path: NifPath, + pub target: NifMap, + pub keys: NifYMapChange, +} + +impl NifEventConstructor for NifMapEvent { + fn new(doc: &ResourceArc, event: &MapEvent, txn: &TransactionMut<'_>) -> Self { + NifMapEvent { + path: event.path().into(), + target: NifMap::new(doc.clone(), event.target().clone()), + keys: NifYMapChange { + doc: doc.clone(), + change: event.keys(txn).clone(), + }, + } + } +} + +#[derive(NifStruct)] +#[module = "Yex.XmlEvent"] +pub struct NifXmlEvent { + pub path: NifPath, + pub target: NifYOut, // XmlFragment or XmlText or XmlElement + + pub delta: NifYArrayChange, + + pub keys: NifYMapChange, +} + +impl NifEventConstructor for NifXmlEvent { + fn new(doc: &ResourceArc, event: &XmlEvent, txn: &TransactionMut<'_>) -> Self { + NifXmlEvent { + path: event.path().into(), + target: NifYOut::from_xml_out(event.target().clone(), doc.clone()), + keys: NifYMapChange { + doc: doc.clone(), + change: event.keys(txn).clone(), + }, + delta: NifYArrayChange { + doc: doc.clone(), + change: event.delta(txn).to_vec(), + }, + } + } +} + +#[derive(NifStruct)] +#[module = "Yex.XmlTextEvent"] +pub struct NifXmlTextEvent { + pub path: NifPath, + pub target: NifXmlText, + pub delta: NifYTextDelta, +} + +impl NifEventConstructor for NifXmlTextEvent { + fn new(doc: &ResourceArc, event: &XmlTextEvent, txn: &TransactionMut<'_>) -> Self { + NifXmlTextEvent { + path: event.path().into(), + target: NifXmlText::new(doc.clone(), event.target().clone()), + delta: NifYTextDelta { + doc: doc.clone(), + delta: event.delta(txn).to_vec(), + }, + } + } +} + +#[derive(NifUntaggedEnum)] +pub enum NifEvent { + Text(NifTextEvent), + Array(NifArrayEvent), + Map(NifMapEvent), + XmlFragment(NifXmlEvent), + XmlText(NifXmlTextEvent), +} + +impl NifEvent { + pub fn new( + doc: ResourceArc, + event: &yrs::types::Event, + txn: &TransactionMut<'_>, + ) -> Self { + match event { + yrs::types::Event::Text(event) => NifEvent::Text(NifTextEvent::new(&doc, &event, txn)), + yrs::types::Event::Array(event) => { + NifEvent::Array(NifArrayEvent::new(&doc, &event, txn)) + } + yrs::types::Event::Map(event) => NifEvent::Map(NifMapEvent::new(&doc, &event, txn)), + yrs::types::Event::XmlFragment(event) => { + NifEvent::XmlFragment(NifXmlEvent::new(&doc, &event, txn)) + } + yrs::types::Event::XmlText(event) => { + NifEvent::XmlText(NifXmlTextEvent::new(&doc, &event, txn)) + } + } + } +} + +pub trait NifSharedTypeDeepObservable +where + Self: NifSharedType, + Self::RefType: DeepObservable, +{ + fn observe_deep( + &self, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, + ) -> NifResult> { + let doc = self.doc(); + + let ref_box = TermBox::new(ref_term); + let metadata_box = TermBox::new(metadata); + + doc.readonly(current_transaction, |txn| { + let ref_value = self.get_ref(txn)?; + + let doc_ref = doc.clone(); + let sub = ref_value.observe_deep(move |txn, events| { + let doc_ref = doc_ref.clone(); + ENV.with(|env| { + let events: Vec = events + .iter() + .map(|event| NifEvent::new(doc_ref.clone(), event, txn)) + .collect(); + let _ = env.send( + &pid, + ( + atoms::observe_deep_event(), + ref_box.get(*env), + events, + origin_to_term(env, txn.origin()), + metadata_box.get(*env), + ), + ); + }) + }); + + Ok(ResourceArc::new(Mutex::new(Some(sub)).into())) + }) + } +} + +pub trait NifSharedTypeObservable +where + Self: NifSharedType, + Self::RefType: Observable, + yrs::types::Event: AsRef<::Event>, + Self::Event: NifEventConstructor<<::RefType as Observable>::Event>, +{ + type Event; + fn observe( + &self, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, + ) -> NifResult> { + let doc = self.doc(); + + let ref_box = TermBox::new(ref_term); + let metadata_box = TermBox::new(metadata); + + doc.readonly(current_transaction, |txn| { + let ref_value = self.get_ref(txn)?; + + let doc_ref: ResourceArc> = doc.clone(); + let sub = ref_value.observe(move |txn, event| { + let doc_ref = doc_ref.clone(); + ENV.with(|env| { + let _ = env.send( + &pid, + ( + atoms::observe_event(), + ref_box.get(*env), + Self::Event::new(&doc_ref, event, txn), + origin_to_term(env, txn.origin()), + metadata_box.get(*env), + ), + ); + }) + }); + + Ok(SubscriptionResource::arc(sub)) + }) + } +} diff --git a/native/yex/src/lib.rs b/native/yex/src/lib.rs index dcc1c8c..8aa11b6 100644 --- a/native/yex/src/lib.rs +++ b/native/yex/src/lib.rs @@ -4,6 +4,7 @@ mod atoms; mod awareness; mod doc; mod error; +mod event; mod map; mod shared_type; mod subscription; diff --git a/native/yex/src/map.rs b/native/yex/src/map.rs index 597ba21..fd08501 100644 --- a/native/yex/src/map.rs +++ b/native/yex/src/map.rs @@ -1,8 +1,11 @@ use crate::atoms; use crate::doc::TransactionResource; -use crate::shared_type::{NifSharedType, SharedTypeId}; +use crate::event::{NifMapEvent, NifSharedTypeDeepObservable, NifSharedTypeObservable}; +use crate::shared_type::NifSharedType; +use crate::shared_type::SharedTypeId; +use crate::subscription::SubscriptionResource; use crate::{doc::DocResource, yinput::NifYInput, youtput::NifYOut, NifAny}; -use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc}; +use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc, Term}; use std::collections::HashMap; use yrs::types::ToJson; use yrs::*; @@ -34,6 +37,10 @@ impl NifSharedType for NifMap { } const DELETED_ERROR: &'static str = "Map has been deleted"; } +impl NifSharedTypeDeepObservable for NifMap {} +impl NifSharedTypeObservable for NifMap { + type Event = NifMapEvent; +} #[rustler::nif] fn map_set( @@ -110,3 +117,25 @@ fn map_to_json( Ok(map.to_json(txn).into()) }) } + +#[rustler::nif] +fn map_observe( + map: NifMap, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + map.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn map_observe_deep( + map: NifMap, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + map.observe_deep(current_transaction, pid, ref_term, metadata) +} diff --git a/native/yex/src/subscription.rs b/native/yex/src/subscription.rs index a64de23..f812229 100644 --- a/native/yex/src/subscription.rs +++ b/native/yex/src/subscription.rs @@ -1,21 +1,27 @@ -use rustler::{Env, ResourceArc}; +use rustler::{Atom, Env, NifResult, ResourceArc}; use std::sync::Mutex; use yrs::*; -use crate::{error::NifError, wrap::NifWrap, ENV}; +use crate::{atoms, wrap::NifWrap, ENV}; pub type SubscriptionResource = NifWrap>>; #[rustler::resource_impl] impl rustler::Resource for SubscriptionResource {} +impl SubscriptionResource { + pub fn arc(sub: Subscription) -> ResourceArc { + ResourceArc::new(NifWrap(Mutex::new(Some(sub)))) + } +} + #[rustler::nif] -fn sub_unsubscribe(env: Env<'_>, sub: ResourceArc) -> Result<(), NifError> { +fn sub_unsubscribe(env: Env<'_>, sub: ResourceArc) -> NifResult { ENV.set(&mut env.clone(), || { let mut inner = match sub.0.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; *inner = None; - Ok(()) + Ok(atoms::ok()) }) } diff --git a/native/yex/src/text.rs b/native/yex/src/text.rs index 12ec804..f826942 100644 --- a/native/yex/src/text.rs +++ b/native/yex/src/text.rs @@ -8,7 +8,9 @@ use crate::{ any::NifAttr, atoms, doc::{DocResource, TransactionResource}, + event::{NifSharedTypeDeepObservable, NifSharedTypeObservable, NifTextEvent}, shared_type::{NifSharedType, SharedTypeId}, + subscription::SubscriptionResource, yinput::NifYInputDelta, youtput::NifYOut, }; @@ -42,6 +44,10 @@ impl NifSharedType for NifText { const DELETED_ERROR: &'static str = "Text has been deleted"; } +impl NifSharedTypeDeepObservable for NifText {} +impl NifSharedTypeObservable for NifText { + type Event = NifTextEvent; +} #[rustler::nif] fn text_insert( @@ -205,3 +211,25 @@ pub fn encode_diff<'a>( } Ok(map) } + +#[rustler::nif] +fn text_observe( + text: NifText, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + text.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn text_observe_deep( + text: NifText, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + text.observe_deep(current_transaction, pid, ref_term, metadata) +} diff --git a/native/yex/src/xml.rs b/native/yex/src/xml.rs index 17f5c45..7899b14 100644 --- a/native/yex/src/xml.rs +++ b/native/yex/src/xml.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc}; +use rustler::{Atom, Env, NifResult, NifStruct, ResourceArc, Term}; use types::text::YChange; use yrs::*; @@ -8,7 +8,9 @@ use crate::{ any::NifAttr, atoms, doc::{DocResource, TransactionResource}, + event::{NifSharedTypeDeepObservable, NifSharedTypeObservable, NifXmlEvent, NifXmlTextEvent}, shared_type::{NifSharedType, SharedTypeId}, + subscription::SubscriptionResource, text::encode_diffs, yinput::{NifXmlIn, NifYInputDelta}, youtput::NifYOut, @@ -47,6 +49,10 @@ impl NifSharedType for NifXmlFragment { const DELETED_ERROR: &'static str = "XmlFragment has been deleted"; } +impl NifSharedTypeDeepObservable for NifXmlFragment {} +impl NifSharedTypeObservable for NifXmlFragment { + type Event = NifXmlEvent; +} #[derive(NifStruct)] #[module = "Yex.XmlElement"] @@ -63,6 +69,10 @@ impl NifXmlElement { } } } +impl NifSharedTypeDeepObservable for NifXmlElement {} +impl NifSharedTypeObservable for NifXmlElement { + type Event = NifXmlEvent; +} impl NifSharedType for NifXmlElement { type RefType = XmlElementRef; @@ -92,6 +102,7 @@ impl NifXmlText { } } } + impl NifSharedType for NifXmlText { type RefType = XmlTextRef; @@ -105,6 +116,11 @@ impl NifSharedType for NifXmlText { const DELETED_ERROR: &'static str = "XmlText has been deleted"; } +impl NifSharedTypeDeepObservable for NifXmlText {} +impl NifSharedTypeObservable for NifXmlText { + type Event = NifXmlTextEvent; +} + #[rustler::nif] fn xml_fragment_insert( env: Env<'_>, @@ -184,6 +200,27 @@ fn xml_fragment_parent( Ok(xml.parent().map(|b| NifYOut::from_xml_out(b, doc.clone()))) }) } +#[rustler::nif] +fn xml_fragment_observe( + xml: NifXmlFragment, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn xml_fragment_observe_deep( + xml: NifXmlFragment, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe_deep(current_transaction, pid, ref_term, metadata) +} #[rustler::nif] fn xml_element_insert( @@ -351,6 +388,27 @@ fn xml_element_parent( Ok(xml.parent().map(|b| NifYOut::from_xml_out(b, doc.clone()))) }) } +#[rustler::nif] +fn xml_element_observe( + xml: NifXmlElement, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn xml_element_observe_deep( + xml: NifXmlElement, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe_deep(current_transaction, pid, ref_term, metadata) +} #[rustler::nif] fn xml_text_insert( @@ -500,7 +558,28 @@ fn xml_text_parent( let doc = xml.doc(); xml.readonly(current_transaction, |txn| { let xml = xml.get_ref(txn)?; - Ok(xml.parent().map(|b| NifYOut::from_xml_out(b, doc.clone()))) }) } + +#[rustler::nif] +fn xml_text_observe( + xml: NifXmlText, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe(current_transaction, pid, ref_term, metadata) +} + +#[rustler::nif] +fn xml_text_observe_deep( + xml: NifXmlText, + current_transaction: Option>, + pid: rustler::LocalPid, + ref_term: Term<'_>, + metadata: Term<'_>, +) -> NifResult> { + xml.observe_deep(current_transaction, pid, ref_term, metadata) +} diff --git a/test/shared_type/array_test.exs b/test/shared_type/array_test.exs index f680c02..d2b29ba 100644 --- a/test/shared_type/array_test.exs +++ b/test/shared_type/array_test.exs @@ -1,6 +1,6 @@ defmodule Yex.ArrayTest do use ExUnit.Case - alias Yex.{Doc, Array, ArrayPrelim} + alias Yex.{Doc, Array, ArrayPrelim, SharedType} doctest Array doctest ArrayPrelim @@ -207,4 +207,146 @@ defmodule Yex.ArrayTest do :error = Yex.Array.move_to(array, 0, 5) :error = Yex.Array.move_to(array, 3, 0) end + + describe "observe" do + test "insert " do + doc = Doc.new() + + array = Doc.get_array(doc, "text") + + ref = SharedType.observe(array) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Array.insert(array, 0, "Hello") + Array.insert(array, 1, " World") + end) + + assert_receive {:observe_event, ^ref, + %Yex.ArrayEvent{ + change: [%{insert: ["Hello", " World"]}] + }, "origin_value", nil} + end + + test "delete " do + doc = Doc.new() + + array = Doc.get_array(doc, "text") + Array.insert(array, 0, "Hello") + Array.insert(array, 1, " World") + + ref = SharedType.observe(array) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Array.delete(array, 0) + end) + + assert_receive {:observe_event, ^ref, + %Yex.ArrayEvent{ + change: [%{delete: 1}] + }, "origin_value", nil} + end + + test "retain and insert" do + doc = Doc.new() + + array = Doc.get_array(doc, "text") + Array.insert(array, 0, "Hello") + + ref = SharedType.observe(array) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Array.insert(array, 1, " World") + end) + + assert_receive {:observe_event, ^ref, + %Yex.ArrayEvent{ + change: [%{retain: 1}, %{insert: [" World"]}] + }, "origin_value", nil} + end + + test "unobserve" do + doc = Doc.new() + + array = Doc.get_array(doc, "text") + + ref = SharedType.observe(array) + assert :ok = SharedType.unobserve(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Array.insert(array, 0, "Hello") + end) + + refute_receive {:observe_event, _, %Yex.ArrayEvent{}, _} + + # noop but return ok + assert :ok = SharedType.unobserve(make_ref()) + end + end + + test "observe_deep" do + doc = Doc.new() + array = Doc.get_array(doc, "data") + + Array.insert( + array, + 0, + Yex.MapPrelim.from(%{ + "key" => Yex.MapPrelim.from(%{"key" => ArrayPrelim.from([1, 2, 3, 4])}) + }) + ) + + ref = SharedType.observe_deep(array) + + map = Yex.Array.fetch!(array, 0) + child_map = Yex.Map.fetch!(map, "key") + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Yex.Array.push(array, "array_value") + Yex.Map.set(child_map, "key2", "value") + Yex.Map.set(map, "key2", "value") + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.ArrayEvent{ + path: [], + target: ^array, + change: [%{retain: 1}, %{insert: ["array_value"]}] + }, + %Yex.MapEvent{ + path: [0], + target: ^map, + keys: %{"key2" => %{action: :add, new_value: "value"}} + }, + %Yex.MapEvent{ + path: [0, "key"], + target: ^child_map, + keys: %{"key2" => %{action: :add, new_value: "value"}} + } + ], "origin_value", nil} + end + + test "unobserve_deep" do + doc = Doc.new() + + array = Doc.get_array(doc, "text") + + ref = SharedType.observe_deep(array) + assert :ok = SharedType.unobserve_deep(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Array.insert(array, 0, "Hello") + end) + + refute_receive {:observe_deep_event, _, %Yex.ArrayEvent{}, _, _} + + # noop but return ok + assert :ok = SharedType.unobserve_deep(make_ref()) + end end diff --git a/test/shared_type/map_test.exs b/test/shared_type/map_test.exs index ea8d4e1..90c9a95 100644 --- a/test/shared_type/map_test.exs +++ b/test/shared_type/map_test.exs @@ -1,6 +1,6 @@ defmodule Yex.MapTest do use ExUnit.Case - alias Yex.{Doc, Map, MapPrelim} + alias Yex.{Doc, Map, MapPrelim, SharedType} doctest Map doctest MapPrelim @@ -111,4 +111,133 @@ defmodule Yex.MapTest do assert 2 == Map.size(map) end + + describe "observe" do + test "set " do + doc = Doc.new() + + map = Doc.get_map(doc, "map") + + ref = SharedType.observe(map) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Map.set(map, "0", "Hello") + Map.set(map, "1", " World") + end) + + assert_receive {:observe_event, ^ref, + %Yex.MapEvent{ + target: ^map, + keys: %{ + "0" => %{action: :add, new_value: "Hello"}, + "1" => %{action: :add, new_value: " World"} + } + }, "origin_value", nil} + end + + test "delete " do + doc = Doc.new() + + map = Doc.get_map(doc, "map") + Map.set(map, "0", "Hello") + Map.set(map, "1", " World") + + ref = SharedType.observe(map) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Map.delete(map, "0") + end) + + assert_receive {:observe_event, ^ref, + %Yex.MapEvent{ + target: ^map, + keys: %{"0" => %{action: :delete, old_value: "Hello"}} + }, "origin_value", nil} + end + + test "unobserve" do + doc = Doc.new() + + map = Doc.get_map(doc, "text") + + ref = SharedType.observe(map) + assert :ok = SharedType.unobserve(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Map.set(map, "0", "Hello") + end) + + refute_receive {:observe_event, _, %Yex.MapEvent{}, _} + + # noop but return ok + assert :ok = SharedType.unobserve(make_ref()) + end + end + + test "observe_deep" do + doc = Doc.new() + map = Doc.get_map(doc, "data") + + Map.set( + map, + "key1", + Yex.MapPrelim.from(%{ + "key2" => Yex.MapPrelim.from(%{"key3" => Yex.ArrayPrelim.from([1, 2, 3, 4])}) + }) + ) + + ref = SharedType.observe_deep(map) + + child_map = Yex.Map.fetch!(map, "key1") + child_map2 = Yex.Map.fetch!(child_map, "key2") + child_array = Yex.Map.fetch!(child_map2, "key3") + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Yex.Array.push(child_array, 5) + Yex.Map.set(child_map, "set1", "value1") + Yex.Map.set(map, "set2", "value2") + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.MapEvent{ + path: [], + target: ^map, + keys: %{"set2" => %{action: :add, new_value: "value2"}} + }, + %Yex.MapEvent{ + path: ["key1"], + target: ^child_map, + keys: %{"set1" => %{action: :add, new_value: "value1"}} + }, + %Yex.ArrayEvent{ + change: [%{retain: 4}, %{insert: [5]}], + path: ["key1", "key2", "key3"], + target: ^child_array + } + ], "origin_value", _metadata} + end + + test "unobserve_deep" do + doc = Doc.new() + + map = Doc.get_map(doc, "text") + + ref = SharedType.observe_deep(map) + assert :ok = SharedType.unobserve_deep(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Map.set(map, "0", "Hello") + end) + + refute_receive {:observe_deep_event, _, %Yex.MapEvent{}, _, _} + + # noop but return ok + assert :ok = SharedType.unobserve_deep(make_ref()) + end end diff --git a/test/shared_type/shared_type_test.exs b/test/shared_type/shared_type_test.exs new file mode 100644 index 0000000..5a16bf9 --- /dev/null +++ b/test/shared_type/shared_type_test.exs @@ -0,0 +1,58 @@ +defmodule Yex.SharedTypeTest do + use ExUnit.Case + alias Yex.{SharedType, Doc} + + test "raise error when wrong usage" do + doc = Doc.new() + + assert_raise Protocol.UndefinedError, fn -> + SharedType.unobserve_deep(1) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.unobserve(Doc.get_text(doc, "text")) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.unobserve_deep(Doc.get_text(doc, "text")) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe(1) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe(1, []) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe_deep(1) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe_deep(1, []) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe(make_ref()) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe(make_ref(), []) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe_deep(make_ref()) + end + + assert_raise Protocol.UndefinedError, fn -> + assert :ok = SharedType.observe_deep(make_ref(), []) + end + end + + test "observe/2" do + doc = Doc.new() + assert _ref = SharedType.observe(Doc.get_text(doc, "text"), []) + assert _ref = SharedType.observe_deep(Doc.get_text(doc, "text"), []) + end +end diff --git a/test/shared_type/text_test.exs b/test/shared_type/text_test.exs index 10eaa81..4584563 100644 --- a/test/shared_type/text_test.exs +++ b/test/shared_type/text_test.exs @@ -1,6 +1,6 @@ defmodule Yex.TextTest do use ExUnit.Case - alias Yex.{Doc, Text, TextPrelim} + alias Yex.{Doc, Text, TextPrelim, SharedType} doctest Text doctest TextPrelim @@ -170,4 +170,113 @@ defmodule Yex.TextTest do assert text1 != text3 end + + describe "observe" do + test "set " do + doc = Doc.new() + + text = Doc.get_text(doc, "text") + + ref = SharedType.observe(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Text.insert(text, 0, "Hello") + Text.insert(text, 6, " World", %{"bold" => true}) + end) + + assert_receive {:observe_event, ^ref, + %Yex.TextEvent{ + target: ^text, + delta: [ + %{insert: "Hello"}, + %{attributes: %{"bold" => true}, insert: " World"} + ] + }, "origin_value", nil} + end + + test "delete " do + doc = Doc.new() + + text = Doc.get_text(doc, "text") + Text.insert(text, 0, "Hello World") + + ref = SharedType.observe(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Text.delete(text, 3, 4) + end) + + assert_receive {:observe_event, ^ref, + %Yex.TextEvent{ + target: ^text, + delta: [%{retain: 3}, %{delete: 4}] + }, "origin_value", nil} + end + + test "unobserve" do + doc = Doc.new() + + text = Doc.get_text(doc, "text") + Text.insert(text, 0, "Hello World") + + ref = SharedType.observe(text) + assert :ok = SharedType.unobserve(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Text.delete(text, 3, 4) + end) + + refute_receive {:observe_event, ^ref, %Yex.TextEvent{}, _} + + # noop but return ok + assert :ok = SharedType.unobserve(make_ref()) + end + end + + test "observe_deep" do + doc = Doc.new() + text = Doc.get_text(doc, "text") + + Text.insert(text, 0, "Hello World") + + ref = Text.observe_deep(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Text.insert(text, 0, "Hello") + Text.insert(text, 5, " World") + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.TextEvent{ + path: [], + target: ^text, + delta: [%{insert: "Hello World"}] + } + ], "origin_value", nil} + end + + test "unobserve_deep" do + doc = Doc.new() + + text = Doc.get_text(doc, "text") + + ref = SharedType.observe_deep(text) + assert :ok = SharedType.unobserve_deep(ref) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + Text.insert(text, 0, "Hello") + Text.insert(text, 6, " World") + end) + + refute_receive {:observe_deep_event, _, %Yex.TextEvent{}, _, _} + + # noop but return ok + assert :ok = SharedType.unobserve_deep(make_ref()) + end end diff --git a/test/shared_type/xml_element_test.exs b/test/shared_type/xml_element_test.exs index 484a2d1..cb0221a 100644 --- a/test/shared_type/xml_element_test.exs +++ b/test/shared_type/xml_element_test.exs @@ -1,6 +1,17 @@ defmodule YexXmlElementTest do use ExUnit.Case - alias Yex.{Doc, XmlFragment, XmlElement, XmlElementPrelim, XmlText, XmlTextPrelim, Xml} + + alias Yex.{ + Doc, + XmlFragment, + XmlElement, + XmlElementPrelim, + XmlText, + XmlTextPrelim, + Xml, + SharedType + } + doctest XmlElement doctest XmlElementPrelim @@ -140,6 +151,94 @@ defmodule YexXmlElementTest do assert 6 === XmlElement.children(e) |> Enum.count() end + + test "observe", %{doc: doc, xml_element: xml_element} do + ref = SharedType.observe(xml_element) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlElement.insert_attribute(xml_element, "Hello", "World") + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlEvent{ + target: ^xml_element, + keys: %{"Hello" => %{action: :add, new_value: "World"}}, + delta: [] + }, "origin_value", nil} + end + + test "observe delete ", %{doc: doc, xml_element: xml_element} do + XmlElement.push(xml_element, XmlTextPrelim.from("Hello")) + XmlElement.push(xml_element, XmlTextPrelim.from("World")) + + ref = SharedType.observe(xml_element) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlElement.delete(xml_element, 0, 1) + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlEvent{ + target: ^xml_element, + keys: %{}, + delta: [%{delete: 1}], + path: [] + }, "origin_value", nil} + end + + test "observe_deep", %{doc: doc, xml_element: xml_element} do + XmlElement.push( + xml_element, + XmlElementPrelim.new("span", [ + XmlElementPrelim.new("span", [ + XmlTextPrelim.from("text") + ]) + ]) + ) + + el2 = XmlElement.first_child(xml_element) + el3 = XmlElement.first_child(el2) + text = XmlElement.first_child(el3) + + ref = SharedType.observe_deep(xml_element) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlElement.push(xml_element, XmlTextPrelim.from("1")) + XmlElement.insert_attribute(el2, "attr", "value") + XmlElement.push(el3, XmlElementPrelim.empty("div")) + XmlText.insert(text, 0, "text") + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.XmlEvent{ + path: [], + target: ^xml_element, + keys: %{}, + delta: [%{retain: 1}, %{insert: [%Yex.XmlText{}]}] + }, + %Yex.XmlEvent{ + path: [0], + target: ^el2, + keys: %{"attr" => %{action: :add, new_value: "value"}}, + delta: [] + }, + %Yex.XmlEvent{ + keys: %{}, + path: [0, 0], + target: ^el3, + delta: [%{retain: 1}, %{insert: [%Yex.XmlElement{}]}] + }, + %Yex.XmlTextEvent{ + path: [0, 0, 0], + target: ^text, + delta: [%{insert: "text"}] + } + ], "origin_value", _metadata} + end end describe "XmlElementPrelim" do diff --git a/test/shared_type/xml_fragment_test.exs b/test/shared_type/xml_fragment_test.exs index 32c29a0..321b39d 100644 --- a/test/shared_type/xml_fragment_test.exs +++ b/test/shared_type/xml_fragment_test.exs @@ -1,6 +1,6 @@ defmodule YexXmlFragmentTest do use ExUnit.Case - alias Yex.{Doc, XmlFragment, XmlElement, XmlElementPrelim, XmlText, XmlTextPrelim} + alias Yex.{Doc, XmlFragment, XmlElement, XmlElementPrelim, XmlText, XmlTextPrelim, SharedType} doctest XmlFragment doctest Yex.XmlFragmentPrelim @@ -131,5 +131,95 @@ defmodule YexXmlFragmentTest do assert 6 === XmlFragment.children(f) |> Enum.count() end + + test "observe", %{doc: doc, xml_fragment: f} do + ref = SharedType.observe(f) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlFragment.push(f, XmlTextPrelim.from("test")) + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlEvent{ + target: ^f, + keys: %{}, + delta: [ + %{insert: [%Yex.XmlText{}]} + ] + }, "origin_value", nil} + end + + test "observe delete ", %{doc: doc, xml_fragment: f} do + XmlFragment.push(f, XmlTextPrelim.from("Hello")) + XmlFragment.push(f, XmlTextPrelim.from("World")) + + ref = SharedType.observe(f) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlFragment.delete(f, 0, 1) + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlEvent{ + target: ^f, + keys: %{}, + delta: [%{delete: 1}], + path: [] + }, "origin_value", nil} + end + + test "observe_deep", %{doc: doc, xml_fragment: f} do + XmlFragment.push( + f, + XmlElementPrelim.new("span", [ + XmlElementPrelim.new("span", [ + XmlTextPrelim.from("text") + ]) + ]) + ) + + el2 = XmlFragment.first_child(f) + el3 = XmlElement.first_child(el2) + text = XmlElement.first_child(el3) + + ref = SharedType.observe_deep(f) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlFragment.push(f, XmlTextPrelim.from("1")) + XmlElement.insert_attribute(el2, "attr", "value") + XmlElement.push(el3, XmlElementPrelim.empty("div")) + XmlText.insert(text, 0, "text") + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.XmlEvent{ + path: [], + target: ^f, + keys: %{}, + delta: [%{retain: 1}, %{insert: [%Yex.XmlText{}]}] + }, + %Yex.XmlEvent{ + path: [0], + target: ^el2, + keys: %{"attr" => %{action: :add, new_value: "value"}}, + delta: [] + }, + %Yex.XmlEvent{ + keys: %{}, + path: [0, 0], + target: ^el3, + delta: [%{retain: 1}, %{insert: [%Yex.XmlElement{}]}] + }, + %Yex.XmlTextEvent{ + path: [0, 0, 0], + target: ^text, + delta: [%{insert: "text"}] + } + ], "origin_value", _metadata} + end end end diff --git a/test/shared_type/xml_text_test.exs b/test/shared_type/xml_text_test.exs index e3ce734..d72344c 100644 --- a/test/shared_type/xml_text_test.exs +++ b/test/shared_type/xml_text_test.exs @@ -1,6 +1,6 @@ defmodule YexXmlTextTest do use ExUnit.Case - alias Yex.{Doc, XmlFragment, XmlText, XmlTextPrelim} + alias Yex.{Doc, XmlFragment, XmlText, XmlTextPrelim, SharedType} doctest XmlText doctest XmlTextPrelim @@ -148,5 +148,65 @@ defmodule YexXmlTextTest do assert parent == xml_fragment end + + test "observe", %{doc: doc, xml_text: text} do + ref = SharedType.observe(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlText.insert(text, 0, "123456") + XmlText.format(text, 1, 3, %{"bold" => true}) + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlTextEvent{ + target: ^text, + delta: [ + %{insert: "1"}, + %{attributes: %{"bold" => true}, insert: "234"}, + %{insert: "56"} + ] + }, "origin_value", nil} + end + + test "observe delete ", %{doc: doc, xml_text: text} do + XmlText.insert(text, 0, "123456") + ref = SharedType.observe(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlText.delete(text, 0, 1) + end) + + assert_receive {:observe_event, ^ref, + %Yex.XmlTextEvent{ + target: ^text, + delta: [%{delete: 1}], + path: [] + }, "origin_value", nil} + end + + test "observe_deep", %{doc: doc, xml_text: text} do + ref = SharedType.observe_deep(text) + + :ok = + Doc.transaction(doc, "origin_value", fn -> + XmlText.insert(text, 0, "123456") + XmlText.format(text, 1, 3, %{"bold" => true}) + end) + + assert_receive {:observe_deep_event, ^ref, + [ + %Yex.XmlTextEvent{ + path: [], + target: ^text, + delta: [ + %{insert: "1"}, + %{attributes: %{"bold" => true}, insert: "234"}, + %{insert: "56"} + ] + } + ], "origin_value", _metadata} + end end end