diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f9847839..b083b656 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: diff --git a/History.md b/History.md index ffa44c84..3aada46a 100644 --- a/History.md +++ b/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 diff --git a/README.md b/README.md index 8ff7a40e..700154e4 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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, diff --git a/lib/active_record/connection_adapters/postgis/oid/spatial.rb b/lib/active_record/connection_adapters/postgis/oid/spatial.rb index 50eb2aa2..b97cb155 100644 --- a/lib/active_record/connection_adapters/postgis/oid/spatial.rb +++ b/lib/active_record/connection_adapters/postgis/oid/spatial.rb @@ -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), ... @@ -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 @@ -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 diff --git a/lib/active_record/connection_adapters/postgis/schema_statements.rb b/lib/active_record/connection_adapters/postgis/schema_statements.rb index 1a1be6a4..cf88ea1b 100644 --- a/lib/active_record/connection_adapters/postgis/schema_statements.rb +++ b/lib/active_record/connection_adapters/postgis/schema_statements.rb @@ -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 diff --git a/lib/active_record/connection_adapters/postgis/spatial_column.rb b/lib/active_record/connection_adapters/postgis/spatial_column.rb index 528f72f9..75c1e7d4 100644 --- a/lib/active_record/connection_adapters/postgis/spatial_column.rb +++ b/lib/active_record/connection_adapters/postgis/spatial_column.rb @@ -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 diff --git a/lib/active_record/connection_adapters/postgis/type.rb b/lib/active_record/connection_adapters/postgis/type.rb new file mode 100644 index 00000000..babd22c5 --- /dev/null +++ b/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 diff --git a/lib/active_record/connection_adapters/postgis_adapter.rb b/lib/active_record/connection_adapters/postgis_adapter.rb index c60ee076..65a81205 100644 --- a/lib/active_record/connection_adapters/postgis_adapter.rb +++ b/lib/active_record/connection_adapters/postgis_adapter.rb @@ -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" @@ -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 diff --git a/test/attributes_test.rb b/test/attributes_test.rb new file mode 100644 index 00000000..e53ad251 --- /dev/null +++ b/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 diff --git a/test/basic_test.rb b/test/basic_test.rb index 1bb6e932..2e36ab8a 100644 --- a/test/basic_test.rb +++ b/test/basic_test.rb @@ -3,6 +3,10 @@ require "test_helper" class BasicTest < ActiveSupport::TestCase + def setup + reset_spatial_store + end + def test_version refute_nil ActiveRecord::ConnectionAdapters::PostGIS::VERSION end @@ -112,7 +116,6 @@ def test_custom_factory object.save! object.reload assert_equal area.to_s, object.area.to_s - spatial_factory_store.clear end def test_spatial_factory_attrs_parsing @@ -135,8 +138,6 @@ def test_spatial_factory_attrs_parsing object.save! object.reload assert_equal(factory, object.areas.factory) - - spatial_factory_store.clear end def test_readme_example @@ -165,8 +166,6 @@ def test_readme_example object.save! object.reload refute_equal geo_factory, object.shape.factory - - spatial_factory_store.clear end def test_point_to_json diff --git a/test/test_helper.rb b/test/test_helper.rb index c1dace16..93820347 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,6 +21,8 @@ def self.establish_test_connection end end +ActiveRecord::Base.establish_test_connection + class SpatialModel < ActiveRecord::Base establish_test_connection end @@ -48,5 +50,10 @@ def geographic_factory def spatial_factory_store RGeo::ActiveRecord::SpatialFactoryStore.instance end + + def reset_spatial_store + spatial_factory_store.clear + spatial_factory_store.default = nil + end end end diff --git a/test/type_test.rb b/test/type_test.rb index a69a4a0b..a6c1b873 100644 --- a/test/type_test.rb +++ b/test/type_test.rb @@ -4,33 +4,33 @@ class TypeTest < ActiveSupport::TestCase def test_parse_simple_type - assert_equal ["geometry", 0, false, false], spatial.parse_sql_type("geometry") - assert_equal ["geography", 0, false, false], spatial.parse_sql_type("geography") + assert_equal ["geometry", 0, false, false, false], spatial.parse_sql_type("geometry") + assert_equal ["geography", 0, false, false, true], spatial.parse_sql_type("geography") end def test_parse_geo_type - assert_equal ["Point", 0, false, false], spatial.parse_sql_type("geography(Point)") - assert_equal ["Point", 0, false, true], spatial.parse_sql_type("geography(PointM)") - assert_equal ["Point", 0, true, false], spatial.parse_sql_type("geography(PointZ)") - assert_equal ["Point", 0, true, true], spatial.parse_sql_type("geography(PointZM)") - assert_equal ["Polygon", 0, false, false], spatial.parse_sql_type("geography(Polygon)") - assert_equal ["Polygon", 0, true, false], spatial.parse_sql_type("geography(PolygonZ)") - assert_equal ["Polygon", 0, false, true], spatial.parse_sql_type("geography(PolygonM)") - assert_equal ["Polygon", 0, true, true], spatial.parse_sql_type("geography(PolygonZM)") + assert_equal ["Point", 0, false, false, true], spatial.parse_sql_type("geography(Point)") + assert_equal ["Point", 0, false, true, true], spatial.parse_sql_type("geography(PointM)") + assert_equal ["Point", 0, true, false, true], spatial.parse_sql_type("geography(PointZ)") + assert_equal ["Point", 0, true, true, true], spatial.parse_sql_type("geography(PointZM)") + assert_equal ["Polygon", 0, false, false, true], spatial.parse_sql_type("geography(Polygon)") + assert_equal ["Polygon", 0, true, false, true], spatial.parse_sql_type("geography(PolygonZ)") + assert_equal ["Polygon", 0, false, true, true], spatial.parse_sql_type("geography(PolygonM)") + assert_equal ["Polygon", 0, true, true, true], spatial.parse_sql_type("geography(PolygonZM)") end def test_parse_type_with_srid - assert_equal ["Point", 4326, false, false], spatial.parse_sql_type("geography(Point,4326)") - assert_equal ["Polygon", 4327, true, false], spatial.parse_sql_type("geography(PolygonZ,4327)") - assert_equal ["Point", 4328, false, true], spatial.parse_sql_type("geography(PointM,4328)") - assert_equal ["Point", 4329, true, true], spatial.parse_sql_type("geography(PointZM,4329)") - assert_equal ["MultiPolygon", 4326, false, false], spatial.parse_sql_type("geometry(MultiPolygon,4326)") + assert_equal ["Point", 4326, false, false, true], spatial.parse_sql_type("geography(Point,4326)") + assert_equal ["Polygon", 4327, true, false, true], spatial.parse_sql_type("geography(PolygonZ,4327)") + assert_equal ["Point", 4328, false, true, true], spatial.parse_sql_type("geography(PointM,4328)") + assert_equal ["Point", 4329, true, true, true], spatial.parse_sql_type("geography(PointZM,4329)") + assert_equal ["MultiPolygon", 4326, false, false, false], spatial.parse_sql_type("geometry(MultiPolygon,4326)") end def test_parse_non_geo_types - assert_equal ["x", 0, false, false], spatial.parse_sql_type("x") - assert_equal ["foo", 0, false, false], spatial.parse_sql_type("foo") - assert_equal ["foo(A,1234)", 0, false, false], spatial.parse_sql_type("foo(A,1234)") + assert_equal ["x", 0, false, false, false], spatial.parse_sql_type("x") + assert_equal ["foo", 0, false, false, false], spatial.parse_sql_type("foo") + assert_equal ["foo(A,1234)", 0, false, false, false], spatial.parse_sql_type("foo(A,1234)") end private