Skip to content

Commit db6200e

Browse files
committed
Fix with-usage cast for string-keyed maps and raise test coverage
1 parent 0497fe5 commit db6200e

5 files changed

Lines changed: 427 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ Renamed from `localize_ecto` to **`localize_sql`** — the package is about SQL
2222

2323
* `Localize.Ecto.Migration.Generator` renders and writes migrations for the SQL-generating mix tasks.
2424

25+
### Fixed
26+
27+
* `Localize.UnitWithUsage.Ecto.Composite.Type.cast/1` now accepts a string-keyed map with no `"usage"` key, casting it as a unit without a usage. Only the atom-keyed form tolerated the key's absence, so an HTML form — which submits string keys — could not cast a unit unless it also submitted a usage.
28+
2529
### Changed
2630

2731
* Requires `localize ~> 1.0`. `postgrex` is an optional dependency; the range, duration and tagged-decimal composite types compile only when it is present, so a SQLite-only or collation-only consumer carries nothing extra.

lib/localize/unit/ecto/with_usage_composite_type.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ if Code.ensure_loaded?(Ecto.Type) do
8484
end
8585
end
8686

87+
# A form need not submit a usage. The atom-keyed clause below has
88+
# always tolerated its absence; this does the same for string keys,
89+
# which is what an HTML form supplies. The guard is what stops a map
90+
# that already carries an unmatched "usage" from recursing here.
91+
def cast(%{"unit" => unit_name, "value" => value} = unit)
92+
when not is_map_key(unit, "usage") do
93+
cast(%{"unit" => unit_name, "value" => value, "usage" => nil})
94+
end
95+
8796
def cast(%{unit: unit_name, value: value} = unit) do
8897
cast(%{"unit" => unit_name, "value" => value, "usage" => Map.get(unit, :usage)})
8998
end
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
defmodule Localize.Ecto.TypeEdgeCaseTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Localize.Currency.Ecto.Type, as: Currency
5+
alias Localize.Duration.Ecto.Type, as: LocalizeDuration
6+
alias Localize.Ecto.Type.DateRange
7+
alias Localize.Ecto.Type.Duration, as: ElixirDuration
8+
alias Localize.Ecto.Type.IntegerRange
9+
alias Localize.LanguageTag.Ecto.Type, as: LanguageTag
10+
alias Localize.Script.Ecto.Type, as: Script
11+
alias Localize.Territory.Ecto.Type, as: Territory
12+
13+
# Every type must treat nil as nil in all three directions, and refuse
14+
# a value of the wrong shape rather than raising.
15+
describe "nil handling" do
16+
test "casts, loads and dumps nil" do
17+
for type <- [Currency, Territory, Script, LanguageTag, LocalizeDuration, ElixirDuration] do
18+
assert type.cast(nil) == {:ok, nil}, "#{inspect(type)} cast"
19+
assert type.load(nil) == {:ok, nil}, "#{inspect(type)} load"
20+
assert type.dump(nil) == {:ok, nil}, "#{inspect(type)} dump"
21+
end
22+
end
23+
24+
test "range types treat nil as nil" do
25+
for type <- [IntegerRange, DateRange] do
26+
assert type.cast(nil) == {:ok, nil}, "#{inspect(type)} cast"
27+
assert type.load(nil) == {:ok, nil}, "#{inspect(type)} load"
28+
assert type.dump(nil) == {:ok, nil}, "#{inspect(type)} dump"
29+
end
30+
end
31+
end
32+
33+
describe "wrong-shaped values are refused, not raised" do
34+
test "code types refuse non-codes" do
35+
for type <- [Currency, Territory, Script] do
36+
assert type.cast(1.5) == :error, "#{inspect(type)} cast"
37+
assert type.load(42) == :error, "#{inspect(type)} load"
38+
assert type.dump(1.5) == :error, "#{inspect(type)} dump"
39+
end
40+
end
41+
42+
test "language tag refuses a non-identifier" do
43+
assert LanguageTag.cast(42) == :error
44+
assert LanguageTag.load(42) == :error
45+
assert LanguageTag.dump(42) == :error
46+
end
47+
48+
test "duration types refuse non-durations" do
49+
for type <- [LocalizeDuration, ElixirDuration] do
50+
assert type.cast(3600) == :error, "#{inspect(type)} cast"
51+
assert type.load(3600) == :error, "#{inspect(type)} load"
52+
assert type.dump(3600) == :error, "#{inspect(type)} dump"
53+
end
54+
end
55+
56+
test "range types refuse non-ranges" do
57+
for type <- [IntegerRange, DateRange] do
58+
assert type.load("not a range") == :error, "#{inspect(type)} load"
59+
assert type.dump("not a range") == :error, "#{inspect(type)} dump"
60+
end
61+
end
62+
end
63+
64+
describe "code types accept every documented input form" do
65+
test "currency accepts atom, string and struct" do
66+
assert Currency.cast(:USD) == {:ok, :USD}
67+
assert Currency.cast("usd") == {:ok, :USD}
68+
assert Currency.cast(%Localize.Currency{code: :EUR}) == {:ok, :EUR}
69+
assert Currency.dump(%Localize.Currency{code: :EUR}) == {:ok, "EUR"}
70+
end
71+
72+
test "territory accepts atom and string" do
73+
assert Territory.cast(:GB) == {:ok, :GB}
74+
assert Territory.dump("gb") == {:ok, "GB"}
75+
end
76+
77+
test "script accepts atom and string" do
78+
assert Script.cast(:Latn) == {:ok, :Latn}
79+
assert Script.dump("cyrl") == {:ok, "Cyrl"}
80+
end
81+
82+
test "unknown codes are refused on dump as well as cast" do
83+
assert Currency.dump(:ZZZ) == :error
84+
assert Territory.dump(:ZZZZ) == :error
85+
assert Script.dump(:Xyzq) == :error
86+
end
87+
end
88+
89+
describe "language tag" do
90+
test "dumps a locale given as a string or atom" do
91+
assert LanguageTag.dump("en-US") == {:ok, "en-US"}
92+
assert LanguageTag.dump(:"en-US") == {:ok, "en-US"}
93+
end
94+
95+
test "dumping an invalid identifier fails" do
96+
assert LanguageTag.dump("this-is-not-a-locale") == :error
97+
end
98+
99+
test "a tag passes through cast unchanged" do
100+
{:ok, tag} = Localize.validate_locale("de-DE")
101+
assert LanguageTag.cast(tag) == {:ok, tag}
102+
end
103+
end
104+
105+
describe "durations" do
106+
test "an empty duration round trips" do
107+
{:ok, dumped} = LocalizeDuration.dump(%Localize.Duration{})
108+
assert {:ok, %Localize.Duration{}} = LocalizeDuration.load(dumped)
109+
end
110+
111+
test "an Elixir duration with only microseconds round trips" do
112+
duration = Duration.new!(microsecond: {500, 6})
113+
{:ok, dumped} = ElixirDuration.dump(duration)
114+
{:ok, loaded} = ElixirDuration.load(dumped)
115+
116+
assert loaded.microsecond == {500, 6}
117+
end
118+
119+
test "both report interval as their database type" do
120+
assert LocalizeDuration.type() == :interval
121+
assert ElixirDuration.type() == :interval
122+
end
123+
end
124+
125+
describe "ranges" do
126+
test "report their database types" do
127+
assert IntegerRange.type() == :int8range
128+
assert DateRange.type() == :daterange
129+
end
130+
131+
test "an integer range round trips through its dumped form" do
132+
{:ok, dumped} = IntegerRange.dump(1..10)
133+
assert IntegerRange.load(dumped) == {:ok, 1..10}
134+
end
135+
136+
test "a date range round trips through its dumped form" do
137+
range = Date.range(~D[2024-01-01], ~D[2024-01-31])
138+
{:ok, dumped} = DateRange.dump(range)
139+
assert DateRange.load(dumped) == {:ok, range}
140+
end
141+
142+
test "an unbounded date range has no Elixir equivalent" do
143+
assert DateRange.load(%Postgrex.Range{
144+
lower: nil,
145+
upper: ~D[2024-12-31],
146+
lower_inclusive: false,
147+
upper_inclusive: true
148+
}) == :error
149+
end
150+
151+
test "a descending date range is refused on dump" do
152+
assert DateRange.dump(Date.range(~D[2024-12-31], ~D[2024-01-01], -1)) == :error
153+
end
154+
end
155+
end

test/localize/language_tag/ecto_type_test.exs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ defmodule Localize.LanguageTag.Ecto.TypeTest do
2424
assert Type.cast(tag) == {:ok, tag}
2525
end
2626

27-
test "canonicalizes so equivalent identifiers cast equal" do
27+
# The tags are not identical — each preserves the identifier it was
28+
# asked for in `requested_locale_id` — but they resolve to the same
29+
# locale and therefore store identically.
30+
test "canonicalizes so equivalent identifiers store identically" do
2831
{:ok, underscored} = Type.cast("en_US")
2932
{:ok, hyphenated} = Type.cast("en-US")
3033

31-
assert underscored == hyphenated
34+
assert Type.dump(underscored) == Type.dump(hyphenated)
35+
assert Type.dump(underscored) == {:ok, "en-US"}
3236
end
3337

3438
test "casts nil" do

0 commit comments

Comments
 (0)