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 +4 -4
- data/app/models/abstract_feature.rb +32 -8
- data/app/models/feature.rb +13 -12
- data/lib/spatial_features.rb +1 -0
- data/lib/spatial_features/controller_helpers/spatial_extensions.rb +0 -2
- data/lib/spatial_features/download.rb +7 -1
- data/lib/spatial_features/has_spatial_features.rb +10 -4
- data/lib/spatial_features/has_spatial_features/feature_import.rb +15 -5
- data/lib/spatial_features/importers/shapefile.rb +16 -2
- data/lib/spatial_features/validation.rb +55 -0
- data/lib/spatial_features/version.rb +1 -1
- metadata +15 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e0bc8fc084675d82dfe1414f599b32d22b3b4435525246580c7cd2af1f5e3afc
|
4
|
+
data.tar.gz: 78b7e7fa4b2e3fe9f46931c4b5a0bcf14c3f518a9e813e48fcb3985ac1bd171f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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:
|
110
|
-
|
111
|
-
|
112
|
-
|
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 = {})
|
data/app/models/feature.rb
CHANGED
@@ -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 =
|
34
|
-
.where.not(:
|
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
|
data/lib/spatial_features.rb
CHANGED
@@ -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'
|
@@ -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
|
-
|
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
|
-
|
96
|
+
owner_class_has_loaded_column?('features_area')
|
97
97
|
end
|
98
98
|
|
99
99
|
def area_in_square_meters
|
100
|
-
features.
|
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
|
-
|
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
|
-
|
82
|
+
features.delete_all
|
83
83
|
valid, invalid = Feature.defer_aggregate_refresh do
|
84
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
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.
|
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 ||=
|
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
|
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.
|
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-
|
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: '
|
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: '
|
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:
|
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:
|
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: '
|
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: '
|
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.
|
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: []
|