Skip to content

Commit 88f2a4e

Browse files
committed
Change format :encoded to :url64, add :hex, fix mysql
1 parent 0b5c998 commit 88f2a4e

File tree

4 files changed

+153
-62
lines changed

4 files changed

+153
-62
lines changed

docker-compose.yml

+12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ services:
66
environment:
77
POSTGRES_PASSWORD: supersecret
88
POSTGRES_DB: no_noncense
9+
depends_on:
10+
mysql:
11+
condition: service_healthy
912

1013
mysql:
1114
image: mysql:debian
@@ -14,3 +17,12 @@ services:
1417
environment:
1518
MYSQL_ROOT_PASSWORD: supersecret
1619
MYSQL_DATABASE: no_noncense
20+
healthcheck:
21+
test:
22+
- CMD
23+
- mysqladmin
24+
- ping
25+
- -p$$MYSQL_ROOT_PASSWORD
26+
timeout: 1s
27+
retries: 30
28+
interval: 1s

lib/once.ex

+84-45
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
defmodule Once do
22
@format_docs """
3-
- `:encoded` a url64-encoded string of 11 characters, for example `"AAjhfZyAAAE"`
3+
- `:url64` a url64-encoded string of 11 characters, for example `"AAjhfZyAAAE"`
4+
- `:hex` a hex-encoded string of 16 characters, for example `"E010831058218A39"`
45
- `:raw` a bitstring of 64 bits, for example `<<0, 8, 225, 125, 156, 128, 0, 2>>`
56
- `:signed` a signed 64-bits integer, like `-12345`, between -(2^63) and 2^63-1
67
- `:unsigned` an unsigned 64-bits integer, like `67890`, between 0 and 2^64-1
78
"""
89
@options_docs """
910
- `:no_noncense` name of the NoNoncense instance used to generate new IDs (default `Once`)
10-
- `:ex_format` what an ID looks like in Elixir, one of `t:format/0` (default `:encoded`)
11+
- `:ex_format` what an ID looks like in Elixir, one of `t:format/0` (default `:url64`)
1112
- `:db_format` what an ID looks like in your database, one of `t:format/0` (default `:signed`)
1213
- `:encrypt?` enable for encrypted nonces (default `false`)
1314
- `:get_key` a zero-arity getter for the 192-bits encryption key, required if encryption is enabled
@@ -54,8 +55,9 @@ defmodule Once do
5455
<<255, 255, 255, 255, 255, 255, 255, 255>>
5556
"__________8"
5657
18_446_744_073_709_551_615
58+
"FFFFFFFFFFFFFFFF"
5759
58-
If you use the defaults `:encoded` as the Elixir format and `:signed` in your database, you could see `"AAAAAACYloA"` in Elixir and `10_000_000` in your database. The reasoning behind these defaults is that the encoded format is readable, short, and JSON safe by default, while the signed format means you can use a standard bigint column type.
60+
If you use the defaults `:url64` as the Elixir format and `:signed` in your database, you could see `"AAAAAACYloA"` in Elixir and `10_000_000` in your database. The reasoning behind these defaults is that the encoded format is readable, short, and JSON safe by default, while the signed format means you can use a standard bigint column type.
5961
6062
The negative integers will not cause problems with Postgres and MySQL, they both happily swallow them. Also, negative integers will only start to appear after ~70 years of usage.
6163
@@ -81,7 +83,7 @@ defmodule Once do
8183
8284
#{@format_docs}
8385
"""
84-
@type format :: :encoded | :raw | :signed | :unsigned
86+
@type format :: :url64 | :raw | :signed | :unsigned | :hex
8587

8688
@typedoc """
8789
Options to initialize `Once`.
@@ -99,20 +101,21 @@ defmodule Once do
99101
@default_opts %{
100102
no_noncense: __MODULE__,
101103
encrypt?: false,
102-
ex_format: :encoded,
104+
ex_format: :url64,
103105
db_format: :signed
104106
}
105107

106108
@int_formats [:signed, :unsigned]
109+
@encoded_formats [:url64, :hex]
107110

108111
#######################
109112
# Type implementation #
110113
#######################
111114

112115
@impl true
113116
def type(%{ex_format: :raw}), do: :binary
114-
def type(%{ex_format: :encoded}), do: :string
115-
def type(%{ex_format: int}) when int in @int_formats, do: :integer
117+
def type(%{ex_format: format}) when format in @encoded_formats, do: :string
118+
def type(%{ex_format: format}) when format in @int_formats, do: :integer
116119

117120
@impl true
118121
@spec init(opts()) :: map()
@@ -164,60 +167,36 @@ defmodule Once do
164167
{:ok, -2301195303365014983}
165168
iex> Once.to_format(-2301195303365014983, :unsigned)
166169
{:ok, 16145548770344536633}
167-
iex> Once.to_format(16145548770344536633, :encoded)
170+
iex> Once.to_format(16145548770344536633, :hex)
171+
{:ok, "E010831058218A39"}
172+
iex> Once.to_format("E010831058218a39", :url64)
168173
{:ok, "4BCDEFghijk"}
169174
170-
iex> Once.to_format(-1, :encoded)
175+
iex> Once.to_format(-1, :url64)
171176
{:ok, "__________8"}
172177
iex> Once.to_format("__________8", :raw)
173178
{:ok, <<255, 255, 255, 255, 255, 255, 255, 255>>}
174179
iex> Once.to_format(<<255, 255, 255, 255, 255, 255, 255, 255>>, :unsigned)
175180
{:ok, 18446744073709551615}
176-
iex> Once.to_format(18446744073709551615, :signed)
181+
iex> Once.to_format(18446744073709551615, :hex)
182+
{:ok, "FFFFFFFFFFFFFFFF"}
183+
iex> Once.to_format("FFFFFFFFFFFFFFFF", :signed)
177184
{:ok, -1}
178185
179186
iex> Once.to_format(Integer.pow(2, 64), :unsigned)
180187
:error
181188
"""
182189
@spec to_format(binary() | integer(), format()) :: {:ok, binary() | integer()} | :error
183-
def to_format(value, format)
184-
# to :encoded
185-
def to_format(encoded = <<_::88>>, :encoded), do: {:ok, encoded}
186-
def to_format(raw = <<_::64>>, :encoded), do: encode(raw)
187-
188-
def to_format(int, :encoded) when is_integer(int) do
189-
convert_int(int, :signed) |> if_ok(&encode(<<&1::signed-64>>))
190-
end
191-
192-
# to :raw
193-
def to_format(encoded = <<_::88>>, :raw), do: decode(encoded)
194-
def to_format(raw = <<_::64>>, :raw), do: {:ok, raw}
195-
196-
def to_format(int, :raw) when is_integer(int) do
197-
convert_int(int, :signed) |> if_ok(&{:ok, <<&1::signed-64>>})
198-
end
199-
200-
# to :signed / :unsigned
201-
def to_format(encoded = <<_::88>>, int_format) when int_format in @int_formats do
202-
decode(encoded) |> if_ok(&to_format(&1, int_format))
203-
end
204-
205-
def to_format(_raw = <<int::signed-64>>, :signed), do: {:ok, int}
206-
def to_format(_raw = <<int::unsigned-64>>, :unsigned), do: {:ok, int}
207-
208-
def to_format(int, int_format) when is_integer(int) and int_format in @int_formats do
209-
convert_int(int, int_format)
210-
end
211-
212-
def to_format(_, _), do: :error
190+
def to_format(value, format), do: identify_format(value) |> maybe_convert(value, format)
213191

214192
@doc """
215193
Same as `to_format/2` but raises on error.
216194
217195
iex> -200
218-
...> |> Once.to_format!(:encoded)
196+
...> |> Once.to_format!(:url64)
219197
...> |> Once.to_format!(:raw)
220198
...> |> Once.to_format!(:unsigned)
199+
...> |> Once.to_format!(:hex)
221200
...> |> Once.to_format!(:signed)
222201
-200
223202
@@ -241,6 +220,7 @@ defmodule Once do
241220
@signed_max Integer.pow(2, 63) - 1
242221
@unsigned_max @range - 1
243222

223+
# convert a signed to unsigned int and back
244224
defp convert_int(int, format)
245225
defp convert_int(int, _) when int < @signed_min, do: :error
246226
defp convert_int(int, _) when int > @unsigned_max, do: :error
@@ -250,9 +230,68 @@ defmodule Once do
250230

251231
defp convert_int(int, _), do: {:ok, int}
252232

253-
defp if_ok({:ok, value}, then), do: then.(value)
254-
defp if_ok(other, _), do: other
233+
# paddingless url64 en/decoding
234+
defp encode64(value), do: {:ok, Base.url_encode64(value, padding: false)}
235+
defp decode64(value), do: Base.url_decode64(value, padding: false)
236+
237+
# hex en/decoding
238+
defp encode16(value), do: {:ok, Base.encode16(value)}
239+
defp decode16(value), do: Base.decode16(value, case: :mixed)
240+
241+
# identify the value's format, where ints are grouped together for convert_int/2 to deal with
242+
defp identify_format(value)
243+
defp identify_format(<<_::88>>), do: :url64
244+
defp identify_format(<<_::64>>), do: :raw
245+
defp identify_format(<<_::128>>), do: :hex
246+
defp identify_format(int) when is_integer(int), do: :int
247+
defp identify_format(_), do: :error
248+
249+
# convert (or verify) a value from one format to another
250+
defp maybe_convert(format_in, value, format_out)
251+
defp maybe_convert(:raw, value, :raw), do: {:ok, value}
252+
253+
defp maybe_convert(:url64, value, :url64) do
254+
# we must check that the encoding is valid
255+
case decode64(value) do
256+
{:ok, _} -> {:ok, value}
257+
_ -> :error
258+
end
259+
end
260+
261+
defp maybe_convert(:hex, value, :hex) do
262+
case decode16(value) do
263+
{:ok, _} -> {:ok, value}
264+
_ -> :error
265+
end
266+
end
267+
268+
defp maybe_convert(:int, value, int_format) when int_format in @int_formats,
269+
do: convert_int(value, int_format)
270+
271+
defp maybe_convert(format_in, value, format_out),
272+
do: value |> to_raw(format_in) |> from_raw(format_out)
273+
274+
# convert a value to raw format
275+
defp to_raw(value, from_format)
276+
defp to_raw(value, :raw), do: {:ok, value}
277+
defp to_raw(value, :url64), do: decode64(value)
278+
defp to_raw(value, :hex), do: decode16(value)
279+
280+
defp to_raw(value, :int) do
281+
case convert_int(value, :signed) do
282+
{:ok, int} -> {:ok, <<int::signed-64>>}
283+
_ -> :error
284+
end
285+
end
286+
287+
defp to_raw(_, :error), do: :error
255288

256-
defp encode(value), do: {:ok, Base.url_encode64(value, padding: false)}
257-
defp decode(value), do: Base.url_decode64(value, padding: false)
289+
# convert a raw value to another format
290+
defp from_raw(raw, to_format)
291+
defp from_raw({:ok, raw}, :raw), do: {:ok, raw}
292+
defp from_raw({:ok, raw}, :url64), do: encode64(raw)
293+
defp from_raw({:ok, raw}, :hex), do: encode16(raw)
294+
defp from_raw({:ok, <<int::signed-64>>}, :signed), do: {:ok, int}
295+
defp from_raw({:ok, <<int::unsigned-64>>}, :unsigned), do: {:ok, int}
296+
defp from_raw(_, _), do: :error
258297
end

test/once_unit_test.exs

+56-16
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,74 @@ defmodule OnceUnitTest do
99

1010
describe "to_format/2" do
1111
@format_tests [
12-
%{encoded: "AAAAAAAAAAA", raw: <<0, 0, 0, 0, 0, 0, 0, 0>>, signed: 0, unsigned: 0},
1312
%{
14-
encoded: "__________8",
13+
url64: "AAAAAAAAAAA",
14+
raw: <<0, 0, 0, 0, 0, 0, 0, 0>>,
15+
signed: 0,
16+
unsigned: 0,
17+
hex: "0000000000000000"
18+
},
19+
%{
20+
url64: "__________8",
1521
raw: <<255, 255, 255, 255, 255, 255, 255, 255>>,
1622
signed: -1,
17-
unsigned: @unsigned_max
23+
unsigned: @unsigned_max,
24+
hex: "FFFFFFFFFFFFFFFF"
1825
},
1926
%{
20-
encoded: "f_________8",
27+
url64: "f_________8",
2128
raw: <<127, 255, 255, 255, 255, 255, 255, 255>>,
2229
signed: @signed_max,
23-
unsigned: @signed_max
30+
unsigned: @signed_max,
31+
hex: "7FFFFFFFFFFFFFFF"
2432
},
2533
%{
26-
encoded: "gAAAAAAAAAA",
34+
url64: "gAAAAAAAAAA",
2735
raw: <<128, 0, 0, 0, 0, 0, 0, 0>>,
2836
signed: @signed_min,
29-
unsigned: @signed_max + 1
37+
unsigned: @signed_max + 1,
38+
hex: "8000000000000000"
39+
},
40+
# invalid inputs
41+
%{
42+
invalid: @range,
43+
url64: :error,
44+
raw: :error,
45+
signed: :error,
46+
unsigned: :error,
47+
hex: :error
48+
},
49+
%{
50+
invalid: @signed_min - 1,
51+
url64: :error,
52+
raw: :error,
53+
signed: :error,
54+
unsigned: :error,
55+
hex: :error
56+
},
57+
%{invalid: "a", url64: :error, raw: :error, signed: :error, unsigned: :error, hex: :error},
58+
%{
59+
invalid: "++++++++++A",
60+
url64: :error,
61+
raw: :error,
62+
signed: :error,
63+
unsigned: :error,
64+
hex: :error
3065
},
31-
# out of bounds
32-
%{invalid: @range, encoded: :error, raw: :error, signed: :error, unsigned: :error},
33-
%{invalid: @signed_min - 1, encoded: :error, raw: :error, signed: :error, unsigned: :error}
66+
%{
67+
invalid: "XX12121212121212",
68+
url64: :error,
69+
raw: :error,
70+
signed: :error,
71+
unsigned: :error,
72+
hex: :error
73+
}
3474
]
3575

3676
for formats_values <- @format_tests,
3777
{format_in, input} <- formats_values,
3878
{format_out, output} <- formats_values,
39-
input != :error and format_in != :invalid do
79+
input != :error and not (format_in == :invalid and format_out == :invalid) do
4080
test "should map [#{format_in}: #{inspect(input)}] to [#{format_out}: #{inspect(output)}]" do
4181
case Once.to_format(unquote(input), unquote(format_out)) do
4282
{:ok, result} -> result
@@ -58,14 +98,14 @@ defmodule OnceUnitTest do
5898
assert %{
5999
db_format: :signed,
60100
encrypt?: false,
61-
ex_format: :encoded,
101+
ex_format: :url64,
62102
no_noncense: Once
63103
} == Once.init()
64104
end
65105

66106
test "overrides defaults" do
67-
assert %{db_format: :encoded, ex_format: :signed} =
68-
Once.init(db_format: :encoded, ex_format: :signed)
107+
assert %{db_format: :url64, ex_format: :signed} =
108+
Once.init(db_format: :url64, ex_format: :signed)
69109
end
70110
end
71111

@@ -80,7 +120,7 @@ defmodule OnceUnitTest do
80120
params = %{encrypt?: false, no_noncense: Once}
81121

82122
assert <<_::64>> = Map.put(params, :ex_format, :raw) |> Once.autogenerate()
83-
assert <<_::88>> = Map.put(params, :ex_format, :encoded) |> Once.autogenerate()
123+
assert <<_::88>> = Map.put(params, :ex_format, :url64) |> Once.autogenerate()
84124
int = Map.put(params, :ex_format, :signed) |> Once.autogenerate()
85125
assert is_integer(int)
86126
end
@@ -101,7 +141,7 @@ defmodule OnceUnitTest do
101141
}
102142

103143
assert <<_::64>> = Map.put(params, :ex_format, :raw) |> Once.autogenerate()
104-
assert <<_::88>> = Map.put(params, :ex_format, :encoded) |> Once.autogenerate()
144+
assert <<_::88>> = Map.put(params, :ex_format, :url64) |> Once.autogenerate()
105145
int = Map.put(params, :ex_format, :signed) |> Once.autogenerate()
106146
assert is_integer(int)
107147
end

test/support/my_app/schema.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule MyApp.Schema do
88
field :bigint_id, Once
99

1010
field :bin_id, Once, db_format: :raw
11-
field :string_id, Once, db_format: :encoded
11+
field :string_id, Once, db_format: :url64
1212

1313
# mysql-only
1414
field :unsigned_id, Once, db_format: :unsigned, load_in_query: false

0 commit comments

Comments
 (0)