diff --git a/lib/bitcrowd_ecto/fixed_width_integer.ex b/lib/bitcrowd_ecto/fixed_width_integer.ex new file mode 100644 index 0000000..8873f01 --- /dev/null +++ b/lib/bitcrowd_ecto/fixed_width_integer.ex @@ -0,0 +1,56 @@ +defmodule BitcrowdEcto.FixedWidthInteger do + @moduledoc """ + An Ecto type that automatically validates that the given integer fits the underlying DB type. + + This turns the ugly Postgrex errors into neat `validation: :cast` changeset errors without + having to manually `validate_number` all `:integer` fields. + + Named widths are based on Postgres' integer types. + + https://www.postgresql.org/docs/current/datatype-numeric.html + """ + + use Ecto.ParameterizedType + + @impl true + def init(opts) do + case Keyword.get(opts, :width, 4) do + 2 -> -32_768..32_767 + 4 -> -2_147_483_648..2_147_483_647 + 8 -> -9_223_372_036_854_775_808..9_223_372_036_854_775_807 + :smallint -> -32_768..32_767 + :integer -> -2_147_483_648..2_147_483_647 + :bigint -> -9_223_372_036_854_775_808..9_223_372_036_854_775_807 + :smallserial -> 1..32_767 + :serial -> 1..2_147_483_647 + :bigserial -> 1..9_223_372_036_854_775_807 + end + end + + @impl true + def type(_range), do: :integer + + @impl true + def cast(value, range) do + if is_integer(value) and value not in range do + :error + else + Ecto.Type.cast(:integer, value) + end + end + + @impl true + def load(value, loader, _range) do + Ecto.Type.load(:integer, value, loader) + end + + @impl true + def dump(value, dumper, _range) do + Ecto.Type.dump(:integer, value, dumper) + end + + @impl true + def equal?(a, b, _range) do + a == b + end +end diff --git a/test/bitcrowd_ecto/fixed_width_integer_test.exs b/test/bitcrowd_ecto/fixed_width_integer_test.exs new file mode 100644 index 0000000..0a58d8b --- /dev/null +++ b/test/bitcrowd_ecto/fixed_width_integer_test.exs @@ -0,0 +1,50 @@ +defmodule BitcrowdEcto.FixedWidthIntegerTest do + use ExUnit.Case, async: true + import BitcrowdEcto.Assertions + import Ecto.Changeset + + defmodule TestSchema do + use Ecto.Schema + + embedded_schema do + field(:int_4, BitcrowdEcto.FixedWidthInteger, width: 4) + field(:int_smallint, BitcrowdEcto.FixedWidthInteger, width: :smallint) + field(:int_bigserial, BitcrowdEcto.FixedWidthInteger, width: :bigserial) + end + end + + test "casting an out-of-range value results in a changeset error" do + for ok <- [-2, 2, 0, -2_147_483_648, 2_147_483_647] do + cs = cast(%TestSchema{}, %{int_4: ok}, [:int_4]) + assert cs.valid? + end + + for not_ok <- [-2_147_483_649, 2_147_483_648] do + cs = cast(%TestSchema{}, %{int_4: not_ok}, [:int_4]) + refute cs.valid? + assert_error_on(cs, :int_4, :cast) + end + + for ok <- [-2, 2, 0, -32768, 32767] do + cs = cast(%TestSchema{}, %{int_smallint: ok}, [:int_smallint]) + assert cs.valid? + end + + for not_ok <- [-32769, 32768] do + cs = cast(%TestSchema{}, %{int_smallint: not_ok}, [:int_smallint]) + refute cs.valid? + assert_error_on(cs, :int_smallint, :cast) + end + + for ok <- [1, 9_223_372_036_854_775_807] do + cs = cast(%TestSchema{}, %{int_bigserial: ok}, [:int_bigserial]) + assert cs.valid? + end + + for not_ok <- [-1, 0, 9_223_372_036_854_775_808] do + cs = cast(%TestSchema{}, %{int_bigserial: not_ok}, [:int_bigserial]) + refute cs.valid? + assert_error_on(cs, :int_bigserial, :cast) + end + end +end