Skip to content

Commit f5711f0

Browse files
committed
Cover Localize.Ecto.Postgres directly and guard against silently skipped SQLite suites
1 parent d689ee7 commit f5711f0

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,5 +183,46 @@ jobs:
183183
- name: Verify the extension was built
184184
run: test -f priv/localize_icu.so
185185

186+
# test_helper raises when LOCALIZE_ECTO_SQLITE_ICU is set but the
187+
# extension cannot be found, so this cannot pass with the SQLite
188+
# suites silently excluded.
186189
- name: Run tests including the SQLite suites
187190
run: mix test
191+
192+
# The Makefile has a separate Darwin branch (dylib, -dynamiclib,
193+
# keg-only Homebrew ICU) that the Linux job never exercises, and most
194+
# users building the extension are on macOS. Build only — service
195+
# containers are Linux-only, so there is no PostgreSQL here to run the
196+
# parity suite against.
197+
sqlite-icu-macos:
198+
name: "SQLite ICU extension (macOS build)"
199+
runs-on: macos-latest
200+
201+
env:
202+
LOCALIZE_ECTO_SQLITE_ICU: "true"
203+
204+
steps:
205+
- name: Checkout
206+
uses: actions/checkout@v5
207+
208+
# pkg-config is already on the macOS runner image; installing it
209+
# here risks colliding with Homebrew's pkgconf rename.
210+
- name: Install ICU
211+
run: brew install icu4c
212+
213+
- name: Set up Elixir
214+
uses: erlef/setup-beam@v1
215+
with:
216+
elixir-version: "1.20.2-otp-29"
217+
otp-version: "29"
218+
219+
- name: Install dependencies
220+
run: mix deps.get
221+
222+
- name: Build the extension
223+
run: mix compile
224+
225+
- name: Verify the extension was built and exports its entry point
226+
run: |
227+
test -f priv/localize_icu.dylib
228+
nm -gU priv/localize_icu.dylib | grep -q _sqlite3_localizeicu_init

mix.exs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ defmodule LocalizeEcto.MixProject do
2424
dialyzer: [plt_add_apps: [:mix, :ecto_sql]],
2525
# The audit mix task is CLI glue over `Localize.Ecto.Audit`
2626
# (which is covered); mix tasks are excluded from coverage
27-
# measurement as in the localize repo.
28-
test_coverage: [ignore_modules: [~r/^Mix\.Tasks\./]]
27+
# measurement as in the localize repo. The test repos are
28+
# scaffolding from test/support — coverage measures lib/, and the
29+
# SQLite repo is not even started when the extension is absent.
30+
test_coverage: [
31+
ignore_modules: [~r/^Mix\.Tasks\./, Localize.Ecto.TestRepo, Localize.Ecto.SQLiteTestRepo]
32+
]
2933
]
3034
end
3135

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
defmodule Localize.Ecto.PostgresTest do
2+
# `Localize.Ecto` exposes the same macros for backwards compatibility
3+
# and is covered by test/localize/ecto_test.exs. This suite drives
4+
# Localize.Ecto.Postgres directly so the named adapter module is
5+
# exercised on its own terms rather than by inference from the alias.
6+
use ExUnit.Case, async: true
7+
8+
import Ecto.Query
9+
import Localize.Ecto.Postgres
10+
11+
require Localize.Ecto
12+
13+
doctest Localize.Ecto.Postgres
14+
15+
defp to_sql(query) do
16+
{query, _cast_params, _dump_params} =
17+
Ecto.Adapter.Queryable.plan_query(:all, Ecto.Adapters.Postgres, query)
18+
19+
IO.iodata_to_binary(Ecto.Adapters.Postgres.Connection.all(query))
20+
end
21+
22+
describe "collate/1,2" do
23+
test "collate/1 resolves the current locale" do
24+
query = from p in "products", order_by: collate(p.name), select: p.name
25+
26+
assert to_sql(query) =~ ~s[ORDER BY p0."name" COLLATE "en-x-icu"]
27+
end
28+
29+
test "collate/2 emits the quoted collation name" do
30+
query = from p in "products", order_by: collate(p.name, "sv"), select: p.name
31+
32+
assert to_sql(query) =~ ~s[ORDER BY p0."name" COLLATE "sv-x-icu"]
33+
end
34+
35+
test "accepts a pinned runtime locale" do
36+
locale = "de-DE"
37+
query = from p in "products", order_by: collate(p.name, ^locale), select: p.name
38+
39+
assert to_sql(query) =~ ~s[COLLATE "de-x-icu"]
40+
end
41+
42+
test "accepts a collation name given directly" do
43+
query =
44+
from p in "products",
45+
order_by: collate(p.name, collation: "german_phonebook"),
46+
select: p.name
47+
48+
assert to_sql(query) =~ ~s[COLLATE "german_phonebook"]
49+
end
50+
51+
test "carries a BCP 47 collation type into the name" do
52+
query = from p in "products", order_by: collate(p.name, "de-u-co-phonebk"), select: p.name
53+
54+
assert to_sql(query) =~ ~s[COLLATE "de-u-co-phonebk-x-icu"]
55+
end
56+
57+
test "collates a comparison" do
58+
query = from p in "products", where: collate(p.name < "münchen", "de"), select: p.name
59+
60+
assert to_sql(query) =~ ~s[WHERE (p0."name" < 'münchen' COLLATE "de-x-icu")]
61+
end
62+
63+
test "collates each comparison operator" do
64+
equality = from p in "products", select: collate(p.name == p.description, "de")
65+
inequality = from p in "products", select: collate(p.name != p.description, "de")
66+
greater = from p in "products", select: collate(p.name >= p.description, "de")
67+
68+
assert to_sql(equality) =~ ~s[p0."name" = p0."description" COLLATE "de-x-icu"]
69+
assert to_sql(inequality) =~ ~s[p0."name" <> p0."description" COLLATE "de-x-icu"]
70+
assert to_sql(greater) =~ ~s[p0."name" >= p0."description" COLLATE "de-x-icu"]
71+
end
72+
end
73+
74+
describe "case mapping" do
75+
# PostgreSQL takes case mapping from the collation of the argument,
76+
# which is what distinguishes these from the SQLite expansions.
77+
test "lower/1 and lower/2 collate the argument" do
78+
assert to_sql(from p in "products", select: lower(p.name)) =~
79+
~s[lower(p0."name" COLLATE "en-x-icu")]
80+
81+
assert to_sql(from p in "products", select: lower(p.name, "tr")) =~
82+
~s[lower(p0."name" COLLATE "tr-x-icu")]
83+
end
84+
85+
test "upper/1 and upper/2 collate the argument" do
86+
assert to_sql(from p in "products", select: upper(p.name)) =~
87+
~s[upper(p0."name" COLLATE "en-x-icu")]
88+
89+
assert to_sql(from p in "products", select: upper(p.name, "tr")) =~
90+
~s[upper(p0."name" COLLATE "tr-x-icu")]
91+
end
92+
93+
test "initcap/1 and initcap/2 collate the argument" do
94+
assert to_sql(from p in "products", select: initcap(p.name)) =~
95+
~s[initcap(p0."name" COLLATE "en-x-icu")]
96+
97+
assert to_sql(from p in "products", select: initcap(p.name, "nl")) =~
98+
~s[initcap(p0."name" COLLATE "nl-x-icu")]
99+
end
100+
end
101+
102+
describe "at_time_zone/2" do
103+
test "canonicalizes the zone" do
104+
query = from e in "events", select: at_time_zone(e.starts_at, "Australia/Sydney")
105+
106+
assert to_sql(query) =~ ~s[AT TIME ZONE]
107+
end
108+
109+
test "accepts a pinned zone" do
110+
zone = "Australia/Sydney"
111+
query = from e in "events", select: at_time_zone(e.starts_at, ^zone)
112+
113+
assert to_sql(query) =~ ~s[AT TIME ZONE]
114+
end
115+
116+
test "raises on an unknown zone when the query is built" do
117+
assert_raise Localize.UnknownTimezoneError, fn ->
118+
Localize.Ecto.Type.TimeZone.canonicalize!("Mars/Olympus_Mons")
119+
end
120+
end
121+
end
122+
123+
describe "ts_match/2,3" do
124+
test "ts_match/2 uses the current locale's configuration" do
125+
query = from p in "products", where: ts_match(p.description, "chair"), select: p.id
126+
127+
assert to_sql(query) =~ "to_tsvector"
128+
assert to_sql(query) =~ "websearch_to_tsquery"
129+
end
130+
131+
test "ts_match/3 resolves the locale's configuration" do
132+
query = from p in "products", where: ts_match(p.description, "stuhl", "de"), select: p.id
133+
134+
assert to_sql(query) =~ "to_tsvector"
135+
end
136+
137+
test "ts_match/3 accepts a configuration name directly" do
138+
query =
139+
from p in "products",
140+
where: ts_match(p.description, "stuhl", config: "german"),
141+
select: p.id
142+
143+
assert to_sql(query) =~ "to_tsvector"
144+
end
145+
146+
# A pinned locale is deliberately not tested here: for a literal
147+
# binary the compiler can prove the macro's is_list/1 branch dead
148+
# and warns. The pin itself goes through Builder.unpin/1, which the
149+
# collate/2 pinned-locale test above already covers.
150+
end
151+
152+
describe "parity with Localize.Ecto" do
153+
test "the compatibility module emits identical SQL" do
154+
via_alias =
155+
from p in "products", order_by: Localize.Ecto.collate(p.name, "sv"), select: p.name
156+
157+
via_module = from p in "products", order_by: collate(p.name, "sv"), select: p.name
158+
159+
assert to_sql(via_alias) == to_sql(via_module)
160+
end
161+
end
162+
end

test/test_helper.exs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ end
2727
# unless it is present, so the default `mix test` stays pure Elixir.
2828
sqlite_icu? = Localize.Ecto.SQLite3.Extension.available?()
2929

30+
# When the build was opted into, the extension must be found. Without
31+
# this check a CI job that builds the extension but cannot locate it
32+
# afterwards would silently exclude every SQLite test and still report
33+
# success — the run would be green having tested none of it.
34+
if not sqlite_icu? and
35+
String.downcase(System.get_env("LOCALIZE_ECTO_SQLITE_ICU", "false")) == "true" do
36+
raise """
37+
LOCALIZE_ECTO_SQLITE_ICU is set, but no localize_icu extension was found at
38+
#{Localize.Ecto.SQLite3.Extension.path()}.{so,dylib}
39+
40+
The SQLite test suites would be silently excluded. Check that the c_src
41+
build ran and that ICU is installed.
42+
"""
43+
end
44+
3045
if sqlite_icu? do
3146
sqlite_database = Path.join(System.tmp_dir!(), "localize_ecto_test.sqlite3")
3247

0 commit comments

Comments
 (0)