spatial_features 3.4.2 → 3.5.1

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: 5c6d46bc910c82f59f8dd93bbd1acebd4c9f16ea7b549615ba11fd873a586b54
4
- data.tar.gz: 3d0b3682100055904fc403d872b9ed368abb4c19ebe1154c8e057f1105a3a25f
3
+ metadata.gz: 58ed4738a69dce88104f9ad09ad32dc9f6ebe8819e6bd4bd87c30ea7bde58c70
4
+ data.tar.gz: b70f376893db22e801ce2b1535c26390f02a754a6884938713ad292cac7432da
5
5
  SHA512:
6
- metadata.gz: d80268d841701617edb70c129f94bba4ada269c850c56cc3a50fd93f97846b19c295aaeb004a05f64cae07cf392a6bb51ef13d922db593ce850729a22e5a240b
7
- data.tar.gz: 895b642f76622a38f3651a95225c1308a6de2652c1b9dd262e8c0f2ca3778a0a91860c30a6a11d801ad01ee3f4e90744ca92ed34b548ba86c57010bb59fc1433
6
+ metadata.gz: 9e0aa77934a5e00d891bb22adc72e3779267193035deaad0e236ced895c0565c06a6d4b81218e2d2708a493f9913fefdec10cac6e64b40d105cb0e0518f57ec4
7
+ data.tar.gz: 9456cac927abb58f4710cbbc6366e5f906c93460e68013be9bd48cef90340d2ccf3d43d29e8ac8dff1752fc1c3950030aa494fbaffb3ca91e8ffc3ee179d8876
data/README.md CHANGED
@@ -200,6 +200,23 @@ add_index :features, :source_identifier
200
200
  MyModel.update_features!(:force => true) # Force an `update_features!` will populate the source_identifier column.
201
201
  ```
202
202
 
203
+ ## Upgrading From 3.4 to 3.5
204
+ The gem now relies on virtual columns to set a number of derived column values.
205
+ ```ruby
206
+ change_table :features do |t|
207
+ t.remove :tilegeom, :feature_type, :centroid, :area, :north, :east, :south, :west
208
+
209
+ t.virtual :tilegeom, :type => 'geometry(Geometry,3857)', as: "ST_Transform(geom, 3857)", stored: true, :index => { :using => :gist }
210
+ t.virtual :feature_type, :type => :string, as: "CASE GeometryType(geog) WHEN 'POLYGON' THEN 'polygon' WHEN 'MULTIPOLYGON' THEN 'polygon' WHEN 'GEOMETRYCOLLECTION' THEN 'polygon' WHEN 'LINESTRING' THEN 'line' WHEN 'MULTILINESTRING' THEN 'line' WHEN 'POINT' THEN 'point' WHEN 'MULTIPOINT' THEN 'point' END", stored: true, :index => true
211
+ t.virtual :centroid, :type => :geography, as: "ST_PointOnSurface(geog::geometry)", stored: true
212
+ t.virtual :area, :type => :decimal, as: "ST_Area(geog)", stored: true
213
+ t.virtual :north, :type => :decimal, as: "ST_YMax(geog::geometry)", stored: true
214
+ t.virtual :east, :type => :decimal, as: "ST_XMax(geog::geometry)", stored: true
215
+ t.virtual :south, :type => :decimal, as: "ST_YMin(geog::geometry)", stored: true
216
+ t.virtual :west, :type => :decimal, as: "ST_XMin(geog::geometry)", stored: true
217
+ end
218
+ ```
219
+
203
220
  ## Testing
204
221
 
205
222
  Create a postgres database:
@@ -13,20 +13,19 @@ class AbstractFeature < ActiveRecord::Base
13
13
 
14
14
  FEATURE_TYPES = %w(polygon point line)
15
15
 
16
- before_validation :sanitize_feature_type
17
16
  validates_presence_of :geog
18
17
  validate :validate_geometry, if: :will_save_change_to_geog?
19
18
  before_save :sanitize, if: :will_save_change_to_geog?
20
19
  after_save :cache_derivatives, :if => [:automatically_cache_derivatives?, :saved_change_to_geog?]
21
20
 
22
- # for Rails >= 5 ActiveRecord collections we override the collection_cache_key
23
- # to prevent Rails doing its default query on `updated_at`
24
- def self.collection_cache_key(_collection, _timestamp_column)
25
- self.cache_key
21
+ def self.cache_key
22
+ collection_cache_key
26
23
  end
27
24
 
28
- def self.cache_key
29
- "#{maximum(:id)}-#{count}"
25
+ # for Rails >= 5 ActiveRecord collections we override the collection_cache_key
26
+ # to prevent Rails doing its default query on `updated_at`
27
+ def self.collection_cache_key(collection = all, *)
28
+ "#{collection.maximum(:id)}-#{collection.count}"
30
29
  end
31
30
 
32
31
  def self.with_metadata(k, v)
@@ -113,13 +112,7 @@ class AbstractFeature < ActiveRecord::Base
113
112
 
114
113
  def self.cache_derivatives(options = {})
115
114
  update_all <<-SQL.squish
116
- geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
117
- north = ST_YMax(geog::geometry),
118
- east = ST_XMax(geog::geometry),
119
- south = ST_YMin(geog::geometry),
120
- west = ST_XMin(geog::geometry),
121
- area = ST_Area(geog),
122
- centroid = ST_PointOnSurface(geog::geometry)
115
+ geom = ST_Transform(geog::geometry, #{detect_srid('geom')})
123
116
  SQL
124
117
 
125
118
  invalid('geom').update_all <<-SQL.squish
@@ -127,8 +120,7 @@ class AbstractFeature < ActiveRecord::Base
127
120
  SQL
128
121
 
129
122
  update_all <<-SQL.squish
130
- geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)}),
131
- tilegeom = ST_Transform(geom, 3857)
123
+ geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)})
132
124
  SQL
133
125
 
134
126
  invalid('geom_lowres').update_all <<-SQL.squish
@@ -286,10 +278,6 @@ class AbstractFeature < ActiveRecord::Base
286
278
  return error.fetch('invalid_geometry_message') if error
287
279
  end
288
280
 
289
- def sanitize_feature_type
290
- self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) }
291
- end
292
-
293
281
  def sanitize_input_for_sql(input)
294
282
  self.class.send(:sanitize_sql_for_conditions, input)
295
283
  end
@@ -9,7 +9,7 @@ class Feature < AbstractFeature
9
9
 
10
10
  has_one :aggregate_feature, lambda { |feature| where(:spatial_model_type => feature.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id
11
11
 
12
- validates_inclusion_of :feature_type, :in => FEATURE_TYPES
12
+ scope :source_identifier, lambda {|source_identifier| where(:source_identifier => source_identifier) if source_identifier.present? }
13
13
 
14
14
  before_save :truncate_name
15
15
 
@@ -1,10 +1,10 @@
1
1
  module SpatialExtensions
2
2
  private
3
3
 
4
- def abstract_refresh_geometry_action(models)
4
+ def abstract_refresh_geometry_action(models, **update_options)
5
5
  Array.wrap(models).each do |model|
6
6
  model.failed_feature_update_jobs.destroy_all
7
- model.delay_update_features!
7
+ model.delay_update_features!(**update_options)
8
8
  end
9
9
  end
10
10
 
@@ -54,6 +54,10 @@ module SpatialFeatures
54
54
  if skip_invalid
55
55
  Rails.logger.warn "Error updating #{self.class} #{self.id}. #{e.message}"
56
56
  return nil
57
+ elsif ENCODING_ERROR.match?(e.message)
58
+ raise ImportEncodingError,
59
+ "One or more features you are trying to import has text encoded in an un-supported format (#{e.message})",
60
+ e.backtrace
57
61
  else
58
62
  raise ImportError, e.message, e.backtrace
59
63
  end
@@ -151,5 +155,5 @@ module SpatialFeatures
151
155
  end
152
156
  end
153
157
 
154
- class ImportError < StandardError; end
158
+ ENCODING_ERROR = /invalid byte sequence/i.freeze
155
159
  end
@@ -1,17 +1,27 @@
1
1
  module SpatialFeatures
2
2
  module QueuedSpatialProcessing
3
3
  extend ActiveSupport::Concern
4
+ mattr_accessor :priority_offset, default: 0 # Offsets the queued priority of spatial tasks. Lower numbers run with higher priority
4
5
 
5
- def queue_update_spatial_cache(*args)
6
- queue_spatial_task('update_spatial_cache', *args)
6
+ def self.update_cached_status(record, method_name, state)
7
+ return unless record.has_attribute?(:spatial_processing_status_cache)
8
+
9
+ cache = record.spatial_processing_status_cache || {}
10
+ cache[method_name] = state
11
+ record.spatial_processing_status_cache = cache
12
+ record.update_column(:spatial_processing_status_cache, cache) if record.will_save_change_to_spatial_processing_status_cache?
13
+ end
14
+
15
+ def queue_update_spatial_cache(*args, priority: priority_offset + 1, **kwargs)
16
+ queue_spatial_task('update_spatial_cache', *args, priority:, **kwargs)
7
17
  end
8
18
 
9
- def delay_update_features!(*args)
10
- queue_spatial_task('update_features!', *args)
19
+ def delay_update_features!(*args, priority: priority_offset + 0, **kwargs)
20
+ queue_spatial_task('update_features!', *args, priority:, **kwargs)
11
21
  end
12
22
 
13
- def updating_features?
14
- case spatial_processing_status(:update_features!)
23
+ def updating_features?(**options)
24
+ case spatial_processing_status(:update_features!, **options)
15
25
  when :queued, :processing
16
26
  true
17
27
  else
@@ -23,18 +33,37 @@ module SpatialFeatures
23
33
  spatial_processing_status(:update_features!) == :failure
24
34
  end
25
35
 
26
- def spatial_processing_status(method_name)
36
+ def spatial_processing_status(method_name, use_cache: true)
27
37
  if has_attribute?(:spatial_processing_status_cache)
38
+ update_spatial_processing_status(method_name) unless use_cache
28
39
  spatial_processing_status_cache[method_name.to_s]&.to_sym
29
40
  end
30
41
  end
31
42
 
43
+ def update_spatial_processing_status(method_name)
44
+ latest_job = spatial_processing_jobs(method_name).last
45
+
46
+ if !latest_job
47
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, nil)
48
+ elsif latest_job.failed_at?
49
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :failure)
50
+ elsif latest_job.locked_at?
51
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :processing)
52
+ else
53
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :queued)
54
+ end
55
+ end
56
+
32
57
  def feature_update_error
33
58
  (failed_feature_update_jobs.first.try(:last_error) || '').split("\n").first
34
59
  end
35
60
 
36
61
  def running_feature_update_jobs
37
- spatial_processing_jobs('update_features!').where(failed_at: nil)
62
+ spatial_processing_jobs('update_features!').where(failed_at: nil).where.not(locked_at: nil)
63
+ end
64
+
65
+ def queued_feature_update_jobs
66
+ spatial_processing_jobs('update_features!').where(failed_at: nil, locked_at: nil)
38
67
  end
39
68
 
40
69
  def failed_feature_update_jobs
@@ -47,8 +76,9 @@ module SpatialFeatures
47
76
 
48
77
  private
49
78
 
50
- def queue_spatial_task(method_name, *args)
51
- Delayed::Job.enqueue SpatialProcessingJob.new(self, method_name, *args), :queue => spatial_processing_queue_name + method_name
79
+ def queue_spatial_task(method_name, *args, priority: 1, **kwargs)
80
+ # NOTE: We pass kwargs as an arg because Delayed::Job does not support separation of positional and keyword arguments in Ruby 3.0. Instead we perform manual extraction in `perform`.
81
+ Delayed::Job.enqueue SpatialProcessingJob.new(self, method_name, *args, kwargs), :queue => spatial_processing_queue_name + method_name, priority:
52
82
  end
53
83
 
54
84
  def spatial_processing_queue_name
@@ -68,9 +98,15 @@ module SpatialFeatures
68
98
  update_cached_status(:queued)
69
99
  end
70
100
 
101
+ def before(job)
102
+ ids = running_jobs.where.not(:id => job.id).pluck(:id)
103
+ raise "Already processing delayed jobs in this spatial queue: Delayed::Job #{ids.to_sentence}." if ids.present?
104
+ end
105
+
71
106
  def perform
72
107
  update_cached_status(:processing)
73
- @record.send(@method_name, *@args)
108
+ options = @args.extract_options!
109
+ @record.send(@method_name, *@args, **options)
74
110
  end
75
111
 
76
112
  def success(job)
@@ -88,11 +124,13 @@ module SpatialFeatures
88
124
  private
89
125
 
90
126
  def update_cached_status(state)
91
- if @record.has_attribute?(:spatial_processing_status_cache)
92
- cache = @record.spatial_processing_status_cache || {}
93
- cache[@method_name] = state
94
- @record.update_column(:spatial_processing_status_cache, cache)
95
- end
127
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(@record, @method_name, state)
128
+ end
129
+
130
+ def running_jobs
131
+ @record.spatial_processing_jobs
132
+ .where(:locked_at => Delayed::Worker.max_run_time.ago..Time.current)
133
+ .where(:failed_at => nil)
96
134
  end
97
135
  end
98
136
  end
@@ -29,7 +29,7 @@ module SpatialFeatures
29
29
  delegate :has_spatial_features_hash?, :has_features_area?, :to => self
30
30
  end
31
31
 
32
- self.spatial_features_options = self.spatial_features_options.merge(options)
32
+ self.spatial_features_options = self.spatial_features_options.deep_merge(options)
33
33
  end
34
34
  end
35
35
 
@@ -66,5 +66,6 @@ module SpatialFeatures
66
66
  # EXCEPTIONS
67
67
 
68
68
  class ImportError < StandardError; end
69
+ class ImportEncodingError < ImportError; end
69
70
  class EmptyImportError < ImportError; end
70
71
  end
@@ -36,7 +36,7 @@ module SpatialFeatures
36
36
 
37
37
  importable_image_paths = images_from_metadata(metadata)
38
38
 
39
- yield OpenStruct.new(:feature_type => sql_type, :geog => geog, :name => name, :metadata => metadata, :importable_image_paths => importable_image_paths)
39
+ yield OpenStruct.new(:geog => geog, :name => name, :metadata => metadata, :importable_image_paths => importable_image_paths)
40
40
  end
41
41
  end
42
42
  end
@@ -6,6 +6,8 @@ module SpatialFeatures
6
6
  class Shapefile < Base
7
7
  class_attribute :default_proj4_projection
8
8
 
9
+ PROJ4_4326 = '+proj=longlat +datum=WGS84 +no_defs'.freeze
10
+
9
11
  def initialize(data, proj4: nil, **options)
10
12
  super(data, **options)
11
13
  @proj4 = proj4
@@ -25,15 +27,10 @@ module SpatialFeatures
25
27
 
26
28
  private
27
29
 
28
- def build_features
29
- validate_shapefile!
30
- super
31
- end
32
-
33
- def each_record(&block)
34
- RGeo::Shapefile::Reader.open(file.path) do |records|
30
+ def each_record
31
+ open_shapefile(archive) do |records, proj4|
35
32
  records.each do |record|
36
- yield OpenStruct.new data_from_wkt(record.geometry.as_text, proj4_projection).merge(:metadata => record.attributes) if record.geometry.present?
33
+ yield OpenStruct.new data_from_record(record, proj4) if record.geometry.present?
37
34
  end
38
35
  end
39
36
  rescue Errno::ENOENT => e
@@ -45,26 +42,61 @@ module SpatialFeatures
45
42
  end
46
43
  end
47
44
 
48
- def proj4_projection
49
- @proj4 ||= proj4_from_file || default_proj4_projection || raise(IndeterminateShapefileProjection, 'Could not determine shapefile projection. Check that `gdalsrsinfo` is installed.')
45
+ def data_from_record(record, proj4 = nil)
46
+ geometry = record.geometry
47
+ wkt = geometry.as_text
48
+ data = { :metadata => record.attributes }
49
+
50
+ if proj4 == PROJ4_4326
51
+ data[:geog] = wkt
52
+ else
53
+ data[:geog] = ActiveRecord::Base.connection.select_value <<-SQL
54
+ SELECT ST_Transform(ST_GeomFromText('#{wkt}'), '#{proj4}', 4326) AS geog
55
+ SQL
56
+ end
57
+
58
+ return data
50
59
  end
51
60
 
52
- def proj4_from_file
53
- # Sanitize: "'+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs '\n" and lately
54
- # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs \n" to
55
- # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs"
56
- `gdalsrsinfo "#{file.path}" -o proj4`.strip.remove(/^'|'$/).presence
61
+ def open_shapefile(file, &block)
62
+ # the individual SHP file for processing (automatically extracted from a ZIP archive if necessary)
63
+ file = possible_shp_files.first if Unzip.is_zip?(file)
64
+ projected_file = project_to_4326(file.path)
65
+ file = projected_file || file
66
+ validate_shapefile!(file.path)
67
+ proj4 = proj4_projection(file.path)
68
+
69
+ RGeo::Shapefile::Reader.open(file.path) do |records| # Fall back to unprojected geometry if projection fails
70
+ block.call records, proj4
71
+ end
72
+ ensure
73
+ if projected_file
74
+ projected_file.close
75
+ ::File.delete(projected_file)
76
+ end
77
+ end
78
+
79
+ def proj4_projection(file_path)
80
+ proj4_from_file(file_path) || default_proj4_projection || raise(IndeterminateShapefileProjection, 'Could not determine shapefile projection. Check that `gdalsrsinfo` is installed.')
57
81
  end
58
82
 
59
- def data_from_wkt(wkt, proj4)
60
- ActiveRecord::Base.connection.select_one <<-SQL
61
- SELECT ST_Transform(ST_GeomFromText('#{wkt}'), '#{proj4}', 4326) AS geog, GeometryType(ST_GeomFromText('#{wkt}')) AS feature_type
62
- SQL
83
+ def validate_shapefile!(file_path)
84
+ Validation.validate_shapefile!(::File.open(file_path), default_proj4_projection: default_proj4_projection)
63
85
  end
64
86
 
65
- # the individual SHP file for processing (automatically extracted from a ZIP archive if necessary)
66
- def file
67
- @file ||= Unzip.is_zip?(archive) ? possible_shp_files.first : archive
87
+ # Use OGR2OGR to reproject into EPSG:4326 so we can skip the reprojection step per-feature
88
+ def project_to_4326(file_path)
89
+ output_path = Tempfile.create([::File.basename(file_path, '.shp') + '_epsg_4326_', '.shp']) { |file| file.path }
90
+ return unless (proj4 = proj4_from_file(file_path))
91
+ return unless system("ogr2ogr -s_srs '#{proj4}' -t_srs EPSG:4326 '#{output_path}' '#{file_path}'")
92
+ return ::File.open(output_path)
93
+ end
94
+
95
+ def proj4_from_file(file_path)
96
+ # Sanitize: "'+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs '\n" and lately
97
+ # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs \n" to
98
+ # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs"
99
+ `gdalsrsinfo "#{file_path}" -o proj4`.strip.remove(/^'|'$/).presence
68
100
  end
69
101
 
70
102
  # a zip archive may contain multiple SHP files
@@ -76,10 +108,6 @@ module SpatialFeatures
76
108
  end
77
109
  end
78
110
 
79
- def validate_shapefile!
80
- Validation.validate_shapefile!(file, default_proj4_projection: default_proj4_projection)
81
- end
82
-
83
111
  def archive
84
112
  @archive ||= Download.open(@data)
85
113
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "3.4.2"
2
+ VERSION = "3.5.1"
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: 3.4.2
4
+ version: 3.5.1
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: 2023-10-26 00:00:00.000000000 Z
12
+ date: 2024-01-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -51,14 +51,14 @@ dependencies:
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '3.0'
54
+ version: '3.1'
55
55
  type: :runtime
56
56
  prerelease: false
57
57
  version_requirements: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '3.0'
61
+ version: '3.1'
62
62
  - !ruby/object:Gem::Dependency
63
63
  name: rgeo-geojson
64
64
  requirement: !ruby/object:Gem::Requirement
@@ -101,6 +101,26 @@ dependencies:
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
103
  version: '0'
104
+ - !ruby/object:Gem::Dependency
105
+ name: rails
106
+ requirement: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '7'
111
+ - - "<"
112
+ - !ruby/object:Gem::Version
113
+ version: '8'
114
+ type: :development
115
+ prerelease: false
116
+ version_requirements: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '7'
121
+ - - "<"
122
+ - !ruby/object:Gem::Version
123
+ version: '8'
104
124
  - !ruby/object:Gem::Dependency
105
125
  name: pg
106
126
  requirement: !ruby/object:Gem::Requirement
@@ -188,7 +208,7 @@ homepage: https://github.com/culturecode/spatial_features
188
208
  licenses:
189
209
  - MIT
190
210
  metadata: {}
191
- post_install_message:
211
+ post_install_message:
192
212
  rdoc_options: []
193
213
  require_paths:
194
214
  - lib
@@ -203,8 +223,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
203
223
  - !ruby/object:Gem::Version
204
224
  version: '0'
205
225
  requirements: []
206
- rubygems_version: 3.3.23
207
- signing_key:
226
+ rubygems_version: 3.5.1
227
+ signing_key:
208
228
  specification_version: 4
209
229
  summary: Adds spatial methods to a model.
210
230
  test_files: []