Simple Setup of PostGIS Columns with Phoenix Generators
How to use Phoenix generators with PostGIS column types, with minimal changes to get tests, changesets, and schema working.
Requirement: I need to create a schema that includes a PostGIS Point columm.
Problem: Phoenix generators are great, but they don’t support special types, such as PostGIS columns.
The docs for geo_postgis are sufficient for figuring out how to work with geographic data types, but they don’t cover how to make it work with Phoenix generators, changesets, or tests.
Goal: Use Phoenix generators (since contexts are too useful to ignore), and have the generated changesets, views, and tests work with only minor tweaks.
How: Use virtual attributes in the Ecto schema to be the bridge to the custom type.
-
First step is to plan out what type of data you will have, and what primitives it is composed of.
In this case, a
Point
has 2 floats, representing latitude and longitude. Most interaction will be with the float values instead of theGeo.Point
struct. These 2 floats will become virtual attributes to our schema struct. -
Use the virtual attributes in our generator.
For a
Admin
context and alocations
table, our generator command will look like this:mix phx.gen.html Admin Location locations lng:float lat:float # ... other columns
There are still a few steps before this works and tests are passing. The migration, schema, and tests need updating.
-
Fix the migration to create the proper column (
coordinates
).defmodule MyApp.Repo.Migrations.CreateLocations do use Ecto.Migration def change do create table(:locations) do # remove the lines for lat/lng. They are commented here to show which lines # add :lat, :float # add :lng, :float end # add the next line, which creates a column `coordinates` on table `locations` execute("SELECT AddGeometryColumn ('locations','coordinates',4326,'POINT',2);") # add an index if necessary create index(:locations, [:coordinates], using: :gist) end end
-
Update the generated schema:
Find
lib/my_app/admin/locations.ex
to make thelat
/lng
columns virtual, add thecoordinates
column, and castlat
/lng
tocoordinates
defmodule MyApp.Admin.Location do use Ecto.Schema import Ecto.Changeset alias MyApp.Admin.Location schema "locations" do field :coordinates, Geo.Point # add the actual column field :lng, :float, virtual: true # add the virtual flag here and below field :lat, :float, virtual: true end @doc false def changeset(%Location{} = location, attrs) do location |> cast(attrs, [:lng, :lat]) |> validate_required([:lng, :lat]) |> cast_coordinates() # remember to cast the coordinates! end # something like this to cast the virtual columns to actual column # it would be good to add validation but I've left it out for brevity def cast_coordinates(changeset) do lat = get_change(changeset, :lat) lng = get_change(changeset, :lng) geo = %Geo.Point{coordinates: {lng, lat}, srid: 4326} changeset |> put_change(:coordinates, geo) end end
-
Update the generated tests:
Find
test/my_app/admin/admin_test.exs
andtest/my_app/controllers/location_controller_test.exs
. These tests must be updated to check thecoordinates
attribute instead oflat
/lng
attributes.# test/my_app/controllers/location_controller_test.exs defmodule MyAppWeb.LocationControllerTest do use MyAppWeb.ConnCase alias MyApp.Admin @create_attrs %{lat: 60.5, lng: 70.5} # ensure that valid values are used @update_attrs %{lat: 45.7, lng: 56.7} # ensure that valid values are used @invalid_attrs %{lat: 200, lng: 200} # use invalid values here # ... end
# test/my_app/admin/admin_test.exs defmodule MyApp.AdminTest do use MyApp.DataCase alias MyApp.Admin describe "locations" do alias MyApp.Admin.Location @create_attrs %{lat: 60.5, lng: 70.5} # ensure that valid values are used @update_attrs %{lat: 45.7, lng: 56.7} # ensure that valid values are used @invalid_attrs %{lat: 200, lng: 200} # use invalid values here # any test checking equality will need to reset the lat/lng attributes # generated test test "list_locations/0 returns all locations" do location = location_fixture() assert Admin.list_locations() == [location] end # updated test test "list_locations/0 returns all locations" do location = %{ location_fixture() | lat: nil, lng: nil} assert Admin.list_locations() == [location] end # for tests checking values, remove the assertions for lat/lng and add one for `coordinates` # generated test test "create_location/1 with valid data creates a location" do assert {:ok, %Location{} = location} = Admin.create_location(@valid_attrs) assert location.lat == 60.5 assert location.lng == 70.5 end # updated test test "create_location/1 with valid data creates a location" do assert {:ok, %Location{} = location} = Admin.create_location(@valid_attrs) assert location.coordinates == %Geo.Point{coordinates: {70.5, 60.5}, srid: 4326} end end
-
Run tests. Everything should be passing!
Now go celebrate by adding some real functionality!