spatial_features 2.17.0 → 2.18.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +5 -1
- data/app/models/abstract_feature.rb +15 -13
- data/app/models/aggregate_feature.rb +1 -1
- data/app/models/feature.rb +10 -0
- data/lib/spatial_features/download.rb +16 -6
- data/lib/spatial_features/has_spatial_features/feature_import.rb +2 -2
- data/lib/spatial_features/has_spatial_features/queued_spatial_processing.rb +60 -4
- data/lib/spatial_features/has_spatial_features.rb +6 -2
- data/lib/spatial_features/importers/base.rb +6 -0
- data/lib/spatial_features/importers/file.rb +22 -6
- data/lib/spatial_features/importers/geojson.rb +29 -0
- data/lib/spatial_features/importers/json_arcgis.rb +1 -5
- data/lib/spatial_features/importers/kml.rb +1 -1
- data/lib/spatial_features/importers/shapefile.rb +26 -8
- data/lib/spatial_features/unzip.rb +3 -3
- data/lib/spatial_features/utils.rb +9 -0
- data/lib/spatial_features/validation.rb +36 -42
- data/lib/spatial_features/version.rb +1 -1
- data/lib/spatial_features.rb +1 -0
- metadata +7 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 41b8326969b8e58e74200ad16194cbc2e6307ba9a1e1b81cc1bc0339ead5ecd5
|
4
|
+
data.tar.gz: aeee4a1a92da17454c61f853cd6cb9917b727dbc4a5bc8095cc74d17846642f5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4f793c92a7c6018655bf8cd20f77f0b7b3b106cd8922fede6090c7c3e8513df966fedddbd1f1e5614a9b14f41b47fd3658e0b8db642cd7287138b611b398735a
|
7
|
+
data.tar.gz: 4b965732b239b2fde43d1449fe31862255d03c5e84f34011707cf26ffffd3b9775646712f754863305f1375e3cb64136bef028558c40dc0f823d58db69408eed
|
data/README.md
CHANGED
@@ -110,7 +110,7 @@ You can specify multiple import sources for geometry. Each key is a method that
|
|
110
110
|
each value is the Importer to use to parse the data. See each Importer for more details.
|
111
111
|
```ruby
|
112
112
|
class Location < ActiveRecord::Base
|
113
|
-
has_spatial_features :import => { :remote_kml_url => 'KMLFile', :file => 'File' }
|
113
|
+
has_spatial_features :import => { :remote_kml_url => 'KMLFile', :file => 'File', :geojson => 'GeoJSON' }
|
114
114
|
|
115
115
|
def remote_kml_url
|
116
116
|
"www.test.com/kml/#{id}.kml"
|
@@ -119,6 +119,10 @@ class Location < ActiveRecord::Base
|
|
119
119
|
def file
|
120
120
|
File.open('local/files/my_kml')
|
121
121
|
end
|
122
|
+
|
123
|
+
def geojson
|
124
|
+
{ "type" => "FeatureCollection", "features" => [] }
|
125
|
+
end
|
122
126
|
end
|
123
127
|
```
|
124
128
|
|
@@ -19,17 +19,16 @@ 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
|
-
def self.cache_key
|
23
|
-
result = connection.select_one(all.select('max(id) AS max, count(*) AS count').to_sql)
|
24
|
-
"#{result['max']}-#{result['count']}"
|
25
|
-
end
|
26
|
-
|
27
22
|
# for Rails >= 5 ActiveRecord collections we override the collection_cache_key
|
28
23
|
# to prevent Rails doing its default query on `updated_at`
|
29
24
|
def self.collection_cache_key(_collection, _timestamp_column)
|
30
25
|
self.cache_key
|
31
26
|
end
|
32
27
|
|
28
|
+
def self.cache_key
|
29
|
+
"#{maximum(:id)}-#{count}"
|
30
|
+
end
|
31
|
+
|
33
32
|
def self.with_metadata(k, v)
|
34
33
|
if k.present? && v.present?
|
35
34
|
where('metadata->? = ?', k, v)
|
@@ -50,14 +49,13 @@ class AbstractFeature < ActiveRecord::Base
|
|
50
49
|
where(:feature_type => 'point')
|
51
50
|
end
|
52
51
|
|
53
|
-
def self.
|
54
|
-
# where("ST_DWithin(features.geog, ST_SetSRID( ST_Point( -71.104, 42.315), 4326)::geography, :distance)", :lat => lat, :lng => lng, :distance => distance_in_meters)
|
52
|
+
def self.within_distance_of_point(lat, lng, distance_in_meters)
|
55
53
|
where("ST_DWithin(features.geog, ST_Point(:lng, :lat), :distance)", :lat => lat, :lng => lng, :distance => distance_in_meters)
|
56
54
|
end
|
57
55
|
|
58
56
|
def self.area_in_square_meters(geom = 'geom_lowres')
|
59
57
|
current_scope = all.polygons
|
60
|
-
unscoped {
|
58
|
+
unscoped { SpatialFeatures::Utils.select_db_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
|
61
59
|
end
|
62
60
|
|
63
61
|
def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
|
@@ -67,13 +65,17 @@ class AbstractFeature < ActiveRecord::Base
|
|
67
65
|
query = base_class.unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
|
68
66
|
.from(scope, "features")
|
69
67
|
.joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
|
70
|
-
return
|
68
|
+
return SpatialFeatures::Utils.select_db_value(query).to_f
|
71
69
|
end
|
72
70
|
|
73
71
|
def self.intersecting(other)
|
74
72
|
join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
|
75
73
|
end
|
76
74
|
|
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
|
77
|
+
end
|
78
|
+
|
77
79
|
def self.invalid(column = 'geog::geometry')
|
78
80
|
select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
|
79
81
|
end
|
@@ -148,7 +150,7 @@ class AbstractFeature < ActiveRecord::Base
|
|
148
150
|
)
|
149
151
|
)
|
150
152
|
SQL
|
151
|
-
|
153
|
+
SpatialFeatures::Utils.select_db_value(all.select(sql))
|
152
154
|
end
|
153
155
|
|
154
156
|
def feature_bounds
|
@@ -176,17 +178,17 @@ class AbstractFeature < ActiveRecord::Base
|
|
176
178
|
private
|
177
179
|
|
178
180
|
def make_valid
|
179
|
-
self.geog =
|
181
|
+
self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Buffer('#{sanitize}', 0)")
|
180
182
|
end
|
181
183
|
|
182
184
|
# Use ST_Force2D to discard z-coordinates that cause failures later in the process
|
183
185
|
def sanitize
|
184
|
-
self.geog =
|
186
|
+
self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Force2D('#{geog}')")
|
185
187
|
end
|
186
188
|
|
187
189
|
SRID_CACHE = {}
|
188
190
|
def self.detect_srid(column_name)
|
189
|
-
SRID_CACHE[column_name] ||=
|
191
|
+
SRID_CACHE[column_name] ||= SpatialFeatures::Utils.select_db_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
|
190
192
|
end
|
191
193
|
|
192
194
|
def self.join_other_features(other)
|
@@ -14,7 +14,7 @@ class AggregateFeature < AbstractFeature
|
|
14
14
|
SQL
|
15
15
|
|
16
16
|
# Remove empty features so ST_COLLECT doesn't choke. This seems to be a difference between PostGIS 2.x and 3.x
|
17
|
-
self.geog =
|
17
|
+
self.geog = SpatialFeatures::Utils.select_db_value <<~SQL
|
18
18
|
SELECT COALESCE(ST_Collect(unnest)::geography, ST_GeogFromText('MULTIPOLYGON EMPTY'))
|
19
19
|
FROM (SELECT unnest(#{feature_array_sql})) AS features
|
20
20
|
WHERE NOT ST_IsEmpty(unnest)
|
data/app/models/feature.rb
CHANGED
@@ -9,6 +9,8 @@ class Feature < AbstractFeature
|
|
9
9
|
|
10
10
|
validates_inclusion_of :feature_type, :in => FEATURE_TYPES
|
11
11
|
|
12
|
+
before_save :truncate_name
|
13
|
+
|
12
14
|
after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
|
13
15
|
|
14
16
|
# Features are used for display so we also cache their KML representation
|
@@ -60,4 +62,12 @@ class Feature < AbstractFeature
|
|
60
62
|
# this field blank.
|
61
63
|
spatial_model_id? && automatically_refresh_aggregate && saved_change_to_geog?
|
62
64
|
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def truncate_name
|
69
|
+
return unless name?
|
70
|
+
col_size = Feature.columns_hash["name"]&.limit || 255
|
71
|
+
self.name = name.to_s.truncate(col_size)
|
72
|
+
end
|
63
73
|
end
|
@@ -4,20 +4,30 @@ 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.read(file, unzip: nil, **unzip_options)
|
7
|
-
file =
|
7
|
+
file = Download.open_each(file, unzip: unzip, **unzip_options).first
|
8
8
|
path = ::File.path(file)
|
9
9
|
return ::File.read(path)
|
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(file)
|
13
14
|
file = Kernel.open(file)
|
14
15
|
file = normalize_file(file) if file.is_a?(StringIO)
|
15
|
-
if unzip && Unzip.is_zip?(file)
|
16
|
-
file = find_in_zip(file, find: unzip, **unzip_options)
|
17
|
-
end
|
18
16
|
return file
|
19
17
|
end
|
20
18
|
|
19
|
+
# file can be a url, path, or file, any of which can return be a zipped archive
|
20
|
+
def self.open_each(file, unzip: nil, **unzip_options)
|
21
|
+
file = Download.open(file)
|
22
|
+
files = if unzip && Unzip.is_zip?(file)
|
23
|
+
find_in_zip(file, find: unzip, **unzip_options)
|
24
|
+
else
|
25
|
+
[file]
|
26
|
+
end
|
27
|
+
|
28
|
+
return files.map { |f| File.open(f) }
|
29
|
+
end
|
30
|
+
|
21
31
|
def self.normalize_file(file)
|
22
32
|
Tempfile.new.tap do |temp|
|
23
33
|
temp.binmode
|
@@ -33,7 +43,7 @@ module SpatialFeatures
|
|
33
43
|
end
|
34
44
|
|
35
45
|
def self.find_in_zip(file, find:, **unzip_options)
|
36
|
-
|
46
|
+
Unzip.paths(file, :find => find, **unzip_options)
|
37
47
|
end
|
38
48
|
end
|
39
49
|
end
|
@@ -68,8 +68,8 @@ module SpatialFeatures
|
|
68
68
|
|
69
69
|
def spatial_feature_imports(import_options, make_valid)
|
70
70
|
import_options.flat_map do |data_method, importer_name|
|
71
|
-
Array.wrap(send(data_method)).
|
72
|
-
spatial_importer_from_name(importer_name).
|
71
|
+
Array.wrap(send(data_method)).flat_map do |data|
|
72
|
+
spatial_importer_from_name(importer_name).create_all(data, :make_valid => make_valid) if data.present?
|
73
73
|
end
|
74
74
|
end.compact
|
75
75
|
end
|
@@ -11,7 +11,22 @@ module SpatialFeatures
|
|
11
11
|
end
|
12
12
|
|
13
13
|
def updating_features?
|
14
|
-
|
14
|
+
case spatial_processing_status(:update_features!)
|
15
|
+
when :queued, :processing
|
16
|
+
true
|
17
|
+
else
|
18
|
+
false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def updating_features_failed?
|
23
|
+
spatial_processing_status(:update_features!) == :failure
|
24
|
+
end
|
25
|
+
|
26
|
+
def spatial_processing_status(method_name)
|
27
|
+
if has_attribute?(:spatial_processing_status_cache)
|
28
|
+
spatial_processing_status_cache[method_name.to_s]&.to_sym
|
29
|
+
end
|
15
30
|
end
|
16
31
|
|
17
32
|
def feature_update_error
|
@@ -27,17 +42,58 @@ module SpatialFeatures
|
|
27
42
|
end
|
28
43
|
|
29
44
|
def spatial_processing_jobs(suffix = nil)
|
30
|
-
Delayed::Job.where(
|
45
|
+
Delayed::Job.where(:queue => "#{spatial_processing_queue_name}#{suffix}")
|
31
46
|
end
|
32
47
|
|
33
48
|
private
|
34
49
|
|
35
50
|
def queue_spatial_task(method_name, *args)
|
36
|
-
|
51
|
+
Delayed::Job.enqueue SpatialProcessingJob.new(self, method_name, *args), :queue => spatial_processing_queue_name + method_name
|
37
52
|
end
|
38
53
|
|
39
54
|
def spatial_processing_queue_name
|
40
|
-
"#{
|
55
|
+
"#{model_name}/#{id}/"
|
56
|
+
end
|
57
|
+
|
58
|
+
# CLASSES
|
59
|
+
|
60
|
+
class SpatialProcessingJob
|
61
|
+
def initialize(record, method_name, *args)
|
62
|
+
@record = record
|
63
|
+
@method_name = method_name
|
64
|
+
@args = args
|
65
|
+
end
|
66
|
+
|
67
|
+
def enqueue(job)
|
68
|
+
update_cached_status(:queued)
|
69
|
+
end
|
70
|
+
|
71
|
+
def perform
|
72
|
+
update_cached_status(:processing)
|
73
|
+
@record.send(@method_name, *@args)
|
74
|
+
end
|
75
|
+
|
76
|
+
def success(job)
|
77
|
+
update_cached_status(:success)
|
78
|
+
end
|
79
|
+
|
80
|
+
def error(job)
|
81
|
+
update_cached_status(:failure)
|
82
|
+
end
|
83
|
+
|
84
|
+
def failure(job)
|
85
|
+
update_cached_status(:failure)
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
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
|
96
|
+
end
|
41
97
|
end
|
42
98
|
end
|
43
99
|
end
|
@@ -203,7 +203,7 @@ module SpatialFeatures
|
|
203
203
|
|
204
204
|
def bounds
|
205
205
|
if association(:aggregate_feature).loaded?
|
206
|
-
aggregate_feature
|
206
|
+
aggregate_feature&.feature_bounds
|
207
207
|
else
|
208
208
|
result = aggregate_features.pluck(:north, :east, :south, :west).first
|
209
209
|
[:north, :east, :south, :west].zip(result.map {|bound| BigDecimal(bound) }).to_h.with_indifferent_access if result
|
@@ -217,7 +217,11 @@ module SpatialFeatures
|
|
217
217
|
end
|
218
218
|
|
219
219
|
def features_area_in_square_meters
|
220
|
-
aggregate_feature
|
220
|
+
if association(:aggregate_feature).loaded?
|
221
|
+
aggregate_feature&.area
|
222
|
+
else
|
223
|
+
aggregate_features.pluck(:area).first
|
224
|
+
end
|
221
225
|
end
|
222
226
|
|
223
227
|
def total_intersection_area_in_square_meters(other)
|
@@ -19,6 +19,12 @@ module SpatialFeatures
|
|
19
19
|
@cache_key ||= Digest::MD5.hexdigest(@data)
|
20
20
|
end
|
21
21
|
|
22
|
+
# factory method that should always be used instead of `new` when creating importers
|
23
|
+
# returns an array of Importer::* objects
|
24
|
+
def self.create_all(data, **options)
|
25
|
+
[new(data, **options)]
|
26
|
+
end
|
27
|
+
|
22
28
|
private
|
23
29
|
|
24
30
|
def build_features
|
@@ -3,18 +3,34 @@ require 'open-uri'
|
|
3
3
|
module SpatialFeatures
|
4
4
|
module Importers
|
5
5
|
class File < SimpleDelegator
|
6
|
-
|
6
|
+
INVALID_ARCHIVE = "Archive did not contain a .kml or .shp file. Supported formats are KMZ, KML, and zipped ArcGIS shapefiles.".freeze
|
7
|
+
|
8
|
+
def self.create_all(data, **options)
|
9
|
+
Download.open_each(data, unzip: [/\.kml$/, /\.shp$/], downcase: true).map do |file|
|
10
|
+
new(data, **options, current_file: file)
|
11
|
+
end
|
12
|
+
rescue Unzip::PathNotFound
|
13
|
+
raise ImportError, INVALID_ARCHIVE
|
14
|
+
end
|
15
|
+
|
16
|
+
# The File importer may be initialized multiple times by `::create_all` if it
|
17
|
+
# receives ZIP data containing multiple KML or SHP files. We use `current_file`
|
18
|
+
# to distinguish which file in the archive is currently being
|
19
|
+
# processed.
|
20
|
+
#
|
21
|
+
# If no `current_file` is passed then we just take the first valid file that we find.
|
22
|
+
def initialize(data, *args, current_file: nil, **options)
|
7
23
|
begin
|
8
|
-
|
24
|
+
current_file ||= Download.open_each(data, unzip: [/\.kml$/, /\.shp$/], downcase: true).first
|
9
25
|
rescue Unzip::PathNotFound
|
10
|
-
raise ImportError,
|
26
|
+
raise ImportError, INVALID_ARCHIVE
|
11
27
|
end
|
12
28
|
|
13
|
-
case ::File.extname(
|
29
|
+
case ::File.extname(current_file.path.downcase)
|
14
30
|
when '.kml'
|
15
|
-
__setobj__(KMLFile.new(
|
31
|
+
__setobj__(KMLFile.new(current_file, *args, **options))
|
16
32
|
when '.shp'
|
17
|
-
__setobj__(Shapefile.new(
|
33
|
+
__setobj__(Shapefile.new(current_file, *args, **options))
|
18
34
|
else
|
19
35
|
raise ImportError, "Could not import file. Supported formats are KMZ, KML, and zipped ArcGIS shapefiles"
|
20
36
|
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
require 'digest/md5'
|
3
|
+
|
4
|
+
module SpatialFeatures
|
5
|
+
module Importers
|
6
|
+
class GeoJSON < Base
|
7
|
+
def cache_key
|
8
|
+
@cache_key ||= Digest::MD5.hexdigest(@data.to_json)
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def each_record(&block)
|
14
|
+
return unless @data
|
15
|
+
|
16
|
+
@data.fetch('features', []).each do |record|
|
17
|
+
metadata = record['properties'] || {}
|
18
|
+
name = metadata.delete('name')
|
19
|
+
yield OpenStruct.new(
|
20
|
+
:feature_type => record['geometry']['type'],
|
21
|
+
:geog => SpatialFeatures::Utils.geom_from_json(record['geometry']),
|
22
|
+
:name => name,
|
23
|
+
:metadata => metadata
|
24
|
+
)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -15,7 +15,7 @@ module SpatialFeatures
|
|
15
15
|
json['features'].each do |record|
|
16
16
|
yield OpenStruct.new(
|
17
17
|
:feature_type => record['geometry']['type'],
|
18
|
-
:geog => geom_from_json(record['geometry']),
|
18
|
+
:geog => SpatialFeatures::Utils.geom_from_json(record['geometry']),
|
19
19
|
:metadata => record['properties']
|
20
20
|
)
|
21
21
|
end
|
@@ -24,10 +24,6 @@ module SpatialFeatures
|
|
24
24
|
def esri_json_to_geojson(url)
|
25
25
|
JSON.parse(`ogr2ogr -f GeoJSON /dev/stdout "#{url}" OGRGeoJSON`)
|
26
26
|
end
|
27
|
-
|
28
|
-
def geom_from_json(geometry)
|
29
|
-
ActiveRecord::Base.connection.select_value("SELECT ST_GeomFromGeoJSON('#{geometry.to_json}')")
|
30
|
-
end
|
31
27
|
end
|
32
28
|
end
|
33
29
|
end
|
@@ -35,7 +35,7 @@ module SpatialFeatures
|
|
35
35
|
|
36
36
|
# Do query in a new thread so we use a new connection (if the query fails it will poison the transaction of the current connection)
|
37
37
|
Thread.new do
|
38
|
-
geom =
|
38
|
+
geom = SpatialFeatures::Utils.select_db_value("SELECT ST_GeomFromKML(#{ActiveRecord::Base.connection.quote(kml.to_s)})")
|
39
39
|
rescue ActiveRecord::StatementInvalid => e # Discard Invalid KML features
|
40
40
|
geom = nil
|
41
41
|
end.join
|
@@ -7,7 +7,7 @@ module SpatialFeatures
|
|
7
7
|
class_attribute :default_proj4_projection
|
8
8
|
|
9
9
|
def initialize(data, *args, proj4: nil, **options)
|
10
|
-
super(data,
|
10
|
+
super(data, **options)
|
11
11
|
@proj4 = proj4
|
12
12
|
end
|
13
13
|
|
@@ -15,8 +15,21 @@ module SpatialFeatures
|
|
15
15
|
@cache_key ||= Digest::MD5.file(archive).to_s
|
16
16
|
end
|
17
17
|
|
18
|
+
def self.create_all(data, **options)
|
19
|
+
Download.open_each(data, unzip: [/\.shp$/], downcase: true).map do |file|
|
20
|
+
new(file, **options)
|
21
|
+
end
|
22
|
+
rescue Unzip::PathNotFound
|
23
|
+
raise ImportError, INVALID_ARCHIVE
|
24
|
+
end
|
25
|
+
|
18
26
|
private
|
19
27
|
|
28
|
+
def build_features
|
29
|
+
validate_shapefile!
|
30
|
+
super
|
31
|
+
end
|
32
|
+
|
20
33
|
def each_record(&block)
|
21
34
|
RGeo::Shapefile::Reader.open(file.path) do |records|
|
22
35
|
records.each do |record|
|
@@ -49,17 +62,22 @@ module SpatialFeatures
|
|
49
62
|
SQL
|
50
63
|
end
|
51
64
|
|
52
|
-
|
65
|
+
# the individual SHP file for processing (automatically extracted from a ZIP archive if necessary)
|
53
66
|
def file
|
54
|
-
@file ||=
|
55
|
-
|
56
|
-
|
67
|
+
@file ||= Unzip.is_zip?(archive) ? possible_shp_files.first : archive
|
68
|
+
end
|
69
|
+
|
70
|
+
# a zip archive may contain multiple SHP files
|
71
|
+
def possible_shp_files
|
72
|
+
@possible_shp_files ||= begin
|
73
|
+
Download.open_each(archive, unzip: /\.shp$/, downcase: true)
|
74
|
+
rescue Unzip::PathNotFound
|
75
|
+
raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file"
|
57
76
|
end
|
58
77
|
end
|
59
78
|
|
60
|
-
def
|
61
|
-
|
62
|
-
Validation.validate_shapefile_archive!(Download.entries(archive), default_proj4_projection: default_proj4_projection)
|
79
|
+
def validate_shapefile!
|
80
|
+
Validation.validate_shapefile!(file, default_proj4_projection: default_proj4_projection)
|
63
81
|
end
|
64
82
|
|
65
83
|
def archive
|
@@ -6,11 +6,11 @@ module SpatialFeatures
|
|
6
6
|
paths = extract(file_path, **extract_options)
|
7
7
|
|
8
8
|
if find = Array.wrap(find).presence
|
9
|
-
paths = paths.
|
10
|
-
raise(PathNotFound, "Archive did not contain a file matching #{find}")
|
9
|
+
paths = paths.select {|path| find.any? {|pattern| path.index(pattern) } }
|
10
|
+
raise(PathNotFound, "Archive did not contain a file matching #{find}") if paths.empty?
|
11
11
|
end
|
12
12
|
|
13
|
-
return paths
|
13
|
+
return Array(paths)
|
14
14
|
end
|
15
15
|
|
16
16
|
def self.extract(file_path, output_dir = Dir.mktmpdir, downcase: false)
|
@@ -52,5 +52,14 @@ module SpatialFeatures
|
|
52
52
|
object.unscope(:select).select(:id).to_sql
|
53
53
|
end
|
54
54
|
end
|
55
|
+
|
56
|
+
def select_db_value(query)
|
57
|
+
ActiveRecord::Base.connection.select_value(query)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Convert a hash of GeoJSON data into a PostGIS geometry object
|
61
|
+
def geom_from_json(geometry)
|
62
|
+
select_db_value("SELECT ST_GeomFromGeoJSON('#{geometry.to_json}')")
|
63
|
+
end
|
55
64
|
end
|
56
65
|
end
|
@@ -1,55 +1,49 @@
|
|
1
1
|
module SpatialFeatures
|
2
2
|
module Validation
|
3
|
-
# SHP file must come first
|
4
3
|
REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS = %w[shp shx dbf prj].freeze
|
5
4
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
5
|
+
class << self
|
6
|
+
# Check if a shapefile includes the required component files, otherwise
|
7
|
+
# raise an exception.
|
8
|
+
#
|
9
|
+
# This validation operates by checking sibling files in the same directory,
|
10
|
+
# similar to how `rgeo-shapefile` validates SHP files.
|
11
|
+
#
|
12
|
+
# @param [File] shp_file A File object
|
13
|
+
# @param [String] default_proj4_projection Optional, if supplied we don't raise an exception when we're missing a .PRJ file
|
14
|
+
def validate_shapefile!(shp_file, default_proj4_projection: nil)
|
15
|
+
basename = File.basename(shp_file.path, '.*')
|
16
|
+
path = shp_file.path.to_s.sub(/\.shp$/i, "")
|
17
|
+
|
18
|
+
required_extensions = REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS
|
19
|
+
required_extensions -= ['prj'] if default_proj4_projection
|
20
|
+
|
21
|
+
required_extensions.each do |ext|
|
22
|
+
component_path = "#{path}.#{ext}"
|
23
|
+
next if ::File.file?(component_path) && ::File.readable?(component_path)
|
24
|
+
|
25
|
+
case ext
|
26
|
+
when "prj"
|
27
|
+
raise ::SpatialFeatures::Importers::IndeterminateShapefileProjection, "Shapefile archive is missing a projection file: #{File.basename(component_path)}"
|
28
|
+
else
|
29
|
+
raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a required file: #{File.basename(component_path)}"
|
30
|
+
end
|
19
31
|
end
|
20
32
|
|
21
|
-
|
33
|
+
true
|
22
34
|
end
|
23
35
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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)}"
|
36
|
+
# Validation helper that takes examines an entire ZIP file
|
37
|
+
#
|
38
|
+
# Useful for validating before persisting records but not used internally
|
39
|
+
def validate_shapefile_archive!(path, default_proj4_projection: nil, allow_generic_zip_files: false)
|
40
|
+
Download.open_each(path, unzip: /\.shp$/, downcase: true).each do |shp_file|
|
41
|
+
validate_shapefile!(shp_file, default_proj4_projection: default_proj4_projection)
|
45
42
|
end
|
43
|
+
rescue Unzip::PathNotFound
|
44
|
+
raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file" \
|
45
|
+
unless allow_generic_zip_files
|
46
46
|
end
|
47
|
-
|
48
|
-
true
|
49
|
-
end
|
50
|
-
|
51
|
-
def self.expected_component_path(basename, ext)
|
52
|
-
"#{basename}.#{ext}"
|
53
47
|
end
|
54
48
|
end
|
55
49
|
end
|
data/lib/spatial_features.rb
CHANGED
@@ -20,6 +20,7 @@ require 'spatial_features/has_spatial_features/feature_import'
|
|
20
20
|
|
21
21
|
require 'spatial_features/importers/base'
|
22
22
|
require 'spatial_features/importers/file'
|
23
|
+
require 'spatial_features/importers/geojson'
|
23
24
|
require 'spatial_features/importers/kml'
|
24
25
|
require 'spatial_features/importers/kml_file'
|
25
26
|
require 'spatial_features/importers/kml_file_arcgis'
|
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.18.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-11-22 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rails
|
@@ -157,6 +157,7 @@ files:
|
|
157
157
|
- lib/spatial_features/has_spatial_features/queued_spatial_processing.rb
|
158
158
|
- lib/spatial_features/importers/base.rb
|
159
159
|
- lib/spatial_features/importers/file.rb
|
160
|
+
- lib/spatial_features/importers/geojson.rb
|
160
161
|
- lib/spatial_features/importers/geomark.rb
|
161
162
|
- lib/spatial_features/importers/json_arcgis.rb
|
162
163
|
- lib/spatial_features/importers/kml.rb
|
@@ -174,7 +175,7 @@ homepage: https://github.com/culturecode/spatial_features
|
|
174
175
|
licenses:
|
175
176
|
- MIT
|
176
177
|
metadata: {}
|
177
|
-
post_install_message:
|
178
|
+
post_install_message:
|
178
179
|
rdoc_options: []
|
179
180
|
require_paths:
|
180
181
|
- lib
|
@@ -189,8 +190,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
189
190
|
- !ruby/object:Gem::Version
|
190
191
|
version: '0'
|
191
192
|
requirements: []
|
192
|
-
rubygems_version: 3.0.
|
193
|
-
signing_key:
|
193
|
+
rubygems_version: 3.0.3.1
|
194
|
+
signing_key:
|
194
195
|
specification_version: 4
|
195
196
|
summary: Adds spatial methods to a model.
|
196
197
|
test_files: []
|