Skip to content

Commit

Permalink
Merge pull request #334 from rgeo/activerecord-attributes
Browse files Browse the repository at this point in the history
Support Attributes
  • Loading branch information
keithdoggett committed Mar 4, 2021
2 parents 56c2d1d + 48e7b56 commit 95be29d
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 43 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Expand Up @@ -18,10 +18,12 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
BUNDLE_GEMFILE: ${{matrix.gemfile}}
strategy:
matrix:
ruby: [3.0, 2.7, 2.6, 2.5]
BUNDLE_GEMFILE:
gemfile:
- gemfiles/ar61.gemfile
pg: [9.6-3.0, 10-2.5, 11-3.0, 12-master, 13-master]
steps:
Expand Down
6 changes: 6 additions & 0 deletions History.md
@@ -1,6 +1,12 @@
### HEAD

* Support Attributes #334
* Access `configuration_hash` using symbols #335

### 7.0.1 / 2021-01-13

* Fix db:gis:setup task #329

### 7.0.0 / 2020-12-22

* Add ActiveRecord 6.1 Compatability (tagliala) #324
Expand Down
62 changes: 61 additions & 1 deletion README.md
Expand Up @@ -29,7 +29,29 @@ RGeo objects can be embedded in where clauses.

## Install

The adapter requires PostgreSQL 9.0+.
The adapter requires PostgreSQL 9.0+ and PostGIS 2.4+.

### Installing PostGIS

Here are common methods for installing PostGIS, but more detailed methods can be found on the [installation guide](https://postgis.net/install/).

#### MacOS

```sh
brew install postgis
```

#### Ubuntu/Debian

```sh
# The second package can be replaced depending on your postgresql version
# ex. postgresql-11-postgis-2 is valid as well
sudo apt-get install postgis postgresql-12-postgis-3
```

#### Windows

PostGIS is likely available as an optional package via your Postgresql installer. If not, refer to the installation guide.

Gemfile:

Expand Down Expand Up @@ -305,6 +327,18 @@ change_table :my_table do |t|
end
```

### Attributes

Models may also define attributes using the above data types and options.

```ruby
class SpatialModel < ActiveRecord::Base
attribute :centroid, :st_point, srid: 4326, geographic: true
end
```

`centroid` will not have an associated column in the `spatial_models` table, but any geometry object assigned to the `centroid` attribute will be cast to a geographic point.

### Point and Polygon Types with ActiveRecord 4.2+

Prior to version 3, the `point` and `polygon` types were supported. In ActiveRecord 4.2, the Postgresql
Expand Down Expand Up @@ -489,6 +523,32 @@ containing_buiildings = Building.where(buildings[:geom].st_contains(point))

See [rgeo-activerecord](https://github.com/rgeo/rgeo-activerecord) for more information about advanced spatial queries.

### Joining Spatial Columns

If a spatial column is joined with another model, `srid` and `geographic` will not be automatically inferred and they will default to 0 and `false`, by default. In order to properly infer these options after a join, an `attribute` must be created on the target table.

```ruby
class SpatialModel < ActiveRecord::Base
belongs_to :foo

# has column geo_point (:st_point, srid: 4326, geographic: true)
end

class Foo < ActiveRecord::Base
has_one :spatial_model

# re-define geo_point here so join works
attribute :geo_point, :st_point, srid: 4326, geographic: true
end

# perform a query where geo_point is joined to foo
foo = Foo.joins(:spatial_models).select("foos.id, spatial_models.geo_point").first
p foo.geo_point.class
# => RGeo::Geographic::SphericalPointImpl
p foo.geo_point.srid
# => 4326
```

## Background: PostGIS

A spatial database is one that includes a set of data types, functions,
Expand Down
29 changes: 14 additions & 15 deletions lib/active_record/connection_adapters/postgis/oid/spatial.rb
Expand Up @@ -4,16 +4,17 @@ module ActiveRecord
module ConnectionAdapters
module PostGIS
module OID
# OID used to represent geometry/geography database types and attributes.
#
# Accepts `geo_type`, `srid`, `has_z`, `has_m`, and `geographic` as parameters.
# Responsible for parsing sql_types returned from the database and WKT features.
class Spatial < Type::Value
# sql_type is a string that comes from the database definition
# examples:
# "geometry(Point,4326)"
# "geography(Point,4326)"
# "geometry(Polygon,4326) NOT NULL"
# "geometry(Geography,4326)"
def initialize(oid, sql_type)
@sql_type = sql_type
@geo_type, @srid, @has_z, @has_m = self.class.parse_sql_type(sql_type)
def initialize(geo_type: 'geometry', srid: 0, has_z: false, has_m: false, geographic: false)
@geo_type = geo_type
@srid = srid
@has_z = has_z
@has_m = has_m
@geographic = geographic
end

# sql_type: geometry, geometry(Point), geometry(Point,4326), ...
Expand Down Expand Up @@ -43,7 +44,9 @@ def self.parse_sql_type(sql_type)
# otherType(a,b)
geo_type = sql_type
end
[geo_type, srid, has_z, has_m]
geographic = sql_type.match?(/geography/)

[geo_type, srid, has_z, has_m, geographic]
end

def spatial_factory
Expand All @@ -53,16 +56,12 @@ def spatial_factory
)
end

def geographic?
@sql_type =~ /geography/
end

def spatial?
true
end

def type
geographic? ? :geography : :geometry
@geographic ? :geography : :geometry
end

# support setting an RGeo object or a WKT string
Expand Down
Expand Up @@ -96,8 +96,15 @@ def initialize_type_map(map = type_map)
st_point
st_polygon
).each do |geo_type|
map.register_type(geo_type) do |oid, _, sql_type|
OID::Spatial.new(oid, sql_type)
map.register_type(geo_type) do |_, _, sql_type|
# sql_type is a string that comes from the database definition
# examples:
# "geometry(Point,4326)"
# "geography(Point,4326)"
# "geometry(Polygon,4326) NOT NULL"
# "geometry(Geography,4326)"
geo_type, srid, has_z, has_m, geographic = OID::Spatial.parse_sql_type(sql_type)
OID::Spatial.new(geo_type: geo_type, srid: srid, has_z: has_z, has_m: has_m, geographic: geographic)
end
end

Expand Down
Expand Up @@ -67,7 +67,7 @@ def set_geometric_type_from_name(name)
end

def build_from_sql_type(sql_type)
geo_type, @srid, @has_z, @has_m = OID::Spatial.parse_sql_type(sql_type)
geo_type, @srid, @has_z, @has_m, @geographic = OID::Spatial.parse_sql_type(sql_type)
set_geometric_type_from_name(geo_type)
end

Expand Down
22 changes: 22 additions & 0 deletions lib/active_record/connection_adapters/postgis/type.rb
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module ActiveRecord
module ConnectionAdapters
module PostGIS
module Type
# Look for :postgis types first, then check for :postgresql
# types to simulate a kind of Type inheritance.
def lookup(*args, adapter: current_adapter_name, **kwargs)
super(*args, adapter: adapter, **kwargs)
rescue ArgumentError => e
raise e unless current_adapter_name == :postgis

super(*args, adapter: :postgresql, **kwargs)
end
end
end
end

# Type uses `class << self` syntax so we have to prepend to the singleton_class
Type.singleton_class.prepend(ActiveRecord::ConnectionAdapters::PostGIS::Type)
end
16 changes: 16 additions & 0 deletions lib/active_record/connection_adapters/postgis_adapter.rb
Expand Up @@ -18,6 +18,7 @@
require "active_record/connection_adapters/postgis/arel_tosql"
require "active_record/connection_adapters/postgis/setup"
require "active_record/connection_adapters/postgis/oid/spatial"
require "active_record/connection_adapters/postgis/type" # has to be after oid/*
require "active_record/connection_adapters/postgis/create_connection"
require "active_record/connection_adapters/postgis/postgis_database_tasks"

Expand Down Expand Up @@ -87,6 +88,21 @@ def quote(value)
super
end
end

# PostGIS specific types
[
:geography,
:geometry,
:geometry_collection,
:line_string,
:multi_line_string,
:multi_point,
:multi_polygon,
:st_point,
:st_polygon,
].each do |geo_type|
ActiveRecord::Type.register(geo_type, PostGIS::OID::Spatial, adapter: :postgis)
end
end
end
end
117 changes: 117 additions & 0 deletions test/attributes_test.rb
@@ -0,0 +1,117 @@
# frozen_string_literal: true

require "test_helper"

class Foo < ActiveRecord::Base
establish_test_connection
has_one :spatial_foo
attribute :bar, :string, array: true
attribute :baz, :string, range: true
end

class SpatialFoo < ActiveRecord::Base
establish_test_connection
attribute :point, :st_point, srid: 3857
attribute :pointz, :st_point, has_z: true, srid: 3509
attribute :pointm, :st_point, has_m: true, srid: 3509
attribute :polygon, :st_polygon, srid: 3857
attribute :path, :line_string, srid: 3857
attribute :geo_path, :line_string, geographic: true, srid: 4326
end

class InvalidAttribute < ActiveRecord::Base
establish_test_connection
end

class AttributesTest < ActiveSupport::TestCase
def setup
reset_spatial_store
create_foo
create_spatial_foo
create_invalid_attributes
end

def test_postgresql_attributes_registered
assert Foo.attribute_names.include?("bar")
assert Foo.attribute_names.include?("baz")

data = Foo.new
data.bar = %w[a b c]
data.baz = "1".."3"

assert_equal data.bar, %w[a b c]
assert_equal data.baz, "1".."3"
end

def test_invalid_attribute
assert_raises(ArgumentError) do
InvalidAttribute.attribute(:attr, :invalid_attr)
InvalidAttribute.new
end
end

def test_spatial_attributes
data = SpatialFoo.new
data.point = "POINT(0 0)"
data.pointz = "POINT(0 0 1)"
data.pointm = "POINT(0 0 2)"
data.polygon = "POLYGON((0 0, 0 1, 1 1, 1 0, 0 0))"
data.path = "LINESTRING(0 0, 0 1, 1 1, 1 0, 0 0)"
data.geo_path = "LINESTRING(-75.165222 39.952583,-73.561668 45.508888)"

assert_equal 3857, data.point.srid
assert_equal 0, data.point.x
assert_equal 0, data.point.y

assert_equal 3509, data.pointz.srid
assert_equal 1, data.pointz.z

assert_equal 3509, data.pointm.srid
assert_equal 2, data.pointm.m

assert_equal 3857, data.polygon.srid
assert_equal 3857, data.path.srid
assert_equal data.path, data.polygon.exterior_ring

assert_equal 4326, data.geo_path.srid
assert_equal RGeo::Geographic::Factory, data.geo_path.factory.class
end

def test_joined_spatial_attribute
# TODO: The attributes that will be joined have to be defined on the
# model we make the query with. Ideally, it would "just work" but
# at least this workaround makes joining functional.
Foo.attribute :geo_point, :st_point, srid: 4326, geographic: true
Foo.attribute :cart_point, :st_point, srid: 3509

foo = Foo.create
SpatialFoo.create(foo_id: foo.id, geo_point: "POINT(10 10)", cart_point: "POINT(2 2)")

# query foo and join child spatial foo on it
foo = Foo.joins(:spatial_foo).select("foos.id, spatial_foos.geo_point, spatial_foos.cart_point").first
assert_equal 4326, foo.geo_point.srid
assert_equal 3509, foo.cart_point.srid
assert_equal foo.geo_point, SpatialFoo.first.geo_point
assert_equal foo.cart_point, SpatialFoo.first.cart_point
end

private

def create_foo
Foo.connection.create_table :foos, force: true do |t|
end
end

def create_spatial_foo
SpatialFoo.connection.create_table :spatial_foos, force: true do |t|
t.references :foo
t.st_point :geo_point, geographic: true, srid: 4326
t.st_point :cart_point, srid: 3509
end
end

def create_invalid_attributes
InvalidAttribute.connection.create_table :invalid_attributes, force: true do |t|
end
end
end

0 comments on commit 95be29d

Please sign in to comment.