Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Do not merge] [CPDNPQ-1078] [SPIKE] Implement geocoded local authority search #787

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on: [push]

env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:@localhost:5432
DATABASE_URL: postgres://postgres:password@localhost:5432

jobs:
tests:
Expand All @@ -14,10 +14,10 @@ jobs:

services:
postgres:
image: postgres:11.6-alpine
image: postgis/postgis:11-3.3-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_PASSWORD: 'password'
ports:
- 5432:5432
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
Expand All @@ -44,6 +44,10 @@ jobs:
restore-keys: |
${{ runner.os }}-gems-

- name: Install postgis extensions
run: |
sudo apt-get install libproj-dev proj-bin

- name: Install gems
run: |
bundle config path vendor/bundle
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ RUN apk add --update --no-cache tzdata && \
# build-base: complication tools for bundle
# yarn: node package manager
# postgresql-dev: postgres driver and libraries
RUN apk add --no-cache build-base yarn postgresql-dev
RUN apk add --no-cache build-base yarn postgresql-dev postgis proj proj-dev

# Install bundler to run bundle exec
# This should be the same version as the Gemfile.lock
Expand Down
6 changes: 6 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ gem "sentry-ruby"
gem "stimulus-rails"
gem "webpacker"

gem "activerecord-postgis-adapter"
gem "geocoder"
gem "rgeo"
gem "rgeo-geojson"
gem "rgeo-proj4"

gem "net-imap", require: false
gem "net-pop", require: false
gem "net-smtp", require: false
Expand Down
17 changes: 17 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ GEM
activerecord (6.1.7.3)
activemodel (= 6.1.7.3)
activesupport (= 6.1.7.3)
activerecord-postgis-adapter (7.1.1)
activerecord (~> 6.1)
rgeo-activerecord (~> 7.0.0)
activerecord-session_store (2.0.0)
actionpack (>= 5.2.4.1)
activerecord (>= 5.2.4.1)
Expand Down Expand Up @@ -178,6 +181,7 @@ GEM
rack (>= 1.4, < 3)
rack-protection (>= 1.5.3, <= 4.0.0)
sanitize (< 7)
geocoder (1.8.1)
globalid (1.1.0)
activesupport (>= 5.0)
govuk-components (3.1.1)
Expand Down Expand Up @@ -356,6 +360,14 @@ GEM
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.5)
rgeo (3.0.0)
rgeo-activerecord (7.0.1)
activerecord (>= 5.0)
rgeo (>= 1.0.0)
rgeo-geojson (2.1.1)
rgeo (>= 1.0.0)
rgeo-proj4 (4.0.0)
rgeo (~> 3.0.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
Expand Down Expand Up @@ -507,6 +519,7 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
activerecord-postgis-adapter
activerecord-session_store
axe-core-capybara
axe-core-rspec
Expand All @@ -525,6 +538,7 @@ DEPENDENCIES
flipper (~> 0.28.0)
flipper-active_record (~> 0.28.0)
flipper-ui (~> 0.28.0)
geocoder
govuk-components
govuk_design_system_formbuilder
httparty (~> 0.21)
Expand All @@ -551,6 +565,9 @@ DEPENDENCIES
puma (~> 6.2)
rack-attack
rails (~> 6.1.7)
rgeo
rgeo-geojson
rgeo-proj4
rspec-rails (~> 6.0.2)
rubocop-govuk
scss_lint-govuk
Expand Down
45 changes: 45 additions & 0 deletions app/lib/services/geojson_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Services
class GeojsonLoader
def self.reload_geojsons(geojson_path)
empty_database
load_geojsons(geojson_path)
end

def self.empty_database
GeoLocalAuthority.delete_all
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider inlining instead of separate method.

end

def self.read_file(geojson_path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would move this method below load_geojsons method as its helper.

file = File.read(geojson_path)
features = RGeo::GeoJSON.decode(file, json_parser: :json)
end

def self.load_geojsons(geojson_path)
Rails.logger.debug("Loading #{geojson_path}")

features = read_file(geojson_path)

features.each do |feature|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to wrap it into transaction?

geo_local_authority = GeoLocalAuthority.new
geo_local_authority.name = feature.properties["LAD22NM"]
geo_local_authority.geometry = feature.geometry
geo_local_authority.save!
end
end

class Shape
attr_reader :geojson_record

def initialize(geojson_record)
@geojson_record = geojson_record
end

delegate :geometry,
to: :geojson_record

def name
geojson_record.attributes["LAD22NM"]
end
end
end
end
31 changes: 31 additions & 0 deletions app/models/geo_local_authority.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class GeoLocalAuthority < ApplicationRecord
include RGeo::ActiveRecord::GeometryMixin

SRID = 27_700

def self.nearest_three_to(string)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe self.nearest_to(string, amount: 3) instead hardcoding argument in method name?

location = Geocoder.search(string).first
by_distance(location.longitude.to_f, location.latitude.to_f).limit(3)
end

def self.by_distance(longitude, latitude)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How many results does it returns by default? Could it turn out to be expensive as do extensive search?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't really return any results - it returns AR scope. The scope itslef uses some of the postgis magic which is rathere quite performant. I wouldn't expect any delays based on the size of the database in this service

# Transform the given lon/lat to have an SRID of 27700 (the SRID of the GeoLocalAuthority geometries)
transformed_point = RGeo::CoordSys::Proj4.transform_coords(
RGeo::CoordSys::Proj4.new("EPSG:4326"),
RGeo::CoordSys::Proj4.new("EPSG:27700"),
longitude,
latitude,
)

target_latitude = transformed_point[1]
target_longitude = transformed_point[0]

# Create a point representing the target coordinates, with the SRID specified
# If you don't specify the SRID it will default to 0, which will cause an error
point_sql = "SRID=#{SRID};POINT(#{target_longitude} #{target_latitude})"

arel = Arel.sql("geometry <-> '#{point_sql}'::geometry")

order(arel)
end
end
4 changes: 2 additions & 2 deletions config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
# gem 'pg'
#
default: &default
adapter: postgresql
adapter: postgis
encoding: unicode
# For details on connection pooling, see Rails configuration guide
# http://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
url: <%= ENV.fetch("DATABASE_URL", "postgres://localhost:5432") %>
url: <%= ENV.fetch("DATABASE_URL", "postgis://localhost:5432") %>

development:
<<: *default
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20230502125034_enable_postgis.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class EnablePostgis < ActiveRecord::Migration[6.1]
def change
enable_extension "postgis"
end
end
12 changes: 12 additions & 0 deletions db/migrate/20230502125316_create_geo_local_authorities.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateGeoLocalAuthorities < ActiveRecord::Migration[6.1]
def change
create_table :geo_local_authorities do |t|
t.string :name
t.geometry :geometry, srid: 27_700

t.timestamps
end

add_index :geo_local_authorities, :geometry, using: :gist
end
end
11 changes: 10 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2023_04_14_121939) do
ActiveRecord::Schema.define(version: 2023_05_02_125316) do

# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "citext"
enable_extension "plpgsql"
enable_extension "postgis"

create_table "applications", force: :cascade do |t|
t.bigint "user_id", null: false
Expand Down Expand Up @@ -110,6 +111,14 @@
t.index ["feature_key", "key", "value"], name: "index_flipper_gates_on_feature_key_and_key_and_value", unique: true
end

create_table "geo_local_authorities", force: :cascade do |t|
t.string "name"
t.geometry "geometry", limit: {:srid=>27700, :type=>"geometry"}
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["geometry"], name: "index_geo_local_authorities_on_geometry", using: :gist
end

create_table "get_an_identity_webhook_messages", force: :cascade do |t|
t.jsonb "raw"
t.jsonb "message"
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ volumes:

services:
db:
image: postgres:11-alpine
image: postgis/postgis:11-3.3-alpine
restart: always
# To preserve data between runs of docker-compose, we mount a folder from the host machine.
volumes:
Expand Down

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions lib/tasks/geojson_loading.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace :geojson do
desc "Load geojsons into the database, will empty local authorities table first"
task load: :environment do |_t, _args|
geojson_path = "lib/local_authority_geojson/Local_Authority_Districts_December_2022_Boundaries_UK_BUC_143497700576642915.geojson"

puts("Loading geojsons from #{geojson_path}")

Services::GeojsonLoader.reload_geojsons(geojson_path)
end

desc "Load geojson file and output local authorities found"
task test: :environment do |_t, _args|
geojson_path = "lib/local_authority_geojson/Local_Authority_Districts_December_2022_Boundaries_UK_BUC_143497700576642915.geojson"

features = Services::GeojsonLoader.read_file(geojson_path)

puts "File contains #{features.count} local authority geometries"

local_authorities_found = features.map { |feature| feature.properties["LAD22NM"] }.sort.join(", ")

puts "Local Authorities found: #{local_authorities_found}"
end
end
90 changes: 90 additions & 0 deletions spec/models/geo_local_authority_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
require "rails_helper"

RSpec.describe GeoLocalAuthority, type: :model do
before do
geojson_path = "lib/local_authority_geojsons/Local_Authority_Districts_December_2022_Boundaries_UK_BUC_143497700576642915.geojson"

Services::GeojsonLoader.reload_geojsons(geojson_path)

Geocoder.configure(lookup: :test)
Geocoder::Lookup::Test.add_stub(
"Department for Education, Westminster", [{
"latitude" => 51.4979314,
"longitude" => -0.13016544222858017,
"address" => "Department for Education, 20, Great Smith Street, Westminster, Millbank, London, Greater London, England, SW1P 3BT, United Kingdom",
"state" => "England",
"country" => "United Kingdom",
"country_code" => "gb",
}]
)
Geocoder::Lookup::Test.add_stub(
"Manchester Airport", [{
"latitude" => 53.350342049999995,
"longitude" => -2.280369252664295,
"address" => "Manchester Airport, Castle Mill Lane, Ashley, Manchester, Cheshire East, England, M90 1QX, United Kingdom",
"state" => "England",
"country" => "United Kingdom",
"country_code" => "gb",
}]
)
Geocoder::Lookup::Test.add_stub(
"Belfast City Airport", [{
"latitude" => 54.614828,
"longitude" => -5.8703437,
"address" => "Bushmills Bar, Sydenham Bypass, Sydenham, Belfast, County Down, Ulster, Northern Ireland, BT3 9JH, United Kingdom",
"state" => "Northern Ireland",
"country" => "United Kingdom",
"country_code" => "gb",
}]
)
Geocoder::Lookup::Test.add_stub(
"Edinburgh Airport", [{
"latitude" => 55.950128899999996,
"longitude" => -3.3595079855289756,
"address" => "Edinburgh Airport, Meadowfield Road, Gogar, City of Edinburgh, Scotland, EH12 0AU, United Kingdom",
"state" => "Scotland",
"country" => "United Kingdom",
"country_code" => "gb",
}]
)
Geocoder::Lookup::Test.add_stub(
"Cardiff Airport", [{
"latitude" => 51.397871550000005,
"longitude" => -3.3445890119919994,
"address" => "Cardiff Airport, B4265, Fonmon, Rhoose, Penmark, Vale of Glamorgan, Wales, CF62 3BL, United Kingdom",
"state" => "Wales",
"country" => "United Kingdom",
"country_code" => "gb",
}]
)

Geocoder::Lookup::Test.add_stub(
"Sydney Opera House", [{
"latitude" => -33.85719805,
"longitude" => 151.21512338473752,
"address" => "Sydney Opera House, 2, Macquarie Street, Quay Quarter, Sydney, Council of the City of Sydney, New South Wales, 2000, Australia",
"state" => "New South Wales",
"country" => "Australia",
"country_code" => "au",
}]
)
end

describe "#nearest_three_to" do
def check_nearest_three_to(location)
described_class.nearest_three_to(location).pluck(:name)
end

it "returns the three nearest local authorities" do
# Do some quick checks around the UK to check it works in various areas
expect(check_nearest_three_to("Department for Education, Westminster")).to eq(%w[Westminster Lambeth Wandsworth])
expect(check_nearest_three_to("Manchester Airport")).to eq(["Manchester", "Cheshire East", "Trafford"])
expect(check_nearest_three_to("Belfast City Airport")).to eq(["Belfast", "Ards and North Down", "Lisburn and Castlereagh"])
expect(check_nearest_three_to("Edinburgh Airport")).to eq(["City of Edinburgh", "West Lothian", "Fife"])
expect(check_nearest_three_to("Cardiff Airport")).to eq(["Vale of Glamorgan", "Cardiff", "Rhondda Cynon Taf"])

# And one across the world to check it doesn't break
expect(check_nearest_three_to("Sydney Opera House")).to eq(["Cornwall", "Isles of Scilly", "South Hams"])
end
end
end
Loading