spatial_features 3.4.2 → 3.4.8

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: 9407d9a0f72fe25d714ee781b2902f387c77837c8deb3b1c1df8cdaa7be97b35
4
+ data.tar.gz: af6a1f4386fcb85adb7b0c17925f13ff11f0730fd4fe407cdbf1c8efdcfbbdee
5
5
  SHA512:
6
- metadata.gz: d80268d841701617edb70c129f94bba4ada269c850c56cc3a50fd93f97846b19c295aaeb004a05f64cae07cf392a6bb51ef13d922db593ce850729a22e5a240b
7
- data.tar.gz: 895b642f76622a38f3651a95225c1308a6de2652c1b9dd262e8c0f2ca3778a0a91860c30a6a11d801ad01ee3f4e90744ca92ed34b548ba86c57010bb59fc1433
6
+ metadata.gz: 22acdcc935004caaf2783679b0e0126ddfe679cd7f51c29f0eb6f358746c9cfb01c67dd5f67db4bfa26f975aec7da783d6e3ad45312e292081677ff194761552
7
+ data.tar.gz: 84fd0dcfd2a158bd7612df2da14b6124bc9b5a4b668779057037cf1c17a4be03eda6641ee9204d3eef696766e446c3764fcb6976ea3387ad660e8fef63285c13
@@ -19,14 +19,14 @@ class AbstractFeature < ActiveRecord::Base
19
19
  before_save :sanitize, if: :will_save_change_to_geog?
20
20
  after_save :cache_derivatives, :if => [:automatically_cache_derivatives?, :saved_change_to_geog?]
21
21
 
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
22
+ def self.cache_key
23
+ collection_cache_key
26
24
  end
27
25
 
28
- def self.cache_key
29
- "#{maximum(:id)}-#{count}"
26
+ # for Rails >= 5 ActiveRecord collections we override the collection_cache_key
27
+ # to prevent Rails doing its default query on `updated_at`
28
+ def self.collection_cache_key(collection = all, *)
29
+ "#{collection.maximum(:id)}-#{collection.count}"
30
30
  end
31
31
 
32
32
  def self.with_metadata(k, v)
@@ -9,6 +9,8 @@ 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
+ scope :source_identifier, lambda {|source_identifier| where(:source_identifier => source_identifier) if source_identifier.present? }
13
+
12
14
  validates_inclusion_of :feature_type, :in => FEATURE_TYPES
13
15
 
14
16
  before_save :truncate_name
@@ -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
@@ -2,16 +2,25 @@ module SpatialFeatures
2
2
  module QueuedSpatialProcessing
3
3
  extend ActiveSupport::Concern
4
4
 
5
- def queue_update_spatial_cache(*args)
6
- queue_spatial_task('update_spatial_cache', *args)
5
+ def self.update_cached_status(record, method_name, state)
6
+ return unless record.has_attribute?(:spatial_processing_status_cache)
7
+
8
+ cache = record.spatial_processing_status_cache || {}
9
+ cache[method_name] = state
10
+ record.spatial_processing_status_cache = cache
11
+ record.update_column(:spatial_processing_status_cache, cache) if record.will_save_change_to_spatial_processing_status_cache?
12
+ end
13
+
14
+ def queue_update_spatial_cache(*args, **kwargs)
15
+ queue_spatial_task('update_spatial_cache', *args, **kwargs)
7
16
  end
8
17
 
9
- def delay_update_features!(*args)
10
- queue_spatial_task('update_features!', *args)
18
+ def delay_update_features!(*args, **kwargs)
19
+ queue_spatial_task('update_features!', *args, **kwargs)
11
20
  end
12
21
 
13
- def updating_features?
14
- case spatial_processing_status(:update_features!)
22
+ def updating_features?(**options)
23
+ case spatial_processing_status(:update_features!, **options)
15
24
  when :queued, :processing
16
25
  true
17
26
  else
@@ -23,18 +32,37 @@ module SpatialFeatures
23
32
  spatial_processing_status(:update_features!) == :failure
24
33
  end
25
34
 
26
- def spatial_processing_status(method_name)
35
+ def spatial_processing_status(method_name, use_cache: true)
27
36
  if has_attribute?(:spatial_processing_status_cache)
37
+ update_spatial_processing_status(method_name) unless use_cache
28
38
  spatial_processing_status_cache[method_name.to_s]&.to_sym
29
39
  end
30
40
  end
31
41
 
42
+ def update_spatial_processing_status(method_name)
43
+ latest_job = spatial_processing_jobs(method_name).last
44
+
45
+ if !latest_job
46
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, nil)
47
+ elsif latest_job.failed_at?
48
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :failure)
49
+ elsif latest_job.locked_at?
50
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :processing)
51
+ else
52
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(self, method_name, :queued)
53
+ end
54
+ end
55
+
32
56
  def feature_update_error
33
57
  (failed_feature_update_jobs.first.try(:last_error) || '').split("\n").first
34
58
  end
35
59
 
36
60
  def running_feature_update_jobs
37
- spatial_processing_jobs('update_features!').where(failed_at: nil)
61
+ spatial_processing_jobs('update_features!').where(failed_at: nil).where.not(locked_at: nil)
62
+ end
63
+
64
+ def queued_feature_update_jobs
65
+ spatial_processing_jobs('update_features!').where(failed_at: nil, locked_at: nil)
38
66
  end
39
67
 
40
68
  def failed_feature_update_jobs
@@ -47,8 +75,9 @@ module SpatialFeatures
47
75
 
48
76
  private
49
77
 
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
78
+ def queue_spatial_task(method_name, *args, priority: 1, **kwargs)
79
+ # 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`.
80
+ Delayed::Job.enqueue SpatialProcessingJob.new(self, method_name, *args, kwargs), :queue => spatial_processing_queue_name + method_name, :priority => priority
52
81
  end
53
82
 
54
83
  def spatial_processing_queue_name
@@ -68,9 +97,15 @@ module SpatialFeatures
68
97
  update_cached_status(:queued)
69
98
  end
70
99
 
100
+ def before(job)
101
+ ids = running_jobs.where.not(:id => job.id).pluck(:id)
102
+ raise "Already processing delayed jobs in this spatial queue: Delayed::Job #{ids.to_sentence}." if ids.present?
103
+ end
104
+
71
105
  def perform
72
106
  update_cached_status(:processing)
73
- @record.send(@method_name, *@args)
107
+ options = @args.extract_options!
108
+ @record.send(@method_name, *@args, **options)
74
109
  end
75
110
 
76
111
  def success(job)
@@ -88,11 +123,13 @@ module SpatialFeatures
88
123
  private
89
124
 
90
125
  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
126
+ SpatialFeatures::QueuedSpatialProcessing.update_cached_status(@record, @method_name, state)
127
+ end
128
+
129
+ def running_jobs
130
+ @record.spatial_processing_jobs
131
+ .where(:locked_at => Delayed::Worker.max_run_time.ago..Time.current)
132
+ .where(:failed_at => nil)
96
133
  end
97
134
  end
98
135
  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
@@ -6,6 +6,9 @@ module SpatialFeatures
6
6
  class Shapefile < Base
7
7
  class_attribute :default_proj4_projection
8
8
 
9
+ FEATURE_TYPE_FOR_DIMENSION = { 0 => 'point', 1 => 'line', 2 => 'polygon' }.freeze
10
+ PROJ4_4326 = '+proj=longlat +datum=WGS84 +no_defs'.freeze
11
+
9
12
  def initialize(data, proj4: nil, **options)
10
13
  super(data, **options)
11
14
  @proj4 = proj4
@@ -25,15 +28,10 @@ module SpatialFeatures
25
28
 
26
29
  private
27
30
 
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|
31
+ def each_record
32
+ open_shapefile(archive) do |records, proj4|
35
33
  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?
34
+ yield OpenStruct.new data_from_record(record, proj4) if record.geometry.present?
37
35
  end
38
36
  end
39
37
  rescue Errno::ENOENT => e
@@ -45,26 +43,61 @@ module SpatialFeatures
45
43
  end
46
44
  end
47
45
 
48
- def proj4_projection
49
- @proj4 ||= proj4_from_file || default_proj4_projection || raise(IndeterminateShapefileProjection, 'Could not determine shapefile projection. Check that `gdalsrsinfo` is installed.')
46
+ def data_from_record(record, proj4 = nil)
47
+ geometry = record.geometry
48
+ wkt = geometry.as_text
49
+ data = { :metadata => record.attributes, feature_type: FEATURE_TYPE_FOR_DIMENSION.fetch(geometry.dimension) }
50
+
51
+ if proj4 == PROJ4_4326
52
+ data[:geog] = wkt
53
+ else
54
+ data[:geog] = ActiveRecord::Base.connection.select_value <<-SQL
55
+ SELECT ST_Transform(ST_GeomFromText('#{wkt}'), '#{proj4}', 4326) AS geog
56
+ SQL
57
+ end
58
+
59
+ return data
50
60
  end
51
61
 
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
62
+ def open_shapefile(file, &block)
63
+ # the individual SHP file for processing (automatically extracted from a ZIP archive if necessary)
64
+ file = possible_shp_files.first if Unzip.is_zip?(file)
65
+ projected_file = project_to_4326(file.path)
66
+ file = projected_file || file
67
+ validate_shapefile!(file.path)
68
+ proj4 = proj4_projection(file.path)
69
+
70
+ RGeo::Shapefile::Reader.open(file.path) do |records| # Fall back to unprojected geometry if projection fails
71
+ block.call records, proj4
72
+ end
73
+ ensure
74
+ if projected_file
75
+ projected_file.close
76
+ ::File.delete(projected_file)
77
+ end
78
+ end
79
+
80
+ def proj4_projection(file_path)
81
+ proj4_from_file(file_path) || default_proj4_projection || raise(IndeterminateShapefileProjection, 'Could not determine shapefile projection. Check that `gdalsrsinfo` is installed.')
57
82
  end
58
83
 
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
84
+ def validate_shapefile!(file_path)
85
+ Validation.validate_shapefile!(::File.open(file_path), default_proj4_projection: default_proj4_projection)
63
86
  end
64
87
 
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
88
+ # Use OGR2OGR to reproject into EPSG:4326 so we can skip the reprojection step per-feature
89
+ def project_to_4326(file_path)
90
+ output_path = Tempfile.create([::File.basename(file_path, '.shp') + '_epsg_4326_', '.shp']) { |file| file.path }
91
+ return unless (proj4 = proj4_from_file(file_path))
92
+ return unless system("ogr2ogr -s_srs '#{proj4}' -t_srs EPSG:4326 '#{output_path}' '#{file_path}'")
93
+ return ::File.open(output_path)
94
+ end
95
+
96
+ def proj4_from_file(file_path)
97
+ # Sanitize: "'+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs '\n" and lately
98
+ # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs \n" to
99
+ # "+proj=utm +zone=11 +datum=NAD83 +units=m +no_defs"
100
+ `gdalsrsinfo "#{file_path}" -o proj4`.strip.remove(/^'|'$/).presence
68
101
  end
69
102
 
70
103
  # a zip archive may contain multiple SHP files
@@ -76,10 +109,6 @@ module SpatialFeatures
76
109
  end
77
110
  end
78
111
 
79
- def validate_shapefile!
80
- Validation.validate_shapefile!(file, default_proj4_projection: default_proj4_projection)
81
- end
82
-
83
112
  def archive
84
113
  @archive ||= Download.open(@data)
85
114
  end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "3.4.2"
2
+ VERSION = "3.4.8"
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.4.2
4
+ version: 3.4.8
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: 2023-10-26 00:00:00.000000000 Z
12
+ date: 2024-01-03 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails