spatial_features 3.0.2 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57499f73e1190669ab1f3b5893c718db536fda282cce4a91bb0936908c5b2548
4
- data.tar.gz: 0eb2e143c321b8e4e0dc498673d4a3c82e6d8d66df131a6ce445654a2f874772
3
+ metadata.gz: fabdd3f0f73eb21327f8e3deb1b7dda5969d97bdb7c053c60199a63fe5cd04b1
4
+ data.tar.gz: 777c5dc188ba968ab4cba25e58fa2f361c9d3814781ecfb9e751cf0fc1b40b0e
5
5
  SHA512:
6
- metadata.gz: 8dca234339ac5c209752e01ae0eea025f8ad88e51480b73d4d5ec3beed2ebd019c58f4c96ba56a25f0655611474eea396ee0da0be8d13ece5db0b279d5de2e27
7
- data.tar.gz: 72f3fbceb533ffad03a849cf7d3caed587749d0d3d3a6830538beb59787588fc67803c965b3ad7fed10512d3cc3aecdc9878f45c8cbf52a1656746289b3e598d
6
+ metadata.gz: e3f28e99e814f1fcc2ba49edf8a59ff957b9f92c9e0d990f93409c61f00d4c496e7ac5f60eb3616660df720c615033f62dbe02d707c1584cc4cee7d9cb88067c
7
+ data.tar.gz: 18e776f2d1bc792e7a0313d5742345e93adca24f060472bb10a36611eb3e62cf7fe8fcf2d7d520db16cd9b1755e85b4e40e75cabcb4110e25c397e42087fd9e0
data/README.md CHANGED
@@ -31,7 +31,6 @@ Adds spatial methods to a model.
31
31
  feature_type character varying(255),
32
32
  geog geography,
33
33
  geom geometry(Geometry,4326),
34
- geom_lowres geometry(Geometry,4326),
35
34
  tilegeom geometry(Geometry,3857),
36
35
  metadata hstore,
37
36
  area double precision,
@@ -50,7 +49,6 @@ Adds spatial methods to a model.
50
49
  CREATE INDEX index_features_on_feature_type ON features USING btree (feature_type);
51
50
  CREATE INDEX index_features_on_spatial_model_id_and_spatial_model_type ON features USING btree (spatial_model_id, spatial_model_type);
52
51
  CREATE INDEX index_features_on_geom ON features USING gist (geom);
53
- CREATE INDEX index_features_on_geom_lowres ON features USING gist (geom_lowres);
54
52
  CREATE INDEX index_features_on_tilegeom ON features USING gist (tilegeom);
55
53
 
56
54
  CREATE TABLE spatial_caches (
@@ -181,3 +179,26 @@ add_column :features, :tilegeom, :geometry
181
179
  add_index :features, :tilegeom, :using => :gist
182
180
  Feature.update_all('tilegeom = ST_Transform(geom, 3857)')
183
181
  ```
182
+
183
+ ## Upgrading From 3.0/3.1 to 3.2
184
+ SpatialProximity now expects the `model_a` and `model_b` records to be decided based on the name of the record type so
185
+ queries can be optimized. Migrate existing SpatialProximity rows to this new scheme by running the code below.
186
+
187
+ ```ruby
188
+ SpatialProximity.normalize
189
+ ```
190
+
191
+ ## Testing
192
+
193
+ Create a postgres database:
194
+ ```bash
195
+ createdb spatial_features_test
196
+ ```
197
+
198
+ There are multiple gemfiles available for testing against different Rails versions. Set `BUNDLE_GEMFILE` to target them, e.g.
199
+
200
+ ```bash
201
+ bundle install
202
+ BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle install
203
+ BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rspec
204
+ ```
@@ -69,11 +69,11 @@ class AbstractFeature < ActiveRecord::Base
69
69
  end
70
70
 
71
71
  def self.intersecting(other)
72
- join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
72
+ join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').distinct
73
73
  end
74
74
 
75
75
  def self.within_distance(other, distance_in_meters)
76
- join_other_features(other).where('ST_DWithin(features.geom_lowres, other_features.geom_lowres, ?)', distance_in_meters).uniq
76
+ join_other_features(other).where('ST_DWithin(features.geom_lowres, other_features.geom_lowres, ?)', distance_in_meters).distinct
77
77
  end
78
78
 
79
79
  def self.invalid(column = 'geog::geometry')
@@ -126,8 +126,8 @@ class AbstractFeature < ActiveRecord::Base
126
126
  SQL
127
127
  end
128
128
 
129
- def self.mvt(*args)
130
- select_sql = mvt_sql(*args)
129
+ def self.mvt(*args, **kwargs)
130
+ select_sql = mvt_sql(*args, **kwargs)
131
131
 
132
132
  # Result is a hex string representing the desired binary output so we need to convert it to binary
133
133
  result = SpatialFeatures::Utils.select_db_value(select_sql)
@@ -2,10 +2,34 @@ class SpatialProximity < ActiveRecord::Base
2
2
  belongs_to :model_a, :polymorphic => true
3
3
  belongs_to :model_b, :polymorphic => true
4
4
 
5
- def self.between(scope_1, scope_2)
6
- where <<-SQL.squish
7
- (#{SpatialFeatures::Utils.polymorphic_condition(scope_1, 'model_a')} AND #{SpatialFeatures::Utils.polymorphic_condition(scope_2, 'model_b')}) OR
8
- (#{SpatialFeatures::Utils.polymorphic_condition(scope_2, 'model_a')} AND #{SpatialFeatures::Utils.polymorphic_condition(scope_1, 'model_b')})
5
+ def self.between(scope1, scope2)
6
+ where condition_sql(scope1, scope2, <<~SQL.squish)
7
+ (#{SpatialFeatures::Utils.polymorphic_condition(scope1, 'model_a')} AND #{SpatialFeatures::Utils.polymorphic_condition(scope2, 'model_b')})
9
8
  SQL
10
9
  end
10
+
11
+ def self.condition_sql(scope1, scope2, template, pattern_a = 'model_a', pattern_b = 'model_b')
12
+ scope1_type = SpatialFeatures::Utils.base_class_of(scope1).to_s
13
+ scope2_type = SpatialFeatures::Utils.base_class_of(scope2).to_s
14
+
15
+ if scope1_type < scope2_type
16
+ template
17
+ elsif scope1_type > scope2_type
18
+ template.gsub(pattern_a, 'model_c').gsub(pattern_b, pattern_a).gsub('model_c', pattern_b)
19
+ else
20
+ <<~SQL.squish
21
+ (#{template}) OR (#{template.gsub(pattern_a, 'model_c').gsub(pattern_b, pattern_a).gsub('model_c', pattern_b)})
22
+ SQL
23
+ end
24
+ end
25
+
26
+ # Ensure the 'earliest' model is always model a
27
+ def self.normalize
28
+ unnormalized
29
+ .update_all('model_a_type = model_b_type, model_b_type = model_a_type, model_a_id = model_b_id, model_b_id = model_a_id')
30
+ end
31
+
32
+ def self.unnormalized
33
+ where('model_a_type > model_b_type OR (model_a_type = model_b_type AND model_a_id > model_b_id)')
34
+ end
11
35
  end
@@ -1,8 +1,12 @@
1
1
  module PostGISTypes
2
2
  def initialize_type_map(m = type_map)
3
3
  super
4
- register_class_with_limit m, 'geometry', ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::OID::SpecializedString
5
- register_class_with_limit m, 'geography', ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::OID::SpecializedString
4
+ %w[
5
+ geography
6
+ geometry
7
+ ].each do |geo_type|
8
+ m.register_type geo_type, ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::OID::SpecializedString.new(geo_type.to_sym)
9
+ end
6
10
  end
7
11
  end
8
12
 
@@ -84,11 +84,13 @@ module SpatialFeatures
84
84
  results.each do |id, distance, area|
85
85
  klass_record.id = id
86
86
  SpatialProximity.create! do |proximity|
87
+ # Always make the spatial model earliest type and id be model a so we can optimize queries
88
+ data = [[Utils.base_class(record).to_s, record.id], [Utils.base_class(klass_record).to_s, klass_record.id]]
89
+ data.sort!
90
+
87
91
  # Set id and type instead of model to avoid autosaving the klass_record
88
- proximity.model_a_id = record.id
89
- proximity.model_a_type = Utils.base_class(record)
90
- proximity.model_b_id = klass_record.id
91
- proximity.model_b_type = Utils.base_class(klass_record)
92
+ proximity.model_a_type, proximity.model_a_id = data.first
93
+ proximity.model_b_type, proximity.model_b_id = data.second
92
94
  proximity.distance_in_meters = distance
93
95
  proximity.intersection_area_in_square_meters = area
94
96
  end
@@ -4,14 +4,14 @@ module SpatialFeatures
4
4
  module Download
5
5
  # file can be a url, path, or file, any of which can return be a zipped archive
6
6
  def self.open(file)
7
- file = Kernel.open(file)
7
+ file = URI.open(file)
8
8
  file = normalize_file(file) if file.is_a?(StringIO)
9
9
  return file
10
10
  end
11
11
 
12
12
  # file can be a url, path, or file, any of which can return be a zipped archive
13
- def self.open_each(file, unzip: nil, **unzip_options)
14
- file = Download.open(file)
13
+ def self.open_each(path_or_url, unzip: nil, **unzip_options)
14
+ file = Download.open(path_or_url)
15
15
  files = if unzip && Unzip.is_zip?(file)
16
16
  find_in_zip(file, find: unzip, **unzip_options)
17
17
  else
@@ -41,9 +41,9 @@ module SpatialFeatures
41
41
  update_spatial_cache(options.slice(:spatial_cache))
42
42
  end
43
43
  end
44
-
45
- return true
46
44
  end
45
+
46
+ return true
47
47
  rescue StandardError => e
48
48
  if skip_invalid
49
49
  Rails.logger.warn "Error updating #{self.class} #{self.id}. #{e.message}"
@@ -13,14 +13,14 @@ module SpatialFeatures
13
13
  has_many :features, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete_all
14
14
  has_one :aggregate_feature, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete
15
15
 
16
- scope :with_features, lambda { joins(:features).uniq }
16
+ scope :with_features, lambda { joins(:features).distinct }
17
17
  scope :without_features, lambda { joins("LEFT OUTER JOIN features ON features.spatial_model_type = '#{Utils.base_class(name)}' AND features.spatial_model_id = #{table_name}.id").where("features.id IS NULL") }
18
18
  scope :include_bounds, lambda { SQLHelpers.append_select(joins(:aggregate_feature), :north, :east, :south, :west) }
19
19
  scope :include_area, lambda { SQLHelpers.append_select(joins(:aggregate_feature), :area) }
20
20
 
21
- scope :with_spatial_cache, lambda {|klass| joins(:spatial_caches).where(:spatial_caches => { :intersection_model_type => Utils.class_name_with_ancestors(klass) }).uniq }
21
+ scope :with_spatial_cache, lambda {|klass| joins(:spatial_caches).where(:spatial_caches => { :intersection_model_type => Utils.class_name_with_ancestors(klass) }).distinct }
22
22
  scope :without_spatial_cache, lambda {|klass| joins("LEFT OUTER JOIN #{SpatialCache.table_name} ON #{SpatialCache.table_name}.spatial_model_id = #{table_name}.id AND #{SpatialCache.table_name}.spatial_model_type = '#{Utils.base_class(name)}' and intersection_model_type IN ('#{Utils.class_name_with_ancestors(klass).join("','") }')").where("#{SpatialCache.table_name}.spatial_model_id IS NULL") }
23
- scope :with_stale_spatial_cache, lambda { joins(:spatial_caches).where("#{table_name}.features_hash != spatial_caches.features_hash").uniq } if has_spatial_features_hash?
23
+ scope :with_stale_spatial_cache, lambda { has_spatial_features_hash? ? joins(:spatial_caches).where("#{table_name}.features_hash != spatial_caches.features_hash").distinct : none }
24
24
 
25
25
  has_many :spatial_caches, :as => :spatial_model, :dependent => :delete_all, :class_name => 'SpatialCache'
26
26
  has_many :model_a_spatial_proximities, :as => :model_a, :class_name => 'SpatialProximity', :dependent => :delete_all
@@ -56,7 +56,7 @@ module SpatialFeatures
56
56
  within_buffer(other, 0, options)
57
57
  end
58
58
 
59
- def within_buffer(other, buffer_in_meters = 0, **options)
59
+ def within_buffer(other, buffer_in_meters = 0, options = {})
60
60
  return none if other.is_a?(ActiveRecord::Base) && other.new_record?
61
61
 
62
62
  # Cache only works on single records, not scopes.
@@ -136,10 +136,8 @@ module SpatialFeatures
136
136
  other_class = Utils.base_class_of(other)
137
137
  self_class = Utils.base_class_of(self)
138
138
 
139
- joins <<~SQL
140
- INNER JOIN spatial_proximities
141
- ON (spatial_proximities.model_a_type = '#{self_class}' AND spatial_proximities.model_a_id = #{table_name}.id AND spatial_proximities.model_b_type = '#{other_class}' AND spatial_proximities.model_b_id IN (#{Utils.id_sql(other)}))
142
- OR (spatial_proximities.model_b_type = '#{self_class}' AND spatial_proximities.model_b_id = #{table_name}.id AND spatial_proximities.model_a_type = '#{other_class}' AND spatial_proximities.model_a_id IN (#{Utils.id_sql(other)}))
139
+ joins "INNER JOIN spatial_proximities ON " + SpatialProximity.condition_sql(self, other, <<~SQL.squish)
140
+ (spatial_proximities.model_a_type = '#{self_class}' AND spatial_proximities.model_a_id = #{table_name}.id AND spatial_proximities.model_b_type = '#{other_class}' AND spatial_proximities.model_b_id IN (#{Utils.id_sql(other)}))
143
141
  SQL
144
142
  end
145
143
 
@@ -228,9 +226,12 @@ module SpatialFeatures
228
226
  end
229
227
 
230
228
  def total_intersection_area_percentage(klass)
231
- return 0.0 unless features_area_in_square_meters > 0
229
+ total_area = features_area_in_square_meters
232
230
 
233
- ((total_intersection_area_in_square_meters(klass) / features_area_in_square_meters) * 100).round(1)
231
+ return if total_area.nil?
232
+ return 0.0 if total_area.zero?
233
+
234
+ ((total_intersection_area_in_square_meters(klass) / total_area) * 100).round(1)
234
235
  end
235
236
 
236
237
  def features_area_in_square_meters
@@ -21,7 +21,7 @@ module SpatialFeatures
21
21
  # processed.
22
22
  #
23
23
  # If no `current_file` is passed then we just take the first valid file that we find.
24
- def initialize(data, *args, current_file: nil, **options)
24
+ def initialize(data, current_file: nil, **options)
25
25
  begin
26
26
  current_file ||= Download.open_each(data, unzip: FILE_PATTERNS, downcase: true, tmpdir: options[:tmpdir]).first
27
27
  rescue Unzip::PathNotFound
@@ -30,11 +30,11 @@ module SpatialFeatures
30
30
 
31
31
  case ::File.extname(current_file.path.downcase)
32
32
  when '.kml'
33
- __setobj__(KMLFile.new(current_file, *args, **options))
33
+ __setobj__(KMLFile.new(current_file, **options))
34
34
  when '.shp'
35
- __setobj__(Shapefile.new(current_file, *args, **options))
35
+ __setobj__(Shapefile.new(current_file, **options))
36
36
  when '.json', '.geojson'
37
- __setobj__(ESRIGeoJSON.new(current_file.path, *args, **options))
37
+ __setobj__(ESRIGeoJSON.new(current_file.path, **options))
38
38
  else
39
39
  raise ImportError, "Could not import file. " + SUPPORTED_FORMATS
40
40
  end
@@ -1,8 +1,8 @@
1
1
  module SpatialFeatures
2
2
  module Importers
3
3
  class Geomark < KMLFile
4
- def initialize(geomark, *args)
5
- super geomark_url(geomark), *args
4
+ def initialize(geomark, **options)
5
+ super geomark_url(geomark), **options
6
6
  end
7
7
 
8
8
  private
@@ -6,16 +6,16 @@ module SpatialFeatures
6
6
  # <SimpleData name> keys that may contain <img> tags
7
7
  IMAGE_METADATA_KEYS = %w[pdfmaps_photos].freeze
8
8
 
9
- def initialize(data, base_dir: nil, **args)
9
+ def initialize(data, base_dir: nil, **options)
10
10
  @base_dir = base_dir
11
- super data, **args
11
+ super data, **options
12
12
  end
13
13
 
14
14
  private
15
15
 
16
16
  def each_record(&block)
17
17
  {'Polygon' => 'POLYGON', 'LineString' => 'LINE', 'Point' => 'POINT'}.each do |kml_type, sql_type|
18
- Nokogiri::XML(@data).css(kml_type).each do |feature|
18
+ kml_document.css(kml_type).each do |feature|
19
19
  if placemark = feature.ancestors('Placemark').first
20
20
  name = placemark.css('name').text
21
21
  metadata = extract_metadata(placemark)
@@ -35,6 +35,15 @@ module SpatialFeatures
35
35
  end
36
36
  end
37
37
 
38
+ def kml_document
39
+ @kml_document ||= begin
40
+ doc = Nokogiri::XML(@data)
41
+ raise ImportError, "Invalid KML document (root node was '#{doc.root&.name}')" unless doc.root&.name.to_s.casecmp?('kml')
42
+ raise ImportError, "NetworkLink elements are not supported" unless doc.search('NetworkLink').empty?
43
+ doc
44
+ end
45
+ end
46
+
38
47
  def blank_feature?(feature)
39
48
  feature.css('coordinates').text.blank?
40
49
  end
@@ -3,7 +3,7 @@ require 'ostruct'
3
3
  module SpatialFeatures
4
4
  module Importers
5
5
  class KMLFileArcGIS < KMLFile
6
- def initialize(data, *args)
6
+ def initialize(data, **options)
7
7
  super
8
8
 
9
9
  rescue SocketError, Errno::ECONNREFUSED
@@ -6,7 +6,7 @@ module SpatialFeatures
6
6
  class Shapefile < Base
7
7
  class_attribute :default_proj4_projection
8
8
 
9
- def initialize(data, *args, proj4: nil, **options)
9
+ def initialize(data, proj4: nil, **options)
10
10
  super(data, **options)
11
11
  @proj4 = proj4
12
12
  end
@@ -56,7 +56,7 @@ module SpatialFeatures
56
56
  # Instantiate objects to hold the kml and records for each venn polygon
57
57
  polygons.group_by{|row| row['kml']}.collect do |kml, rows|
58
58
  # Uniq on row id in case a single record had self intersecting multi geometry, which would cause it to appear duplicated on a single venn polygon
59
- records = rows.uniq{|row| row.values_at('id', 'type') }.collect{|row| eager_load_hash.fetch(row['type']).detect{|record| record.id == row['id'].to_i } }
59
+ records = rows.uniq {|row| row.values_at('id', 'type') }.collect{|row| eager_load_hash.fetch(row['type']).detect{|record| record.id == row['id'].to_i } }
60
60
  OpenStruct.new(:kml => kml, :records => records)
61
61
  end
62
62
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "3.0.2"
2
+ VERSION = "3.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wallace
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-02-24 00:00:00.000000000 Z
12
+ date: 2023-08-12 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -17,20 +17,20 @@ dependencies:
17
17
  requirements:
18
18
  - - ">="
19
19
  - !ruby/object:Gem::Version
20
- version: '4.2'
20
+ version: '6'
21
21
  - - "<"
22
22
  - !ruby/object:Gem::Version
23
- version: '7.0'
23
+ version: '8'
24
24
  type: :runtime
25
25
  prerelease: false
26
26
  version_requirements: !ruby/object:Gem::Requirement
27
27
  requirements:
28
28
  - - ">="
29
29
  - !ruby/object:Gem::Version
30
- version: '4.2'
30
+ version: '6'
31
31
  - - "<"
32
32
  - !ruby/object:Gem::Version
33
- version: '7.0'
33
+ version: '8'
34
34
  - !ruby/object:Gem::Dependency
35
35
  name: delayed_job_active_record
36
36
  requirement: !ruby/object:Gem::Requirement
@@ -91,16 +91,16 @@ dependencies:
91
91
  name: nokogiri
92
92
  requirement: !ruby/object:Gem::Requirement
93
93
  requirements:
94
- - - "~>"
94
+ - - ">="
95
95
  - !ruby/object:Gem::Version
96
- version: '1.6'
96
+ version: '0'
97
97
  type: :runtime
98
98
  prerelease: false
99
99
  version_requirements: !ruby/object:Gem::Requirement
100
100
  requirements:
101
- - - "~>"
101
+ - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: '1.6'
103
+ version: '0'
104
104
  - !ruby/object:Gem::Dependency
105
105
  name: pg
106
106
  requirement: !ruby/object:Gem::Requirement
@@ -129,6 +129,20 @@ dependencies:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
131
  version: '3.5'
132
+ - !ruby/object:Gem::Dependency
133
+ name: pry-byebug
134
+ requirement: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ type: :development
140
+ prerelease: false
141
+ version_requirements: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
132
146
  description: Adds spatial methods to a model.
133
147
  email:
134
148
  - contact@culturecode.ca
@@ -189,7 +203,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
189
203
  - !ruby/object:Gem::Version
190
204
  version: '0'
191
205
  requirements: []
192
- rubygems_version: 3.0.3
206
+ rubygems_version: 3.3.23
193
207
  signing_key:
194
208
  specification_version: 4
195
209
  summary: Adds spatial methods to a model.