spatial_features 2.14.0 → 2.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0aa9e8be0d78cabc65695247e3000bcc2d69ce75aeffe1333b0dd5ce423eb562
4
- data.tar.gz: 587dc7a4be41c38acd66de43864ed3b8b51f22186996fcdd2c906c01ee97102e
3
+ metadata.gz: e0bc8fc084675d82dfe1414f599b32d22b3b4435525246580c7cd2af1f5e3afc
4
+ data.tar.gz: 78b7e7fa4b2e3fe9f46931c4b5a0bcf14c3f518a9e813e48fcb3985ac1bd171f
5
5
  SHA512:
6
- metadata.gz: 75293b351a8bfa078b1eac5c02a7d5dcce3e6eaa15a1779e660d0ca1c8a1cf93be2ad30190946e8934f51301c8535cfda4a75d0af2d86d051ffdd79646f170eb
7
- data.tar.gz: a725a65315d15a5885bd475dc6575969823cac93bc865f4dca3f583e602342a7b40a1afef2478264e74f29a79d44be8fb3528648144b10fe9d4c0a3d8f5858ab
6
+ metadata.gz: 1fe304ec107f4d9ca8537a4a665c118bb20a1365b2bee3a58d88bd2ccc65758a4906a812a09632ad779e2df60b715c100967e62bfb97e23c012e96a7d031f958
7
+ data.tar.gz: ab611750f2a507937f0f3a62b120c619b7e2b3a9fda837657eeb844bd0e02911d3fd5419a779f60ed0d20bdee6b387e4fb2b9b51e814216b1a0a82bce11852b5
@@ -1,6 +1,9 @@
1
1
  class AbstractFeature < ActiveRecord::Base
2
2
  self.table_name = 'features'
3
3
 
4
+ class_attribute :automatically_cache_derivatives
5
+ self.automatically_cache_derivatives = true
6
+
4
7
  class_attribute :lowres_simplification
5
8
  self.lowres_simplification = 2 # Threshold in meters
6
9
 
@@ -12,15 +15,21 @@ class AbstractFeature < ActiveRecord::Base
12
15
 
13
16
  before_validation :sanitize_feature_type
14
17
  validates_presence_of :geog
15
- validate :validate_geometry
16
- before_save :sanitize
17
- after_save :cache_derivatives, :if => :saved_change_to_geog?
18
+ validate :validate_geometry, if: :will_save_change_to_geog?
19
+ before_save :sanitize, if: :will_save_change_to_geog?
20
+ after_save :cache_derivatives, :if => [:automatically_cache_derivatives?, :saved_change_to_geog?]
18
21
 
19
22
  def self.cache_key
20
23
  result = connection.select_one(all.select('max(id) AS max, count(*) AS count').to_sql)
21
24
  "#{result['max']}-#{result['count']}"
22
25
  end
23
26
 
27
+ # for Rails >= 5 ActiveRecord collections we override the collection_cache_key
28
+ # to prevent Rails doing its default query on `updated_at`
29
+ def self.collection_cache_key(_collection, _timestamp_column)
30
+ self.cache_key
31
+ end
32
+
24
33
  def self.with_metadata(k, v)
25
34
  if k.present? && v.present?
26
35
  where('metadata->? = ?', k, v)
@@ -82,6 +91,14 @@ class AbstractFeature < ActiveRecord::Base
82
91
  return envelope_json.values_at(0,2)
83
92
  end
84
93
 
94
+ def self.without_caching_derivatives(&block)
95
+ old = automatically_cache_derivatives
96
+ self.automatically_cache_derivatives = false
97
+ block.call
98
+ ensure
99
+ self.automatically_cache_derivatives = old
100
+ end
101
+
85
102
  def self.cache_derivatives(options = {})
86
103
  update_all <<-SQL.squish
87
104
  geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
@@ -106,10 +123,17 @@ class AbstractFeature < ActiveRecord::Base
106
123
  SQL
107
124
  end
108
125
 
109
- def self.geojson(lowres: false, precision: 6, properties: {}, srid: 4326) # default srid is 4326 so output is Google Maps compatible
110
- column = lowres ? "ST_Transform(geom_lowres, #{srid})" : 'geog'
111
- properties_sql = <<~SQL if properties.present?
112
- , 'properties', json_build_object(#{properties.map {|k,v| "'#{k}',#{v}" }.join(',') })
126
+ def self.geojson(lowres: false, precision: 6, properties: true, srid: 4326, centroids: false) # default srid is 4326 so output is Google Maps compatible
127
+ if centroids
128
+ column = 'centroid'
129
+ elsif lowres
130
+ column = "ST_Transform(geom_lowres, #{srid})"
131
+ else
132
+ column = 'geog'
133
+ end
134
+
135
+ properties_sql = <<~SQL if properties
136
+ , 'properties', hstore_to_json(metadata)
113
137
  SQL
114
138
 
115
139
  sql = <<~SQL
@@ -132,7 +156,7 @@ class AbstractFeature < ActiveRecord::Base
132
156
  end
133
157
 
134
158
  def cache_derivatives(*args)
135
- self.class.where(:id => self.id).cache_derivatives(*args)
159
+ self.class.default_scoped.where(:id => self.id).cache_derivatives(*args)
136
160
  end
137
161
 
138
162
  def kml(options = {})
@@ -11,6 +11,16 @@ class Feature < AbstractFeature
11
11
 
12
12
  after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
13
13
 
14
+ # Features are used for display so we also cache their KML representation
15
+ def self.cache_derivatives(options = {})
16
+ super
17
+ update_all <<-SQL.squish
18
+ kml = ST_AsKML(geog, 6),
19
+ kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
20
+ kml_centroid = ST_AsKML(centroid)
21
+ SQL
22
+ end
23
+
14
24
  def self.defer_aggregate_refresh(&block)
15
25
  start_at = Feature.maximum(:id).to_i + 1
16
26
  output = without_aggregate_refresh(&block)
@@ -30,9 +40,10 @@ class Feature < AbstractFeature
30
40
 
31
41
  def self.refresh_aggregates
32
42
  # Find one feature from each spatial model and trigger the aggregate feature refresh
33
- ids = select('MAX(id)')
34
- .where.not(:spatial_model_type => nil, :spatial_model_id => nil)
43
+ ids = where.not(:spatial_model_type => nil)
44
+ .where.not(:spatial_model_id => nil)
35
45
  .group('spatial_model_type, spatial_model_id')
46
+ .pluck('MAX(id)')
36
47
 
37
48
  # Unscope so that newly built AggregateFeatures get their type column set correctly
38
49
  AbstractFeature.unscoped { where(:id => ids).find_each(&:refresh_aggregate) }
@@ -49,14 +60,4 @@ class Feature < AbstractFeature
49
60
  # this field blank.
50
61
  spatial_model_id? && automatically_refresh_aggregate && saved_change_to_geog?
51
62
  end
52
-
53
- # Features are used for display so we also cache their KML representation
54
- def self.cache_derivatives(options = {})
55
- super
56
- update_all <<-SQL.squish
57
- kml = ST_AsKML(geog, 6),
58
- kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
59
- kml_centroid = ST_AsKML(centroid)
60
- SQL
61
- end
62
63
  end
@@ -12,6 +12,7 @@ require 'spatial_features/controller_helpers/spatial_extensions'
12
12
  require 'spatial_features/download'
13
13
  require 'spatial_features/unzip'
14
14
  require 'spatial_features/utils'
15
+ require 'spatial_features/validation'
15
16
 
16
17
  require 'spatial_features/has_spatial_features'
17
18
  require 'spatial_features/has_spatial_features/queued_spatial_processing'
@@ -6,8 +6,6 @@ module SpatialExtensions
6
6
  model.failed_feature_update_jobs.destroy_all
7
7
  model.delay_update_features!
8
8
  end
9
-
10
- redirect_to :back
11
9
  end
12
10
 
13
11
  def abstract_proximity_action(scope, target, distance, &block)
@@ -12,7 +12,7 @@ module SpatialFeatures
12
12
  def self.open(file, unzip: nil, **unzip_options)
13
13
  file = Kernel.open(file)
14
14
  file = normalize_file(file) if file.is_a?(StringIO)
15
- if Unzip.is_zip?(file)
15
+ if unzip && Unzip.is_zip?(file)
16
16
  file = find_in_zip(file, find: unzip, **unzip_options)
17
17
  end
18
18
  return file
@@ -26,6 +26,12 @@ module SpatialFeatures
26
26
  end
27
27
  end
28
28
 
29
+ def self.entries(file)
30
+ file = Kernel.open(file)
31
+ file = normalize_file(file) if file.is_a?(StringIO)
32
+ Unzip.entries(file)
33
+ end
34
+
29
35
  def self.find_in_zip(file, find:, **unzip_options)
30
36
  return File.open(Unzip.paths(file, :find => find, **unzip_options))
31
37
  end
@@ -88,16 +88,16 @@ module SpatialFeatures
88
88
 
89
89
  # Returns true if the model stores a hash of the features so we don't need to process the features if they haven't changed
90
90
  def has_spatial_features_hash?
91
- column_names.include? 'features_hash'
91
+ owner_class_has_loaded_column?('features_hash')
92
92
  end
93
93
 
94
94
  # Returns true if the model stores a cache of the features area
95
95
  def has_features_area?
96
- column_names.include? 'features_area'
96
+ owner_class_has_loaded_column?('features_area')
97
97
  end
98
98
 
99
99
  def area_in_square_meters
100
- features.area_in_square_meters
100
+ features.area
101
101
  end
102
102
 
103
103
  private
@@ -160,6 +160,12 @@ module SpatialFeatures
160
160
  scope = scope.where(:spatial_model_id => other) unless Utils.class_of(other) == other
161
161
  return scope
162
162
  end
163
+
164
+ def owner_class_has_loaded_column?(column_name)
165
+ return false unless connected?
166
+ return false unless table_exists?
167
+ column_names.include? column_name
168
+ end
163
169
  end
164
170
 
165
171
  module InstanceMethods
@@ -202,7 +208,7 @@ module SpatialFeatures
202
208
  end
203
209
 
204
210
  def features_area_in_square_meters
205
- @features_area_in_square_meters ||= features.area
211
+ aggregate_feature&.area
206
212
  end
207
213
 
208
214
  def total_intersection_area_in_square_meters(other)
@@ -79,14 +79,24 @@ module SpatialFeatures
79
79
  end
80
80
 
81
81
  def import_features(imports, skip_invalid)
82
- self.features.delete_all
82
+ features.delete_all
83
83
  valid, invalid = Feature.defer_aggregate_refresh do
84
- imports.flat_map(&:features).partition do |feature|
85
- feature.spatial_model = self
86
- feature.save
84
+ Feature.without_caching_derivatives do
85
+ imports.flat_map(&:features).partition do |feature|
86
+ feature.spatial_model = self
87
+ feature.save
88
+ end
87
89
  end
88
90
  end
89
91
 
92
+ if persisted?
93
+ features.reset # Reset the association cache because we've updated the features
94
+ features.cache_derivatives
95
+ else
96
+ self.features = valid # Assign the features so when we save this record we update the foreign key on the features
97
+ Feature.where(id: features).cache_derivatives
98
+ end
99
+
90
100
  errors = imports.flat_map(&:errors)
91
101
  invalid.each do |feature|
92
102
  errors << "Feature #{feature.name}: #{feature.errors.full_messages.to_sentence}"
@@ -98,7 +108,7 @@ module SpatialFeatures
98
108
  raise ImportError, "Error updating #{self.class} #{self.id}. #{errors.to_sentence}"
99
109
  end
100
110
 
101
- self.features = valid
111
+ return features
102
112
  end
103
113
 
104
114
  def features_cache_key_matches?(cache_key)
@@ -12,7 +12,7 @@ module SpatialFeatures
12
12
  end
13
13
 
14
14
  def cache_key
15
- @cache_key ||= Digest::MD5.hexdigest(features.to_json)
15
+ @cache_key ||= Digest::MD5.file(archive).to_s
16
16
  end
17
17
 
18
18
  private
@@ -49,13 +49,27 @@ module SpatialFeatures
49
49
  SQL
50
50
  end
51
51
 
52
+
52
53
  def file
53
- @file ||= Download.open(@data, unzip: /\.shp$/, downcase: true)
54
+ @file ||= begin
55
+ validate_file!
56
+ Download.open(archive, unzip: /\.shp$/, downcase: true)
57
+ end
58
+ end
59
+
60
+ def validate_file!
61
+ return unless Unzip.is_zip?(archive)
62
+ Validation.validate_shapefile_archive!(Download.entries(archive), default_proj4_projection: default_proj4_projection)
63
+ end
64
+
65
+ def archive
66
+ @archive ||= Download.open(@data)
54
67
  end
55
68
  end
56
69
 
57
70
  # ERRORS
58
71
  class IndeterminateShapefileProjection < SpatialFeatures::ImportError; end
59
72
  class IncompleteShapefileArchive < SpatialFeatures::ImportError; end
73
+ class InvalidShapefileArchive < SpatialFeatures::ImportError; end
60
74
  end
61
75
  end
@@ -0,0 +1,55 @@
1
+ module SpatialFeatures
2
+ module Validation
3
+ # SHP file must come first
4
+ REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS = %w[shp shx dbf prj].freeze
5
+
6
+ # Check if a shapefile archive includes the required component files, otherwise
7
+ # raise an exception.
8
+ #
9
+ # @param [Zip::File] zip_file A Zip::File object
10
+ # @param [String] default_proj4_projection Optional, if supplied we don't raise an exception when we're missing a .PRJ file
11
+ # @param [Boolean] allow_generic_zip_files When true, we skip validation entirely if the archive does not contain a .SHP file
12
+ def self.validate_shapefile_archive!(zip_file, default_proj4_projection: nil, allow_generic_zip_files: false)
13
+ zip_file_entries = zip_file.entries.each_with_object({}) do |f, obj|
14
+ ext = File.extname(f.name).downcase[1..-1]
15
+ next unless ext
16
+
17
+ if ext.casecmp?("shp") && obj.key?(ext)
18
+ raise ::SpatialFeatures::Importers::InvalidShapefileArchive, "Zip files that contain multiple Shapefiles are not supported. Please separate each Shapefile into its own zip file."
19
+ end
20
+
21
+ obj[ext] = File.basename(f.name, '.*')
22
+ end
23
+
24
+ shapefile_basename = zip_file_entries["shp"]
25
+ unless shapefile_basename
26
+ # not a shapefile archive but we don't care
27
+ return if allow_generic_zip_files
28
+
29
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file"
30
+ end
31
+
32
+ REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS[1..-1].each do |ext|
33
+ ext_basename = zip_file_entries[ext]
34
+ next if ext_basename&.casecmp?(shapefile_basename)
35
+
36
+ case ext
37
+ when "prj"
38
+ # special case for missing projection files to allow using default_proj4_projection
39
+ next if default_proj4_projection
40
+
41
+ raise ::SpatialFeatures::Importers::IndeterminateShapefileProjection, "Shapefile archive is missing a projection file: #{expected_component_path(shapefile_basename, ext)}"
42
+ else
43
+ # for all un-handled cases of missing files raise the more generic error
44
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a required file: #{expected_component_path(shapefile_basename, ext)}"
45
+ end
46
+ end
47
+
48
+ true
49
+ end
50
+
51
+ def self.expected_component_path(basename, ext)
52
+ "#{basename}.#{ext}"
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "2.14.0"
2
+ VERSION = "2.16.0"
3
3
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.14.0
4
+ version: 2.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wallace
8
8
  - Nicholas Jakobsen
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-02-17 00:00:00.000000000 Z
12
+ date: 2021-07-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -20,7 +20,7 @@ dependencies:
20
20
  version: '4.2'
21
21
  - - "<"
22
22
  - !ruby/object:Gem::Version
23
- version: '6.0'
23
+ version: '7.0'
24
24
  type: :runtime
25
25
  prerelease: false
26
26
  version_requirements: !ruby/object:Gem::Requirement
@@ -30,7 +30,7 @@ dependencies:
30
30
  version: '4.2'
31
31
  - - "<"
32
32
  - !ruby/object:Gem::Version
33
- version: '6.0'
33
+ version: '7.0'
34
34
  - !ruby/object:Gem::Dependency
35
35
  name: delayed_job_active_record
36
36
  requirement: !ruby/object:Gem::Requirement
@@ -63,16 +63,16 @@ dependencies:
63
63
  name: rubyzip
64
64
  requirement: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '1.1'
68
+ version: 1.0.0
69
69
  type: :runtime
70
70
  prerelease: false
71
71
  version_requirements: !ruby/object:Gem::Requirement
72
72
  requirements:
73
- - - "~>"
73
+ - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '1.1'
75
+ version: 1.0.0
76
76
  - !ruby/object:Gem::Dependency
77
77
  name: nokogiri
78
78
  requirement: !ruby/object:Gem::Requirement
@@ -107,14 +107,14 @@ dependencies:
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '0'
110
+ version: '1'
111
111
  type: :development
112
112
  prerelease: false
113
113
  version_requirements: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '0'
117
+ version: '1'
118
118
  - !ruby/object:Gem::Dependency
119
119
  name: rspec
120
120
  requirement: !ruby/object:Gem::Requirement
@@ -166,6 +166,7 @@ files:
166
166
  - lib/spatial_features/uncached_result.rb
167
167
  - lib/spatial_features/unzip.rb
168
168
  - lib/spatial_features/utils.rb
169
+ - lib/spatial_features/validation.rb
169
170
  - lib/spatial_features/venn_polygons.rb
170
171
  - lib/spatial_features/version.rb
171
172
  - lib/tasks/spatial_features_tasks.rake
@@ -173,7 +174,7 @@ homepage: https://github.com/culturecode/spatial_features
173
174
  licenses:
174
175
  - MIT
175
176
  metadata: {}
176
- post_install_message:
177
+ post_install_message:
177
178
  rdoc_options: []
178
179
  require_paths:
179
180
  - lib
@@ -188,8 +189,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
189
  - !ruby/object:Gem::Version
189
190
  version: '0'
190
191
  requirements: []
191
- rubygems_version: 3.0.3
192
- signing_key:
192
+ rubygems_version: 3.0.8
193
+ signing_key:
193
194
  specification_version: 4
194
195
  summary: Adds spatial methods to a model.
195
196
  test_files: []