From 6a79f48541cd3afb232de207d3e113ddbe6e3427 Mon Sep 17 00:00:00 2001 From: Malte Rohde Date: Fri, 1 Dec 2023 15:11:47 +0100 Subject: [PATCH] Add BitcrowdEcto.FixedWidthInteger --- lib/bitcrowd_ecto/fixed_width_integer.ex | 66 +++++++++++++++++++ .../fixed_width_integer_test.exs | 50 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 lib/bitcrowd_ecto/fixed_width_integer.ex create mode 100644 test/bitcrowd_ecto/fixed_width_integer_test.exs diff --git a/lib/bitcrowd_ecto/fixed_width_integer.ex b/lib/bitcrowd_ecto/fixed_width_integer.ex new file mode 100644 index 0000000..6ccf200 --- /dev/null +++ b/lib/bitcrowd_ecto/fixed_width_integer.ex @@ -0,0 +1,66 @@ +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 + + @postgres_type_ranges %{ + 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 + } + + @generic_byte_size_ranges %{ + 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 + } + + @impl true + def init(opts) do + opts + |> Keyword.get(:width, 4) + |> width_to_range() + end + + defp width_to_range(type) when is_atom(type), do: Map.fetch!(@postgres_type_ranges, type) + defp width_to_range(size) when is_integer(size), do: Map.fetch!(@generic_byte_size_ranges, size) + + @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..4b0b34a --- /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, -32_768, 32_767] do + cs = cast(%TestSchema{}, %{int_smallint: ok}, [:int_smallint]) + assert cs.valid? + end + + for not_ok <- [-32_769, 32_768] 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