Simple Setup of PostGIS Columns with Phoenix Generators

3 minute read

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.

  1. 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 the Geo.Point struct. These 2 floats will become virtual attributes to our schema struct.

  2. Use the virtual attributes in our generator.

    For a Admin context and a locations 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.

  3. 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
    
  4. Update the generated schema:

    Find lib/my_app/admin/locations.ex to make the lat/lng columns virtual, add the coordinates column, and cast lat/lng to coordinates

    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
    
  5. Update the generated tests:

    Find test/my_app/admin/admin_test.exs and test/my_app/controllers/location_controller_test.exs. These tests must be updated to check the coordinates attribute instead of lat/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
    
  6. Run tests. Everything should be passing!

    Now go celebrate by adding some real functionality!