From 75b97444dc81360246f660eb25e9557708c9256b Mon Sep 17 00:00:00 2001 From: Heri Sim <527101+heri16@users.noreply.github.com> Date: Wed, 8 Dec 2021 14:30:59 +0000 Subject: [PATCH 1/4] Add `load64(bytes)` --- lib/ecto/ulid.ex | 84 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/lib/ecto/ulid.ex b/lib/ecto/ulid.ex index 4602a29..7b01946 100644 --- a/lib/ecto/ulid.ex +++ b/lib/ecto/ulid.ex @@ -48,6 +48,12 @@ defmodule Ecto.ULID do def load(<<_::unsigned-size(128)>> = bytes), do: encode(bytes) def load(_), do: :error + @doc """ + Converts a binary ULID into a Firebase Base64 encoded string. + """ + def load64(<<_::unsigned-size(128)>> = bytes), do: encode64(bytes) + def load64(_), do: :error + @doc false def autogenerate, do: generate() @@ -91,7 +97,18 @@ defmodule Ecto.ULID do end defp encode(_), do: :error - @compile {:inline, e: 1} + defp encode64(<< b1::2, b2::6, b3::6, b4::6, b5::6, b6::6, b7::6, b8::6, b9::6, b10::6, b11::6, b12::6, b13::6, + b14::6, b15::6, b16::6, b17::6, b18::6, b19::6, b20::6, b21::6, b22::6>>) do + <> + catch + :error -> :error + else + encoded -> {:ok, encoded} + end + defp encode64(_), do: :error + + @compile {:inline, e: 1, e64: 1} defp e(0), do: ?0 defp e(1), do: ?1 @@ -126,6 +143,71 @@ defmodule Ecto.ULID do defp e(30), do: ?Y defp e(31), do: ?Z + defp e64(0), do: ?- + defp e64(1), do: ?0 + defp e64(2), do: ?1 + defp e64(3), do: ?2 + defp e64(4), do: ?3 + defp e64(5), do: ?4 + defp e64(6), do: ?5 + defp e64(7), do: ?6 + defp e64(8), do: ?7 + defp e64(9), do: ?8 + defp e64(10), do: ?9 + defp e64(11), do: ?A + defp e64(12), do: ?B + defp e64(13), do: ?C + defp e64(14), do: ?D + defp e64(15), do: ?E + defp e64(16), do: ?F + defp e64(17), do: ?G + defp e64(18), do: ?H + defp e64(19), do: ?I + defp e64(20), do: ?J + defp e64(21), do: ?K + defp e64(22), do: ?L + defp e64(23), do: ?M + defp e64(24), do: ?N + defp e64(25), do: ?O + defp e64(26), do: ?P + defp e64(27), do: ?Q + defp e64(28), do: ?R + defp e64(29), do: ?S + defp e64(30), do: ?T + defp e64(31), do: ?U + defp e64(32), do: ?V + defp e64(33), do: ?W + defp e64(34), do: ?X + defp e64(35), do: ?Y + defp e64(36), do: ?Z + defp e64(37), do: ?_ + defp e64(38), do: ?a + defp e64(39), do: ?b + defp e64(40), do: ?c + defp e64(41), do: ?d + defp e64(42), do: ?e + defp e64(43), do: ?f + defp e64(44), do: ?g + defp e64(45), do: ?h + defp e64(46), do: ?i + defp e64(47), do: ?j + defp e64(48), do: ?k + defp e64(49), do: ?l + defp e64(50), do: ?m + defp e64(51), do: ?n + defp e64(52), do: ?o + defp e64(53), do: ?p + defp e64(54), do: ?q + defp e64(55), do: ?r + defp e64(56), do: ?s + defp e64(57), do: ?t + defp e64(58), do: ?u + defp e64(59), do: ?v + defp e64(60), do: ?w + defp e64(61), do: ?x + defp e64(62), do: ?y + defp e64(63), do: ?z + defp decode(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8, c21::8, c22::8, c23::8, c24::8, c25::8, c26::8>>) do << d(c1)::3, d(c2)::5, d(c3)::5, d(c4)::5, d(c5)::5, d(c6)::5, d(c7)::5, d(c8)::5, d(c9)::5, d(c10)::5, d(c11)::5, d(c12)::5, d(c13)::5, From a2e8c28b2c0cfa5a046efa371b68bed790ac6d48 Mon Sep 17 00:00:00 2001 From: heri16 Date: Wed, 8 Dec 2021 23:08:52 +0800 Subject: [PATCH 2/4] use `Ecto.ParameterizedType` --- README.md | 10 +- bench/ulid_bench.exs | 42 +++++- lib/ecto/ulid.ex | 293 ++++++++++++++++++++++++++++++++++++---- test/ecto/ulid_test.exs | 54 +++++--- 4 files changed, 344 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index e6cddd0..c8905ad 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ The following are results from running the benchmark on an AMD Ryzen Threadrippe ``` benchmark name iterations average time -cast/1 10000000 0.25 µs/op +cast/2 10000000 0.25 µs/op dump/1 10000000 0.50 µs/op -load/1 10000000 0.55 µs/op -bingenerate/0 10000000 0.93 µs/op -generate/0 1000000 1.55 µs/op +load/2 10000000 0.55 µs/op +bingenerate/1 10000000 0.93 µs/op +generate/2 1000000 1.55 µs/op ``` ## Usage @@ -108,7 +108,7 @@ key. ### Application Usage -A ULID can be generated in string or binary format by calling `generate/0` or `bingenerate/0`. This +A ULID can be generated in string or binary format by calling `generate/2` or `bingenerate/1`. This can be useful when generating ULIDs to send to external systems: ```elixir diff --git a/bench/ulid_bench.exs b/bench/ulid_bench.exs index c6f87f9..ec555b7 100644 --- a/bench/ulid_bench.exs +++ b/bench/ulid_bench.exs @@ -1,25 +1,59 @@ defmodule ULIDBench do use Benchfella - bench "generate/0" do + bench "generate/2" do Ecto.ULID.generate() nil end - bench "bingenerate/0" do + bench "generate/2 (:b64)" do + Ecto.ULID.generate(:b64) + nil + end + + bench "generate/2 (:push)" do + Ecto.ULID.generate(:push) + nil + end + + bench "bingenerate/1" do Ecto.ULID.bingenerate() nil end - bench "cast/1" do + bench "cast/2" do Ecto.ULID.cast("01C0M0Y7BG2NMB15VVVJH807F3") end + bench "cast/2 (:b64)" do + Ecto.ULID.cast("-0N1VE6M-KPA1MTxmXV0rY") + end + + bench "cast/2 (:push)" do + Ecto.ULID.cast("-L-c2lpkPA1MTxmXV0rY") + end + bench "dump/1" do Ecto.ULID.dump("01C0M0Y7BG2NMB15VVVJH807F3") end - bench "load/1" do + bench "dump/1 (:b64)" do + Ecto.ULID.dump("-0N1VE6M-KPA1MTxmXV0rY") + end + + bench "dump/1 (:push)" do + Ecto.ULID.dump("-L-c2lpkPA1MTxmXV0rY") + end + + bench "load/2" do Ecto.ULID.load(<<1, 96, 40, 15, 29, 112, 21, 104, 176, 151, 123, 220, 162, 128, 29, 227>>) end + + bench "load/2 (:b64)" do + Ecto.ULID.load(<<1, 96, 40, 15, 29, 112, 21, 104, 176, 151, 123, 220, 162, 128, 29, 227>>, :b64) + end + + bench "load/2 (:push)" do + Ecto.ULID.load(<<1, 96, 40, 15, 29, 112, 21, 104, 176, 151, 123, 220, 162, 128, 29, 227>>, :push) + end end diff --git a/lib/ecto/ulid.ex b/lib/ecto/ulid.ex index 7b01946..91bb2e2 100644 --- a/lib/ecto/ulid.ex +++ b/lib/ecto/ulid.ex @@ -3,71 +3,140 @@ defmodule Ecto.ULID do An Ecto type for ULID strings. """ - # replace with `use Ecto.Type` after Ecto 3.2.0 is required - @behaviour Ecto.Type + @default_params %{variant: :b32} + + # replace with `use Ecto.ParameterizedType` after Ecto 3.2.0 is required + @behaviour Ecto.ParameterizedType # and remove both of these functions - def embed_as(_), do: :self - def equal?(term1, term2), do: term1 == term2 + def embed_as(_, _params), do: :self + def equal?(term1, term2, _params), do: dump(term1) == dump(term2) @doc """ The underlying schema type. """ - def type, do: :uuid + def type(_params \\ @default_params), do: :uuid @doc """ Casts a string to ULID. """ - def cast(<<_::bytes-size(26)>> = value) do + def cast(value, params \\ @default_params) + def cast(<<_::bytes-size(26)>> = value, _params) do + # Crockford Base32 encoded string if valid?(value) do {:ok, value} else :error end end - def cast(_), do: :error + def cast(<<_::bytes-size(22)>> = value, _params) do + # Lexicographic Base64 encoded string + if valid64?(value) do + {:ok, value} + else + :error + end + end + def cast(<<_::bytes-size(20)>> = value, _params) do + # Firebase-Push-Key Base64 encoded string + if valid64?(value) do + {:ok, value} + else + :error + end + end + def cast(_, _params), do: :error @doc """ - Same as `cast/1` but raises `Ecto.CastError` on invalid arguments. + Same as `cast/2` but raises `Ecto.CastError` on invalid arguments. """ - def cast!(value) do - case cast(value) do + def cast!(value, params \\ @default_params) do + case cast(value, params) do {:ok, ulid} -> ulid :error -> raise Ecto.CastError, type: __MODULE__, value: value end end @doc """ - Converts a Crockford Base32 encoded ULID into a binary. + Converts a Crockford Base32 encoded string or + Lexicographic Base64 encoded string or Firebase-Push-Key Base64 encoded string + into a binary ULID. """ + def dump(encoded) def dump(<<_::bytes-size(26)>> = encoded), do: decode(encoded) + def dump(<<_::bytes-size(22)>> = encoded), do: decode64(encoded) + def dump(<<_::bytes-size(20)>> = encoded), do: decode64(encoded) def dump(_), do: :error - @doc """ - Converts a binary ULID into a Crockford Base32 encoded string. - """ - def load(<<_::unsigned-size(128)>> = bytes), do: encode(bytes) - def load(_), do: :error + @doc false + def dump(encoded, _dumper, _params), do: dump(encoded) @doc """ - Converts a binary ULID into a Firebase Base64 encoded string. + Converts a binary ULID into an encoded string (defaults to Crockford Base32 encoding). + + Variants: + + * `:b32`: Crockford Base32 encoding (default) + * `:b64`: Lexicographic Base64 encoding + * `:push`: Firebase Push-Key Base64 encoding + + Arguments: + + * `bytes`: A binary ULID. + * `variant`: :b32 (default), :b64 (Base64), or :push (Firebase Push-Key). """ - def load64(<<_::unsigned-size(128)>> = bytes), do: encode64(bytes) - def load64(_), do: :error + def load(bytes, variant \\ :b32) + def load(<<_::unsigned-size(128)>> = bytes, :b32), do: encode(bytes) + def load(<<_::unsigned-size(128)>> = bytes, :b64), do: encode64(bytes) + def load(<> = _bytes, :push), do: encode64(<>) + def load(_, _variant), do: :error + + @doc false + def load(bytes, _loader, %{variant: variant}), do: load(bytes, variant) + def load(_, _loader, _params), do: :error @doc false - def autogenerate, do: generate() + def init(opts) do + case Keyword.get(opts, :variant, :b32) do + v when v in [:b32, :b64, :push] -> %{variant: v} + _ -> raise "Ecto.ULID variant must be one of [:b32, :b64, :push]" + end + end + + @doc false + def autogenerate(%{variant: variant} = _params), do: generate(variant) @doc """ - Generates a Crockford Base32 encoded ULID. + Generates a string encoded ULID (defaults to Crockford Base32 encoding). If a value is provided for `timestamp`, the generated ULID will be for the provided timestamp. Otherwise, a ULID will be generated for the current time. + Variants: + + * `:b32`: Crockford Base32 encoding (default) + * `:b64`: Lexicographic Base64 encoding + * `:push`: Firebase Push-Key Base64 encoding + Arguments: + * `variant`: :b32 (default), :b64 (Base64), or :push (Firebase Push-Key). * `timestamp`: A Unix timestamp with millisecond precision. """ - def generate(timestamp \\ System.system_time(:millisecond)) do + def generate(variant \\ :b32, timestamp \\ System.system_time(:millisecond)) + def generate(:b32, timestamp) do + {:ok, ulid} = encode(bingenerate(timestamp)) + ulid + end + def generate(:b64, timestamp) do + {:ok, ulid} = encode64(bingenerate(timestamp)) + ulid + end + def generate(:push, timestamp) do + <> = bingenerate(timestamp) + {:ok, ulid} = encode64(<>) + ulid + end + def generate(timestamp, _) when is_integer(timestamp) do {:ok, ulid} = encode(bingenerate(timestamp)) ulid end @@ -106,6 +175,15 @@ defmodule Ecto.ULID do else encoded -> {:ok, encoded} end + defp encode64(<< b1::6, b2::6, b3::6, b4::6, b5::6, b6::6, b7::6, b8::6, b9::6, b10::6, b11::6, b12::6, b13::6, + b14::6, b15::6, b16::6, b17::6, b18::6, b19::6, b20::6>>) do + <> + catch + :error -> :error + else + encoded -> {:ok, encoded} + end defp encode64(_), do: :error @compile {:inline, e: 1, e64: 1} @@ -219,7 +297,27 @@ defmodule Ecto.ULID do end defp decode(_), do: :error - @compile {:inline, d: 1} + defp decode64(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, + c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8, c21::8, c22::8>>) do + << d64(c1)::2, d64(c2)::6, d64(c3)::6, d64(c4)::6, d64(c5)::6, d64(c6)::6, d64(c7)::6, d64(c8)::6, d64(c9)::6, d64(c10)::6, d64(c11)::6, d64(c12)::6, d64(c13)::6, + d64(c14)::6, d64(c15)::6, d64(c16)::6, d64(c17)::6, d64(c18)::6, d64(c19)::6, d64(c20)::6, d64(c21)::6, d64(c22)::6>> + catch + :error -> :error + else + decoded -> {:ok, decoded} + end + defp decode64(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, + c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8>>) do + << d64(c1)::6, d64(c2)::6, d64(c3)::6, d64(c4)::6, d64(c5)::6, d64(c6)::6, d64(c7)::6, d64(c8)::6, 0::unsigned-size(8), d64(c9)::6, d64(c10)::6, d64(c11)::6, d64(c12)::6, d64(c13)::6, + d64(c14)::6, d64(c15)::6, d64(c16)::6, d64(c17)::6, d64(c18)::6, d64(c19)::6, d64(c20)::6>> + catch + :error -> :error + else + decoded -> {:ok, decoded} + end + defp decode64(_), do: :error + + @compile {:inline, d: 1, d64: 1} defp d(?0), do: 0 defp d(?1), do: 1 @@ -255,14 +353,93 @@ defmodule Ecto.ULID do defp d(?Z), do: 31 defp d(_), do: throw :error + defp d64(?-), do: 0 + defp d64(?0), do: 1 + defp d64(?1), do: 2 + defp d64(?2), do: 3 + defp d64(?3), do: 4 + defp d64(?4), do: 5 + defp d64(?5), do: 6 + defp d64(?6), do: 7 + defp d64(?7), do: 8 + defp d64(?8), do: 9 + defp d64(?9), do: 10 + defp d64(?A), do: 11 + defp d64(?B), do: 12 + defp d64(?C), do: 13 + defp d64(?D), do: 14 + defp d64(?E), do: 15 + defp d64(?F), do: 16 + defp d64(?G), do: 17 + defp d64(?H), do: 18 + defp d64(?I), do: 19 + defp d64(?J), do: 20 + defp d64(?K), do: 21 + defp d64(?L), do: 22 + defp d64(?M), do: 23 + defp d64(?N), do: 24 + defp d64(?O), do: 25 + defp d64(?P), do: 26 + defp d64(?Q), do: 27 + defp d64(?R), do: 28 + defp d64(?S), do: 29 + defp d64(?T), do: 30 + defp d64(?U), do: 31 + defp d64(?V), do: 32 + defp d64(?W), do: 33 + defp d64(?X), do: 34 + defp d64(?Y), do: 35 + defp d64(?Z), do: 36 + defp d64(?_), do: 37 + defp d64(?a), do: 38 + defp d64(?b), do: 39 + defp d64(?c), do: 40 + defp d64(?d), do: 41 + defp d64(?e), do: 42 + defp d64(?f), do: 43 + defp d64(?g), do: 44 + defp d64(?h), do: 45 + defp d64(?i), do: 46 + defp d64(?j), do: 47 + defp d64(?k), do: 48 + defp d64(?l), do: 49 + defp d64(?m), do: 50 + defp d64(?n), do: 51 + defp d64(?o), do: 52 + defp d64(?p), do: 53 + defp d64(?q), do: 54 + defp d64(?r), do: 55 + defp d64(?s), do: 56 + defp d64(?t), do: 57 + defp d64(?u), do: 58 + defp d64(?v), do: 59 + defp d64(?w), do: 60 + defp d64(?x), do: 61 + defp d64(?y), do: 62 + defp d64(?z), do: 63 + defp d64(_), do: throw :error + defp valid?(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8, c21::8, c22::8, c23::8, c24::8, c25::8, c26::8>>) do - v(c1) && v(c2) && v(c3) && v(c4) && v(c5) && v(c6) && v(c7) && v(c8) && v(c9) && v(c10) && v(c11) && v(c12) && v(c13) && + c1 in [?0, ?1, ?2, ?3, ?4, ?5, ?6, ?7] && + v(c2) && v(c3) && v(c4) && v(c5) && v(c6) && v(c7) && v(c8) && v(c9) && v(c10) && v(c11) && v(c12) && v(c13) && v(c14) && v(c15) && v(c16) && v(c17) && v(c18) && v(c19) && v(c20) && v(c21) && v(c22) && v(c23) && v(c24) && v(c25) && v(c26) end defp valid?(_), do: false - @compile {:inline, v: 1} + defp valid64?(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, + c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8, c21::8, c22::8>>) do + v64(c1) && v64(c2) && v64(c3) && v64(c4) && v64(c5) && v64(c6) && v64(c7) && v64(c8) && v64(c9) && v64(c10) && v64(c11) && v64(c12) && v64(c13) && + v64(c14) && v64(c15) && v64(c16) && v64(c17) && v64(c18) && v64(c19) && v64(c20) && v64(c21) && v64(c22) + end + defp valid64?(<< c1::8, c2::8, c3::8, c4::8, c5::8, c6::8, c7::8, c8::8, c9::8, c10::8, c11::8, c12::8, c13::8, + c14::8, c15::8, c16::8, c17::8, c18::8, c19::8, c20::8>>) do + v64(c1) && v64(c2) && v64(c3) && v64(c4) && v64(c5) && v64(c6) && v64(c7) && v64(c8) && v64(c9) && v64(c10) && v64(c11) && v64(c12) && v64(c13) && + v64(c14) && v64(c15) && v64(c16) && v64(c17) && v64(c18) && v64(c19) && v64(c20) + end + defp valid64?(_), do: false + + @compile {:inline, v: 1, v64: 1} defp v(?0), do: true defp v(?1), do: true @@ -297,4 +474,70 @@ defmodule Ecto.ULID do defp v(?Y), do: true defp v(?Z), do: true defp v(_), do: false + + defp v64(?-), do: true + defp v64(?0), do: true + defp v64(?1), do: true + defp v64(?2), do: true + defp v64(?3), do: true + defp v64(?4), do: true + defp v64(?5), do: true + defp v64(?6), do: true + defp v64(?7), do: true + defp v64(?8), do: true + defp v64(?9), do: true + defp v64(?A), do: true + defp v64(?B), do: true + defp v64(?C), do: true + defp v64(?D), do: true + defp v64(?E), do: true + defp v64(?F), do: true + defp v64(?G), do: true + defp v64(?H), do: true + defp v64(?I), do: true + defp v64(?J), do: true + defp v64(?K), do: true + defp v64(?L), do: true + defp v64(?M), do: true + defp v64(?N), do: true + defp v64(?O), do: true + defp v64(?P), do: true + defp v64(?Q), do: true + defp v64(?R), do: true + defp v64(?S), do: true + defp v64(?T), do: true + defp v64(?U), do: true + defp v64(?V), do: true + defp v64(?W), do: true + defp v64(?X), do: true + defp v64(?Y), do: true + defp v64(?Z), do: true + defp v64(?_), do: true + defp v64(?a), do: true + defp v64(?b), do: true + defp v64(?c), do: true + defp v64(?d), do: true + defp v64(?e), do: true + defp v64(?f), do: true + defp v64(?g), do: true + defp v64(?h), do: true + defp v64(?i), do: true + defp v64(?j), do: true + defp v64(?k), do: true + defp v64(?l), do: true + defp v64(?m), do: true + defp v64(?n), do: true + defp v64(?o), do: true + defp v64(?p), do: true + defp v64(?q), do: true + defp v64(?r), do: true + defp v64(?s), do: true + defp v64(?t), do: true + defp v64(?u), do: true + defp v64(?v), do: true + defp v64(?w), do: true + defp v64(?x), do: true + defp v64(?y), do: true + defp v64(?z), do: true + defp v64(_), do: false end diff --git a/test/ecto/ulid_test.exs b/test/ecto/ulid_test.exs index c9ae9d8..9b922e5 100644 --- a/test/ecto/ulid_test.exs +++ b/test/ecto/ulid_test.exs @@ -3,76 +3,78 @@ defmodule Ecto.ULIDTest do @binary <<1, 95, 194, 60, 108, 73, 209, 114, 136, 236, 133, 115, 106, 195, 145, 22>> @encoded "01BZ13RV29T5S8HV45EDNC748P" + @encoded_b64 "-0Mw7wQ3bGRcYgWMCekt3L" + @encoded_push "-Kz1E5l8RcYgWMCekt3L" - # generate/0 + # generate/2 - test "generate/0 encodes milliseconds in first 10 characters" do + test "generate/2 encodes milliseconds in first 10 characters" do # test case from ULID README: https://github.com/ulid/javascript#seed-time <> = Ecto.ULID.generate(1469918176385) assert encoded == "01ARYZ6S41" end - test "generate/0 generates unique identifiers" do + test "generate/2 generates unique identifiers" do ulid1 = Ecto.ULID.generate() ulid2 = Ecto.ULID.generate() assert ulid1 != ulid2 end - # bingenerate/0 + # bingenerate/1 - test "bingenerate/0 encodes milliseconds in first 48 bits" do + test "bingenerate/1 encodes milliseconds in first 48 bits" do now = System.system_time(:millisecond) <> = Ecto.ULID.bingenerate() assert_in_delta now, time, 10 end - test "bingenerate/0 generates unique identifiers" do + test "bingenerate/1 generates unique identifiers" do ulid1 = Ecto.ULID.bingenerate() ulid2 = Ecto.ULID.bingenerate() assert ulid1 != ulid2 end - # cast/1 + # cast/2 - test "cast/1 returns valid ULID" do + test "cast/2 returns valid ULID" do {:ok, ulid} = Ecto.ULID.cast(@encoded) assert ulid == @encoded end - test "cast/1 returns ULID for encoding of correct length" do + test "cast/2 returns ULID for encoding of correct length" do {:ok, ulid} = Ecto.ULID.cast("00000000000000000000000000") assert ulid == "00000000000000000000000000" end - test "cast/1 returns error when encoding is too short" do + test "cast/2 returns error when encoding is too short" do assert Ecto.ULID.cast("0000000000000000000000000") == :error end - test "cast/1 returns error when encoding is too long" do + test "cast/2 returns error when encoding is too long" do assert Ecto.ULID.cast("000000000000000000000000000") == :error end - test "cast/1 returns error when encoding contains letter I" do + test "cast/2 returns error when encoding contains letter I" do assert Ecto.ULID.cast("I0000000000000000000000000") == :error end - test "cast/1 returns error when encoding contains letter L" do + test "cast/2 returns error when encoding contains letter L" do assert Ecto.ULID.cast("L0000000000000000000000000") == :error end - test "cast/1 returns error when encoding contains letter O" do + test "cast/2 returns error when encoding contains letter O" do assert Ecto.ULID.cast("O0000000000000000000000000") == :error end - test "cast/1 returns error when encoding contains letter U" do + test "cast/2 returns error when encoding contains letter U" do assert Ecto.ULID.cast("U0000000000000000000000000") == :error end - test "cast/1 returns error for invalid encoding" do + test "cast/2 returns error for invalid encoding" do assert Ecto.ULID.cast("$0000000000000000000000000") == :error end @@ -116,23 +118,33 @@ defmodule Ecto.ULIDTest do assert Ecto.ULID.dump("$0000000000000000000000000") == :error end - # load/1 + # load/2 - test "load/1 encodes binary as ULID" do + test "load/2 encodes binary as Base32" do {:ok, encoded} = Ecto.ULID.load(@binary) assert encoded == @encoded end - test "load/1 encodes binary of correct length" do + test "load/2 encodes binary as Base64" do + {:ok, encoded} = Ecto.ULID.load(@binary, :b64) + assert encoded == @encoded_b64 + end + + test "load/2 encodes binary as Firebase-Push-Key" do + {:ok, encoded} = Ecto.ULID.load(@binary, :push) + assert encoded == @encoded_push + end + + test "load/2 encodes binary of correct length" do {:ok, encoded} = Ecto.ULID.load(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) assert encoded == "00000000000000000000000000" end - test "load/1 returns error when data is too short" do + test "load/2 returns error when data is too short" do assert Ecto.ULID.load(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) == :error end - test "load/1 returns error when data is too long" do + test "load/2 returns error when data is too long" do assert Ecto.ULID.load(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) == :error end end From 77452235958fb51b00570f555558582cac02b8cb Mon Sep 17 00:00:00 2001 From: heri16 Date: Thu, 9 Dec 2021 23:38:53 +0800 Subject: [PATCH 3/4] Update README --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8905ad..b97bb1f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ecto.ULID -An `Ecto.Type` implementation of [ULID](https://github.com/ulid/spec). +An `Ecto.ParameterizedType` implementation of [ULID](https://github.com/ulid/spec). `Ecto.ULID` should be compatible anywhere that `Ecto.UUID` is supported. It has been confirmed to work with PostgreSQL and MySQL on Ecto 2.x and 3.x. Ecto 1.x is *not* supported. @@ -106,6 +106,20 @@ end `Ecto.ULID` supports `autogenerate: true` as well as `autogenerate: false` when used as the primary key. +`Ecto.ULID` also supports `variant: :b64` as well as `variant: :push` that follows the compact 20-char string encoding standard defined in [Firebase Push Key](https://firebase.googleblog.com/2015/02/the-2120-ways-to-ensure-unique_68.html). Just like the default Crockford base32 encoding, both variants use a modified base64 alphabet to ensure the IDs will still sort correctly when ordered lexicographically. Do note that the `:push` variant encodes only 120-bits, but is otherwise interoperable with the standard 128-bit binary ULID (by having the 1st-byte of the random component set to 0, we achieve a more efficient base64 encoding that saves 2 characters). + +```elixir +defmodule MyApp.Event do + use Ecto.Schema + + @primary_key {:id, Ecto.ULID, autogenerate: false, variant: :b64} + @foreign_key_type Ecto.ULID + schema "events" do + # more columns ... + end +end +``` + ### Application Usage A ULID can be generated in string or binary format by calling `generate/2` or `bingenerate/1`. This From 969023feed28083fda18b0af9a322ea33b015ef8 Mon Sep 17 00:00:00 2001 From: heri16 Date: Fri, 10 Dec 2021 18:41:22 +0800 Subject: [PATCH 4/4] Fix "does not support :autogenerate" error --- lib/ecto/ulid.ex | 29 ++++++++++++++++------------- test/ecto/ulid_test.exs | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/lib/ecto/ulid.ex b/lib/ecto/ulid.ex index 91bb2e2..73602ca 100644 --- a/lib/ecto/ulid.ex +++ b/lib/ecto/ulid.ex @@ -9,18 +9,27 @@ defmodule Ecto.ULID do @behaviour Ecto.ParameterizedType # and remove both of these functions def embed_as(_, _params), do: :self - def equal?(term1, term2, _params), do: dump(term1) == dump(term2) + def equal?(term1, term2, _params), do: term1 == term2 @doc """ The underlying schema type. """ - def type(_params \\ @default_params), do: :uuid + def type(_params), do: :uuid + + @doc false + def init(opts) do + case Keyword.get(opts, :variant, :b32) do + v when v in [:b32, :b64, :push] -> %{variant: v} + _ -> raise "Ecto.ULID variant must be one of [:b32, :b64, :push]" + end + end @doc """ Casts a string to ULID. """ def cast(value, params \\ @default_params) - def cast(<<_::bytes-size(26)>> = value, _params) do + def cast(nil, _params), do: {:ok, nil} + def cast(<<_::bytes-size(26)>> = value, %{variant: :b32}) do # Crockford Base32 encoded string if valid?(value) do {:ok, value} @@ -28,7 +37,7 @@ defmodule Ecto.ULID do :error end end - def cast(<<_::bytes-size(22)>> = value, _params) do + def cast(<<_::bytes-size(22)>> = value, %{variant: :b64}) do # Lexicographic Base64 encoded string if valid64?(value) do {:ok, value} @@ -36,7 +45,7 @@ defmodule Ecto.ULID do :error end end - def cast(<<_::bytes-size(20)>> = value, _params) do + def cast(<<_::bytes-size(20)>> = value, %{variant: :push}) do # Firebase-Push-Key Base64 encoded string if valid64?(value) do {:ok, value} @@ -68,6 +77,7 @@ defmodule Ecto.ULID do def dump(_), do: :error @doc false + def dump(nil, _, _), do: {:ok, nil} def dump(encoded, _dumper, _params), do: dump(encoded) @doc """ @@ -91,17 +101,10 @@ defmodule Ecto.ULID do def load(_, _variant), do: :error @doc false + def load(nil, _, _), do: {:ok, nil} def load(bytes, _loader, %{variant: variant}), do: load(bytes, variant) def load(_, _loader, _params), do: :error - @doc false - def init(opts) do - case Keyword.get(opts, :variant, :b32) do - v when v in [:b32, :b64, :push] -> %{variant: v} - _ -> raise "Ecto.ULID variant must be one of [:b32, :b64, :push]" - end - end - @doc false def autogenerate(%{variant: variant} = _params), do: generate(variant) diff --git a/test/ecto/ulid_test.exs b/test/ecto/ulid_test.exs index 9b922e5..cc58946 100644 --- a/test/ecto/ulid_test.exs +++ b/test/ecto/ulid_test.exs @@ -147,4 +147,19 @@ defmodule Ecto.ULIDTest do test "load/2 returns error when data is too long" do assert Ecto.ULID.load(<<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>) == :error end + + defmodule SchemaWithUlidAsPrimaryKey do + use Ecto.Schema + + @primary_key {:id, Ecto.ULID, + autogenerate: true, variant: :b64} + schema "" do + end + end + + test "init primary key field" do + assert SchemaWithUlidAsPrimaryKey.__schema__(:autogenerate_id) == nil + assert SchemaWithUlidAsPrimaryKey.__schema__(:autogenerate) == + [{[:id], {Ecto.ULID, :autogenerate, [%{variant: :b64}]}}] + end end