activerecord-postgis 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +128 -9
- data/activerecord-postgis.gemspec +1 -1
- data/lib/active_record/connection_adapters/postgis/column_methods.rb +1 -0
- data/lib/active_record/connection_adapters/postgis/quoting.rb +9 -7
- data/lib/active_record/connection_adapters/postgis/schema_statements.rb +1 -0
- data/lib/active_record/connection_adapters/postgis/spatial_column_type.rb +9 -4
- data/lib/active_record/connection_adapters/postgis/spatial_queries.rb +96 -0
- data/lib/active_record/connection_adapters/postgis/test_helpers.rb +202 -0
- data/lib/active_record/connection_adapters/postgis/version.rb +1 -1
- data/lib/active_record/connection_adapters/postgis.rb +12 -0
- data/lib/activerecord-postgis/test_helper.rb +51 -0
- data/lib/arel/visitors/postgis.rb +145 -0
- metadata +9 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b80f70c84d88643edef39e5d702779a4248b05c03ed692c09139bad00f7f5009
|
|
4
|
+
data.tar.gz: 9f923717999ac80231119e353356f8f9570c04e17ef560e84aa0656132ec0618
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aff8a1b78d4b16f498c013652b5fd0d1b9b268fe9292197e7d799e12c8cfa562be6f6ba9a118fd3bab8cef19f028439db1befa6da02528e81c7aede7669560d6
|
|
7
|
+
data.tar.gz: 8cb2c7bfe70dfdab95a8c8d535f5b7397be7b03978808b01a3169979cc6c417eae1f4a9c8e24043b5196aa5bfb678bd50c1778393ed40429f10b58d82f3666fd
|
data/README.md
CHANGED
|
@@ -93,22 +93,42 @@ parks_in_city = Park.where(
|
|
|
93
93
|
)
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
**With Arel Spatial Methods
|
|
96
|
+
**With Arel Spatial Methods** (Now with expanded arsenal!):
|
|
97
97
|
|
|
98
98
|
```ruby
|
|
99
|
-
#
|
|
99
|
+
# Basic spatial queries
|
|
100
100
|
Location.where(
|
|
101
101
|
Location.arel_table[:coordinates].st_distance(point).lt(1000)
|
|
102
102
|
)
|
|
103
103
|
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
# NEW: K-Nearest Neighbor - Lightning fast "find nearest" queries
|
|
105
|
+
# Uses spatial index for incredible performance!
|
|
106
|
+
nearest_locations = Location
|
|
107
|
+
.order(Location.arel_table[:coordinates].distance_operator(my_position))
|
|
108
|
+
.limit(10)
|
|
109
|
+
|
|
110
|
+
# Advanced spatial predicates
|
|
111
|
+
# Find intersecting routes
|
|
112
|
+
Route.where(Route.arel_table[:path].st_intersects(restricted_zone))
|
|
113
|
+
|
|
114
|
+
# Find points within efficient distance (uses spatial index!)
|
|
115
|
+
Location.where(
|
|
116
|
+
Location.arel_table[:coordinates].st_dwithin(headquarters, 5000)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Create buffer zones
|
|
120
|
+
safe_zones = DangerZone.select(
|
|
121
|
+
DangerZone.arel_table[:area].st_buffer(100).as('safety_perimeter')
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Transform between coordinate systems
|
|
125
|
+
global_coords = Location.select(
|
|
126
|
+
Location.arel_table[:local_position].st_transform(4326).as('wgs84_position')
|
|
107
127
|
)
|
|
108
128
|
|
|
109
|
-
# Calculate
|
|
110
|
-
|
|
111
|
-
|
|
129
|
+
# Calculate areas
|
|
130
|
+
territories = Region.select(
|
|
131
|
+
Region.arel_table[:boundary].st_area.as('territory_size')
|
|
112
132
|
)
|
|
113
133
|
```
|
|
114
134
|
|
|
@@ -139,6 +159,97 @@ puts location.coordinates.x # -5.923647
|
|
|
139
159
|
puts location.coordinates.y # 35.790897
|
|
140
160
|
```
|
|
141
161
|
|
|
162
|
+
## Testing
|
|
163
|
+
|
|
164
|
+
When testing spatial functionality in your Rails application, this gem provides helpful test utilities:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# In your test_helper.rb or rails_helper.rb
|
|
168
|
+
require 'activerecord-postgis/test_helper'
|
|
169
|
+
|
|
170
|
+
class ActiveSupport::TestCase
|
|
171
|
+
include ActiveRecordPostgis::TestHelper
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Or for RSpec
|
|
175
|
+
RSpec.configure do |config|
|
|
176
|
+
config.include ActiveRecordPostgis::TestHelper
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Test Helper Methods
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class LocationTest < ActiveSupport::TestCase
|
|
184
|
+
def test_spatial_operations
|
|
185
|
+
# Create test geometries
|
|
186
|
+
point1 = create_point(-5.9, 35.8)
|
|
187
|
+
point2 = create_point(-5.91, 35.81)
|
|
188
|
+
polygon = create_test_polygon
|
|
189
|
+
|
|
190
|
+
location = Location.create!(coordinates: point1, boundary: polygon)
|
|
191
|
+
|
|
192
|
+
# Traditional assertions
|
|
193
|
+
assert_spatial_equal point1, location.coordinates
|
|
194
|
+
assert_within_distance point1, point2, 200 # meters
|
|
195
|
+
assert_contains polygon, point1
|
|
196
|
+
|
|
197
|
+
# New chainable syntax (recommended)
|
|
198
|
+
assert_spatial_column(location.coordinates)
|
|
199
|
+
.has_srid(4326)
|
|
200
|
+
.is_type(:point)
|
|
201
|
+
.is_geographic
|
|
202
|
+
|
|
203
|
+
assert_spatial_column(location.boundary)
|
|
204
|
+
.is_type(:polygon)
|
|
205
|
+
.has_srid(4326)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def test_3d_geometry
|
|
209
|
+
point_3d = create_point(1.0, 2.0, srid: 4326, z: 10.0)
|
|
210
|
+
|
|
211
|
+
assert_spatial_column(point_3d)
|
|
212
|
+
.has_z
|
|
213
|
+
.has_srid(4326)
|
|
214
|
+
.is_type(:point)
|
|
215
|
+
.is_cartesian
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Available Test Helpers:**
|
|
221
|
+
|
|
222
|
+
**Traditional Assertions:**
|
|
223
|
+
- `assert_spatial_equal(expected, actual)` - Assert spatial objects are equal
|
|
224
|
+
- `assert_within_distance(point1, point2, distance)` - Assert points within distance
|
|
225
|
+
- `assert_contains(container, contained)` - Assert geometry contains another
|
|
226
|
+
- `assert_within(inner, outer)` - Assert geometry is within another
|
|
227
|
+
- `assert_intersects(geom1, geom2)` - Assert geometries intersect
|
|
228
|
+
- `assert_disjoint(geom1, geom2)` - Assert geometries don't intersect
|
|
229
|
+
|
|
230
|
+
**Chainable Spatial Column Assertions:**
|
|
231
|
+
- `assert_spatial_column(geometry).has_z` - Assert has Z dimension
|
|
232
|
+
- `assert_spatial_column(geometry).has_m` - Assert has M dimension
|
|
233
|
+
- `assert_spatial_column(geometry).has_srid(srid)` - Assert SRID value
|
|
234
|
+
- `assert_spatial_column(geometry).is_type(type)` - Assert geometry type
|
|
235
|
+
- `assert_spatial_column(geometry).is_geographic` - Assert geographic factory
|
|
236
|
+
- `assert_spatial_column(geometry).is_cartesian` - Assert cartesian factory
|
|
237
|
+
|
|
238
|
+
**Geometry Factories:**
|
|
239
|
+
- `create_point(x, y, srid: 4326)` - Create test points
|
|
240
|
+
- `create_test_polygon(srid: 4326)` - Create test polygons
|
|
241
|
+
- `create_test_linestring(srid: 4326)` - Create test linestrings
|
|
242
|
+
- `factory(srid: 4326, geographic: false)` - Get geometry factory
|
|
243
|
+
- `geographic_factory(srid: 4326)` - Get geographic factory
|
|
244
|
+
- `cartesian_factory(srid: 0)` - Get cartesian factory
|
|
245
|
+
|
|
246
|
+
## Documentation
|
|
247
|
+
|
|
248
|
+
📚 **Learn Like You're Defending the Galaxy**
|
|
249
|
+
|
|
250
|
+
- [🚀 Spatial Warfare Manual](docs/SPATIAL_WARFARE.md) - Advanced PostGIS arsenal explained through space combat
|
|
251
|
+
- [🍳 The PostGIS Cookbook](docs/COOKBOOK.md) - Real-world recipes from delivery fleets to geofencing
|
|
252
|
+
|
|
142
253
|
## Features
|
|
143
254
|
|
|
144
255
|
🌍 **Complete PostGIS Type Support**
|
|
@@ -148,7 +259,14 @@ puts location.coordinates.y # 35.790897
|
|
|
148
259
|
- Support for SRID, Z/M dimensions
|
|
149
260
|
|
|
150
261
|
🔍 **Spatial Query Methods**
|
|
151
|
-
- `st_distance`, `st_contains`, `st_within`, `st_length`
|
|
262
|
+
- Core methods: `st_distance`, `st_contains`, `st_within`, `st_length`
|
|
263
|
+
- **NEW:** Advanced spatial operations:
|
|
264
|
+
- `<->` (distance_operator) - K-Nearest Neighbor search (blazing fast!)
|
|
265
|
+
- `st_intersects` - Detect geometry intersections
|
|
266
|
+
- `st_dwithin` - Efficient proximity queries (index-optimized!)
|
|
267
|
+
- `st_buffer` - Create buffer zones around geometries
|
|
268
|
+
- `st_transform` - Convert between coordinate systems
|
|
269
|
+
- `st_area` - Calculate polygon areas
|
|
152
270
|
- Custom Arel visitor for PostGIS SQL generation
|
|
153
271
|
- Seamless integration with ActiveRecord queries
|
|
154
272
|
|
|
@@ -163,6 +281,7 @@ puts location.coordinates.y # 35.790897
|
|
|
163
281
|
- Works with existing PostgreSQL tools
|
|
164
282
|
- Clear error messages and debugging
|
|
165
283
|
- Full RGeo integration
|
|
284
|
+
- Comprehensive test helpers for spatial assertions
|
|
166
285
|
|
|
167
286
|
## Acknowledgments
|
|
168
287
|
|
|
@@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
|
|
|
22
22
|
spec.files = Dir.glob('{lib}/**/*') + [ gemspec, 'LICENSE.txt', 'README.md' ]
|
|
23
23
|
spec.require_paths = [ 'lib' ]
|
|
24
24
|
|
|
25
|
-
spec.add_dependency 'activerecord', '>= 8.0', '< 8.
|
|
25
|
+
spec.add_dependency 'activerecord', '>= 8.1.0', '< 8.2'
|
|
26
26
|
spec.add_dependency 'pg'
|
|
27
27
|
spec.add_dependency 'rgeo-activerecord', '>= 8.0'
|
|
28
28
|
end
|
|
@@ -18,13 +18,15 @@ module ActiveRecord
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def type_cast(value)
|
|
21
|
-
if
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
if RGeo::Feature::Geometry.check_type(value)
|
|
22
|
+
# Use EWKB format to preserve SRID information
|
|
23
|
+
RGeo::WKRep::WKBGenerator.new(
|
|
24
|
+
hex_format: true,
|
|
25
|
+
type_format: :ewkb,
|
|
26
|
+
emit_ewkb_srid: true
|
|
27
|
+
).generate(value)
|
|
28
|
+
elsif value.is_a?(RGeo::Cartesian::BoundingBox)
|
|
29
|
+
value.to_s
|
|
28
30
|
else
|
|
29
31
|
super
|
|
30
32
|
end
|
|
@@ -120,10 +120,15 @@ module ActiveRecord
|
|
|
120
120
|
return base_type if @type == "geography"
|
|
121
121
|
|
|
122
122
|
type_with_dimensions = build_type_with_dimensions
|
|
123
|
-
# Include SRID if specified and not the default for the type
|
|
124
|
-
# Geography
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
# Include SRID if specified and not the default for the column type
|
|
124
|
+
# Geography columns: only include SRID if it's not the default 4326
|
|
125
|
+
# Geometry columns: always include SRID when specified and not 0
|
|
126
|
+
if @geography
|
|
127
|
+
should_include_srid = @srid && @srid != 4326
|
|
128
|
+
else
|
|
129
|
+
# For geometry columns, always include SRID when explicitly specified and not 0
|
|
130
|
+
should_include_srid = @srid && @srid != 0
|
|
131
|
+
end
|
|
127
132
|
|
|
128
133
|
if should_include_srid
|
|
129
134
|
"#{base_type}(#{type_with_dimensions},#{@srid})"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module PostGIS
|
|
6
|
+
module SpatialQueries
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
# Safe wrapper for ST_Distance queries
|
|
11
|
+
# Usage: Model.where_st_distance(:column, lon, lat, '<', distance)
|
|
12
|
+
# For geographic calculations (meters), cast to geography
|
|
13
|
+
def where_st_distance(column, lon, lat, operator, distance, srid: 4326, geographic: false)
|
|
14
|
+
if geographic
|
|
15
|
+
where("ST_Distance(#{column}::geography, ST_SetSRID(ST_MakePoint(?, ?), ?)::geography) #{operator} ?", lon, lat, srid, distance)
|
|
16
|
+
else
|
|
17
|
+
where("ST_Distance(#{column}, ST_SetSRID(ST_MakePoint(?, ?), ?)) #{operator} ?", lon, lat, srid, distance)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Safe wrapper for ST_DWithin queries
|
|
22
|
+
# Usage: Model.where_st_dwithin(:column, lon, lat, distance)
|
|
23
|
+
# For geographic calculations (meters), cast to geography
|
|
24
|
+
def where_st_dwithin(column, lon, lat, distance, srid: 4326, geographic: false)
|
|
25
|
+
if geographic
|
|
26
|
+
where("ST_DWithin(#{column}::geography, ST_SetSRID(ST_MakePoint(?, ?), ?)::geography, ?)", lon, lat, srid, distance)
|
|
27
|
+
else
|
|
28
|
+
where("ST_DWithin(#{column}, ST_SetSRID(ST_MakePoint(?, ?), ?), ?)", lon, lat, srid, distance)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Safe wrapper for ST_Contains with point
|
|
33
|
+
# Usage: Model.where_st_contains(:column, lon, lat)
|
|
34
|
+
def where_st_contains(column, lon, lat, srid: 4326)
|
|
35
|
+
where("ST_Contains(#{column}, ST_SetSRID(ST_MakePoint(?, ?), ?))", lon, lat, srid)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Safe wrapper for ST_Within with point
|
|
39
|
+
# Usage: Model.where_st_within_point(:column, lon, lat)
|
|
40
|
+
def where_st_within_point(column, lon, lat, srid: 4326)
|
|
41
|
+
where("ST_Within(ST_SetSRID(ST_MakePoint(?, ?), ?), #{column})", lon, lat, srid)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Safe wrapper for ST_Intersects with WKT geometry
|
|
45
|
+
# Usage: Model.where_st_intersects(:column, wkt_string)
|
|
46
|
+
def where_st_intersects(column, wkt, srid: 4326)
|
|
47
|
+
where("ST_Intersects(#{column}, ST_GeomFromText(?, ?))", wkt, srid)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generic safe wrapper for any PostGIS function with a point parameter
|
|
51
|
+
# Usage: Model.where_st_function('ST_Distance', :column, lon, lat, '<', value)
|
|
52
|
+
def where_st_function(function, column, lon, lat, operator = nil, value = nil, srid: 4326)
|
|
53
|
+
if operator && value
|
|
54
|
+
where("#{function}(#{column}, ST_SetSRID(ST_MakePoint(?, ?), ?)) #{operator} ?", lon, lat, srid, value)
|
|
55
|
+
else
|
|
56
|
+
where("#{function}(#{column}, ST_SetSRID(ST_MakePoint(?, ?), ?))", lon, lat, srid)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Module to include in models for instance methods
|
|
63
|
+
module SpatialScopes
|
|
64
|
+
extend ActiveSupport::Concern
|
|
65
|
+
|
|
66
|
+
included do
|
|
67
|
+
# Define commonly used spatial scopes
|
|
68
|
+
scope :within_distance, ->(column, lon, lat, distance, srid: 4326, geographic: false) {
|
|
69
|
+
where_st_distance(column, lon, lat, "<", distance, srid: srid, geographic: geographic)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
scope :beyond_distance, ->(column, lon, lat, distance, srid: 4326, geographic: false) {
|
|
73
|
+
where_st_distance(column, lon, lat, ">", distance, srid: srid, geographic: geographic)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
scope :near, ->(column, lon, lat, distance, srid: 4326, geographic: false) {
|
|
77
|
+
where_st_dwithin(column, lon, lat, distance, srid: srid, geographic: geographic)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
scope :containing_point, ->(column, lon, lat, srid: 4326) {
|
|
81
|
+
where_st_contains(column, lon, lat, srid: srid)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
scope :intersecting, ->(column, wkt, srid: 4326) {
|
|
85
|
+
where_st_intersects(column, wkt, srid: srid)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Automatically include in ActiveRecord::Base
|
|
94
|
+
ActiveSupport.on_load(:active_record) do
|
|
95
|
+
ActiveRecord::Base.include(ActiveRecord::ConnectionAdapters::PostGIS::SpatialQueries)
|
|
96
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module PostGIS
|
|
6
|
+
# Test helpers for spatial data assertions
|
|
7
|
+
module TestHelpers
|
|
8
|
+
# Assert that two spatial objects are equal
|
|
9
|
+
def assert_spatial_equal(expected, actual, msg = nil)
|
|
10
|
+
msg ||= "Expected spatial object #{expected.as_text} but got #{actual.as_text}"
|
|
11
|
+
|
|
12
|
+
if expected.respond_to?(:equals?) && actual.respond_to?(:equals?)
|
|
13
|
+
assert expected.equals?(actual), msg
|
|
14
|
+
else
|
|
15
|
+
assert_equal expected.to_s, actual.to_s, msg
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Assert that a point is within a specified distance of another point
|
|
20
|
+
def assert_within_distance(point1, point2, distance, msg = nil)
|
|
21
|
+
actual_distance = point1.distance(point2)
|
|
22
|
+
msg ||= "Distance #{actual_distance} exceeds maximum allowed distance of #{distance}"
|
|
23
|
+
assert actual_distance <= distance, msg
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Assert that a geometry contains another geometry
|
|
27
|
+
def assert_contains(container, contained, msg = nil)
|
|
28
|
+
msg ||= "Expected #{container.as_text} to contain #{contained.as_text}"
|
|
29
|
+
assert container.contains?(contained), msg
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Assert that a geometry is within another geometry
|
|
33
|
+
def assert_within(inner, outer, msg = nil)
|
|
34
|
+
msg ||= "Expected #{inner.as_text} to be within #{outer.as_text}"
|
|
35
|
+
assert inner.within?(outer), msg
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Assert that two geometries intersect
|
|
39
|
+
def assert_intersects(geom1, geom2, msg = nil)
|
|
40
|
+
msg ||= "Expected #{geom1.as_text} to intersect #{geom2.as_text}"
|
|
41
|
+
assert geom1.intersects?(geom2), msg
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Assert that two geometries do not intersect
|
|
45
|
+
def assert_disjoint(geom1, geom2, msg = nil)
|
|
46
|
+
msg ||= "Expected #{geom1.as_text} to be disjoint from #{geom2.as_text}"
|
|
47
|
+
assert geom1.disjoint?(geom2), msg
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Assert that a geometry has the expected SRID
|
|
51
|
+
def assert_srid(geometry, expected_srid, msg = nil)
|
|
52
|
+
actual_srid = geometry.srid
|
|
53
|
+
msg ||= "Expected SRID #{expected_srid} but got #{actual_srid}"
|
|
54
|
+
assert_equal expected_srid, actual_srid, msg
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Chainable spatial column assertion builder
|
|
58
|
+
def assert_spatial_column(geometry, msg_prefix = nil)
|
|
59
|
+
SpatialColumnAssertion.new(geometry, self, msg_prefix)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Assert that a geometry is of the expected type
|
|
63
|
+
def assert_geometry_type(geometry, expected_type, msg = nil)
|
|
64
|
+
actual_type = geometry.geometry_type.type_name.downcase
|
|
65
|
+
expected_type = expected_type.to_s.downcase.gsub("_", "")
|
|
66
|
+
actual_type = actual_type.gsub("_", "")
|
|
67
|
+
msg ||= "Expected geometry type #{expected_type} but got #{actual_type}"
|
|
68
|
+
assert_equal expected_type, actual_type, msg
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Legacy methods for backward compatibility
|
|
72
|
+
def assert_has_z(geometry, msg = nil)
|
|
73
|
+
assert_spatial_column(geometry, msg).has_z
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def assert_has_m(geometry, msg = nil)
|
|
77
|
+
assert_spatial_column(geometry, msg).has_m
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Create a point for testing
|
|
81
|
+
def create_point(x, y, srid: 4326, z: nil, m: nil)
|
|
82
|
+
if z || m
|
|
83
|
+
# Use cartesian factory for 3D/4D points
|
|
84
|
+
factory = RGeo::Cartesian.preferred_factory(srid: srid, has_z_coordinate: !!z, has_m_coordinate: !!m)
|
|
85
|
+
if z && m
|
|
86
|
+
factory.point(x, y, z, m)
|
|
87
|
+
elsif z
|
|
88
|
+
factory.point(x, y, z)
|
|
89
|
+
elsif m
|
|
90
|
+
factory.point(x, y, 0, m) # Default Z to 0 for M-only
|
|
91
|
+
else
|
|
92
|
+
factory.point(x, y)
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
factory = RGeo::Geographic.simple_mercator_factory(srid: srid)
|
|
96
|
+
factory.point(x, y)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Create a test polygon for testing
|
|
101
|
+
def create_test_polygon(srid: 4326)
|
|
102
|
+
factory = RGeo::Geographic.simple_mercator_factory(srid: srid)
|
|
103
|
+
factory.polygon(
|
|
104
|
+
factory.linear_ring([
|
|
105
|
+
factory.point(0, 0),
|
|
106
|
+
factory.point(0, 1),
|
|
107
|
+
factory.point(1, 1),
|
|
108
|
+
factory.point(1, 0),
|
|
109
|
+
factory.point(0, 0)
|
|
110
|
+
])
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Create a test linestring for testing
|
|
115
|
+
def create_test_linestring(srid: 4326)
|
|
116
|
+
factory = RGeo::Geographic.simple_mercator_factory(srid: srid)
|
|
117
|
+
factory.line_string([
|
|
118
|
+
factory.point(0, 0),
|
|
119
|
+
factory.point(1, 1),
|
|
120
|
+
factory.point(2, 0)
|
|
121
|
+
])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Chainable spatial column assertion class
|
|
126
|
+
class SpatialColumnAssertion
|
|
127
|
+
def initialize(geometry, test_case, msg_prefix = nil)
|
|
128
|
+
@geometry = geometry
|
|
129
|
+
@test_case = test_case
|
|
130
|
+
@msg_prefix = msg_prefix
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def has_z
|
|
134
|
+
msg = build_message("to have Z dimension")
|
|
135
|
+
has_z = detect_has_z(@geometry)
|
|
136
|
+
@test_case.assert has_z, msg
|
|
137
|
+
self
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def has_m
|
|
141
|
+
msg = build_message("to have M dimension")
|
|
142
|
+
has_m = detect_has_m(@geometry)
|
|
143
|
+
@test_case.assert has_m, msg
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def has_srid(expected_srid)
|
|
148
|
+
msg = build_message("to have SRID #{expected_srid}")
|
|
149
|
+
actual_srid = @geometry.srid
|
|
150
|
+
@test_case.assert_equal expected_srid, actual_srid, msg
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def is_type(expected_type)
|
|
155
|
+
msg = build_message("to be of type #{expected_type}")
|
|
156
|
+
actual_type = @geometry.geometry_type.type_name.downcase
|
|
157
|
+
expected_type = expected_type.to_s.downcase.gsub("_", "")
|
|
158
|
+
actual_type = actual_type.gsub("_", "")
|
|
159
|
+
@test_case.assert_equal expected_type, actual_type, msg
|
|
160
|
+
self
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def is_geographic
|
|
164
|
+
msg = build_message("to be geographic")
|
|
165
|
+
# Check if factory is geographic
|
|
166
|
+
is_geo = @geometry.factory.respond_to?(:spherical?) && @geometry.factory.spherical?
|
|
167
|
+
@test_case.assert is_geo, msg
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def is_cartesian
|
|
172
|
+
msg = build_message("to be cartesian")
|
|
173
|
+
# Check if factory is cartesian
|
|
174
|
+
is_cart = !(@geometry.factory.respond_to?(:spherical?) && @geometry.factory.spherical?)
|
|
175
|
+
@test_case.assert is_cart, msg
|
|
176
|
+
self
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def build_message(expectation)
|
|
182
|
+
prefix = @msg_prefix ? "#{@msg_prefix}: " : ""
|
|
183
|
+
"#{prefix}Expected geometry #{expectation}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def detect_has_z(geometry)
|
|
187
|
+
return geometry.has_z_coordinate? if geometry.respond_to?(:has_z_coordinate?)
|
|
188
|
+
return geometry.has_z? if geometry.respond_to?(:has_z?)
|
|
189
|
+
return !geometry.z.nil? if geometry.respond_to?(:z)
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def detect_has_m(geometry)
|
|
194
|
+
return geometry.has_m_coordinate? if geometry.respond_to?(:has_m_coordinate?)
|
|
195
|
+
return geometry.has_m? if geometry.respond_to?(:has_m?)
|
|
196
|
+
return !geometry.m.nil? if geometry.respond_to?(:m)
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -19,6 +19,7 @@ require_relative "postgis/spatial_column_type"
|
|
|
19
19
|
require_relative "postgis/adapter_extensions"
|
|
20
20
|
require_relative "postgis/column_extensions"
|
|
21
21
|
require_relative "postgis/quoting"
|
|
22
|
+
require_relative "postgis/spatial_queries"
|
|
22
23
|
|
|
23
24
|
module ActiveRecord
|
|
24
25
|
module ConnectionAdapters
|
|
@@ -142,6 +143,16 @@ module ActiveRecord
|
|
|
142
143
|
private
|
|
143
144
|
|
|
144
145
|
def create_spatial_type_from_sql(sql_type)
|
|
146
|
+
# Handle empty sql_type (common in joins) - this is an upstream Rails issue
|
|
147
|
+
# where sql_type comes back as empty string, making it impossible to determine
|
|
148
|
+
# the correct spatial type properties
|
|
149
|
+
if sql_type.nil? || sql_type.empty?
|
|
150
|
+
# Log warning about potential type mismatch due to upstream issue
|
|
151
|
+
# Users experiencing this should use explicit attribute registration as workaround
|
|
152
|
+
# See: https://github.com/rgeo/activerecord-postgis-adapter/pull/334
|
|
153
|
+
return Type::Geometry.new(srid: 0, has_z: false, has_m: false, geographic: false)
|
|
154
|
+
end
|
|
155
|
+
|
|
145
156
|
# Extract SRID and dimensions from SQL type
|
|
146
157
|
srid = extract_srid_from_sql(sql_type)
|
|
147
158
|
# Check for dimension suffixes (e.g., PointZ, PointM, PointZM)
|
|
@@ -182,6 +193,7 @@ module ActiveRecord
|
|
|
182
193
|
when /geometry/i
|
|
183
194
|
Type::Geometry.new(srid: srid, has_z: has_z, has_m: has_m)
|
|
184
195
|
else
|
|
196
|
+
# Fallback for unrecognized types
|
|
185
197
|
Type::Geometry.new(srid: srid, has_z: has_z, has_m: has_m)
|
|
186
198
|
end
|
|
187
199
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../active_record/connection_adapters/postgis/test_helpers"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordPostgis
|
|
6
|
+
module TestHelper
|
|
7
|
+
include ActiveRecord::ConnectionAdapters::PostGIS::TestHelpers
|
|
8
|
+
|
|
9
|
+
# Additional convenience methods for PostGIS testing
|
|
10
|
+
def factory(srid: 4326, geographic: false)
|
|
11
|
+
if geographic
|
|
12
|
+
RGeo::Geographic.spherical_factory(srid: srid)
|
|
13
|
+
else
|
|
14
|
+
RGeo::Cartesian.preferred_factory(srid: srid)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def geographic_factory(srid: 4326)
|
|
19
|
+
RGeo::Geographic.spherical_factory(srid: srid)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cartesian_factory(srid: 0)
|
|
23
|
+
RGeo::Cartesian.preferred_factory(srid: srid)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spatial_factory_store
|
|
27
|
+
RGeo::ActiveRecord::SpatialFactoryStore.instance
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reset_spatial_store
|
|
31
|
+
spatial_factory_store.clear
|
|
32
|
+
spatial_factory_store.default = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Create a test table with spatial columns
|
|
36
|
+
def create_spatial_table(table_name, connection = ActiveRecord::Base.connection)
|
|
37
|
+
connection.create_table table_name, force: true do |t|
|
|
38
|
+
t.st_point :coordinates, srid: 4326
|
|
39
|
+
t.st_point :location, srid: 4326, geographic: true
|
|
40
|
+
t.st_polygon :boundary, srid: 4326
|
|
41
|
+
t.st_line_string :path, srid: 4326
|
|
42
|
+
t.timestamps
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clean up spatial tables after tests
|
|
47
|
+
def drop_spatial_table(table_name, connection = ActiveRecord::Base.connection)
|
|
48
|
+
connection.drop_table table_name if connection.table_exists?(table_name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -16,6 +16,33 @@ module Arel
|
|
|
16
16
|
class SpatialLength < Unary; end
|
|
17
17
|
class SpatialContains < SpatialNode; end
|
|
18
18
|
class SpatialWithin < SpatialNode; end
|
|
19
|
+
class SpatialIntersects < SpatialNode; end
|
|
20
|
+
class SpatialDWithin < SpatialNode
|
|
21
|
+
attr_reader :distance
|
|
22
|
+
|
|
23
|
+
def initialize(left, right, distance)
|
|
24
|
+
super(left, right)
|
|
25
|
+
@distance = distance
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
class SpatialBuffer < SpatialNode
|
|
29
|
+
def st_area
|
|
30
|
+
SpatialArea.new(self)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
class SpatialTransform < SpatialNode
|
|
34
|
+
def st_area
|
|
35
|
+
SpatialArea.new(self)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
class SpatialArea < Unary; end
|
|
39
|
+
|
|
40
|
+
# K-Nearest Neighbor distance operator
|
|
41
|
+
class SpatialDistanceOperator < Binary
|
|
42
|
+
def initialize(left, right)
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
end
|
|
19
46
|
|
|
20
47
|
# Wrapper for spatial values that need special handling
|
|
21
48
|
class SpatialValue < Node
|
|
@@ -36,6 +63,32 @@ module Arel
|
|
|
36
63
|
def st_within(other)
|
|
37
64
|
SpatialWithin.new(self, other)
|
|
38
65
|
end
|
|
66
|
+
|
|
67
|
+
def st_intersects(other)
|
|
68
|
+
SpatialIntersects.new(self, other)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def st_dwithin(other, distance)
|
|
72
|
+
SpatialDWithin.new(self, other, distance)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def st_buffer(distance)
|
|
76
|
+
SpatialBuffer.new(self, distance)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def st_transform(srid)
|
|
80
|
+
SpatialTransform.new(self, srid)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def st_area
|
|
84
|
+
SpatialArea.new(self)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def distance_operator(other)
|
|
88
|
+
SpatialDistanceOperator.new(self, other)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
alias :'<->' :distance_operator
|
|
39
92
|
end
|
|
40
93
|
end
|
|
41
94
|
|
|
@@ -56,6 +109,32 @@ module Arel
|
|
|
56
109
|
def st_within(other)
|
|
57
110
|
Arel::Nodes::SpatialWithin.new(self, other)
|
|
58
111
|
end
|
|
112
|
+
|
|
113
|
+
def st_intersects(other)
|
|
114
|
+
Arel::Nodes::SpatialIntersects.new(self, other)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def st_dwithin(other, distance)
|
|
118
|
+
Arel::Nodes::SpatialDWithin.new(self, other, distance)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def st_buffer(distance)
|
|
122
|
+
Arel::Nodes::SpatialBuffer.new(self, distance)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def st_transform(srid)
|
|
126
|
+
Arel::Nodes::SpatialTransform.new(self, srid)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def st_area
|
|
130
|
+
Arel::Nodes::SpatialArea.new(self)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def distance_operator(other)
|
|
134
|
+
Arel::Nodes::SpatialDistanceOperator.new(self, other)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
alias :'<->' :distance_operator
|
|
59
138
|
end
|
|
60
139
|
end
|
|
61
140
|
|
|
@@ -64,6 +143,26 @@ module Arel
|
|
|
64
143
|
Arel::Nodes::SpatialValue.new(value)
|
|
65
144
|
end
|
|
66
145
|
|
|
146
|
+
# Add Arel.st_make_point() method that properly handles floats for Rails 8.1
|
|
147
|
+
def self.st_make_point(x, y, srid = nil)
|
|
148
|
+
# Wrap floats in SqlLiteral nodes to avoid Rails 8.1 Arel strictness
|
|
149
|
+
x_node = x.is_a?(Numeric) ? Arel::Nodes::SqlLiteral.new(x.to_s) : x
|
|
150
|
+
y_node = y.is_a?(Numeric) ? Arel::Nodes::SqlLiteral.new(y.to_s) : y
|
|
151
|
+
|
|
152
|
+
if srid
|
|
153
|
+
srid_node = srid.is_a?(Numeric) ? Arel::Nodes::SqlLiteral.new(srid.to_s) : srid
|
|
154
|
+
Arel::Nodes::NamedFunction.new(
|
|
155
|
+
"ST_SetSRID",
|
|
156
|
+
[
|
|
157
|
+
Arel::Nodes::NamedFunction.new("ST_MakePoint", [ x_node, y_node ]),
|
|
158
|
+
srid_node
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
else
|
|
162
|
+
Arel::Nodes::NamedFunction.new("ST_MakePoint", [ x_node, y_node ])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
67
166
|
module Visitors
|
|
68
167
|
class PostGIS < PostgreSQL
|
|
69
168
|
include RGeo::ActiveRecord::SpatialToSql
|
|
@@ -112,6 +211,52 @@ module Arel
|
|
|
112
211
|
visit_spatial_operand(node.value, collector)
|
|
113
212
|
end
|
|
114
213
|
|
|
214
|
+
def visit_Arel_Nodes_SpatialIntersects(node, collector)
|
|
215
|
+
collector << "ST_Intersects("
|
|
216
|
+
visit(node.left, collector)
|
|
217
|
+
collector << ", "
|
|
218
|
+
visit_spatial_operand(node.right, collector)
|
|
219
|
+
collector << ")"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def visit_Arel_Nodes_SpatialDWithin(node, collector)
|
|
223
|
+
collector << "ST_DWithin("
|
|
224
|
+
visit(node.left, collector)
|
|
225
|
+
collector << ", "
|
|
226
|
+
visit_spatial_operand(node.right, collector)
|
|
227
|
+
collector << ", "
|
|
228
|
+
collector << node.distance.to_s
|
|
229
|
+
collector << ")"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def visit_Arel_Nodes_SpatialBuffer(node, collector)
|
|
233
|
+
collector << "ST_Buffer("
|
|
234
|
+
visit(node.left, collector)
|
|
235
|
+
collector << ", "
|
|
236
|
+
collector << node.right.to_s
|
|
237
|
+
collector << ")"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def visit_Arel_Nodes_SpatialTransform(node, collector)
|
|
241
|
+
collector << "ST_Transform("
|
|
242
|
+
visit(node.left, collector)
|
|
243
|
+
collector << ", "
|
|
244
|
+
collector << node.right.to_s
|
|
245
|
+
collector << ")"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def visit_Arel_Nodes_SpatialArea(node, collector)
|
|
249
|
+
collector << "ST_Area("
|
|
250
|
+
visit(node.expr, collector)
|
|
251
|
+
collector << ")"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def visit_Arel_Nodes_SpatialDistanceOperator(node, collector)
|
|
255
|
+
visit(node.left, collector)
|
|
256
|
+
collector << " <-> "
|
|
257
|
+
visit_spatial_operand(node.right, collector)
|
|
258
|
+
end
|
|
259
|
+
|
|
115
260
|
private
|
|
116
261
|
|
|
117
262
|
def visit_spatial_operand(operand, collector)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-postgis
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -15,20 +15,20 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 8.1.0
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
|
-
version: '8.
|
|
21
|
+
version: '8.2'
|
|
22
22
|
type: :runtime
|
|
23
23
|
prerelease: false
|
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version:
|
|
28
|
+
version: 8.1.0
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
|
-
version: '8.
|
|
31
|
+
version: '8.2'
|
|
32
32
|
- !ruby/object:Gem::Dependency
|
|
33
33
|
name: pg
|
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -79,7 +79,9 @@ files:
|
|
|
79
79
|
- lib/active_record/connection_adapters/postgis/schema_statements.rb
|
|
80
80
|
- lib/active_record/connection_adapters/postgis/spatial_column_methods.rb
|
|
81
81
|
- lib/active_record/connection_adapters/postgis/spatial_column_type.rb
|
|
82
|
+
- lib/active_record/connection_adapters/postgis/spatial_queries.rb
|
|
82
83
|
- lib/active_record/connection_adapters/postgis/table_definition.rb
|
|
84
|
+
- lib/active_record/connection_adapters/postgis/test_helpers.rb
|
|
83
85
|
- lib/active_record/connection_adapters/postgis/type/geography.rb
|
|
84
86
|
- lib/active_record/connection_adapters/postgis/type/geometry.rb
|
|
85
87
|
- lib/active_record/connection_adapters/postgis/type/geometry_collection.rb
|
|
@@ -92,6 +94,7 @@ files:
|
|
|
92
94
|
- lib/active_record/connection_adapters/postgis/type/spatial.rb
|
|
93
95
|
- lib/active_record/connection_adapters/postgis/version.rb
|
|
94
96
|
- lib/activerecord-postgis.rb
|
|
97
|
+
- lib/activerecord-postgis/test_helper.rb
|
|
95
98
|
- lib/arel/visitors/postgis.rb
|
|
96
99
|
homepage: https://github.com/seuros/activerecord-postgis
|
|
97
100
|
licenses:
|
|
@@ -114,7 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
114
117
|
- !ruby/object:Gem::Version
|
|
115
118
|
version: '0'
|
|
116
119
|
requirements: []
|
|
117
|
-
rubygems_version: 3.6.
|
|
120
|
+
rubygems_version: 3.6.9
|
|
118
121
|
specification_version: 4
|
|
119
122
|
summary: PostGIS Type support for ActiveRecord
|
|
120
123
|
test_files: []
|