spatial_features 2.17.2 → 2.17.3

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: 86e67798202370af90674a888afbf1ac4f7847fa24385e1457ebe14e3ff6d4bc
4
- data.tar.gz: 75e5c07bb3dc7c3f969f36919d7a468046fec0fcb3683e28f2c862dce9bcee11
3
+ metadata.gz: e0a8fb7f4886f07f02b8b110f7975d91538324d6cf2c5a70b3b247dfb6719c98
4
+ data.tar.gz: 15c681c5de6fccb10a515c8ff1f8a423a7c9fa1c1fded8955c2072dd76f5d4f2
5
5
  SHA512:
6
- metadata.gz: fc4d1aeb966cc2623bd8d324e6ef1bf1fc8e3b4f4bdffddaf123f30e76d827461b0b2277b141ae57a501fd26dc47105aa289771c70f302d1119f5d7270d9ff96
7
- data.tar.gz: 41a82aebeef10ff3445348a0859e2db72f42e4b5041fd27b8b72d1368b12d4294e8ec9b270bcf570280f28b23819ed3ad80b066803c0997eca9777f361a778e2
6
+ metadata.gz: 0a9224909db6dd85bf20cf9b9561e78cdc12f094423c86b0280f0e2a96e2dad9ae24030c4a4014b448f566e232b7dd37620d2a8890c411af97487a0047261346
7
+ data.tar.gz: 740174a0882bc82edd053e4adf31318c6afc51860a66a3d50068e3c106cc8ac9351061f9d4990c652c5f41704815e2d5b8c8ef3d6b7aaa47525d9663f01acce4
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
 
@@ -56,7 +56,7 @@ class AbstractFeature < ActiveRecord::Base
56
56
 
57
57
  def self.area_in_square_meters(geom = 'geom_lowres')
58
58
  current_scope = all.polygons
59
- unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
59
+ unscoped { SpatialFeatures::Utils.select_db_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
60
60
  end
61
61
 
62
62
  def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
@@ -66,7 +66,7 @@ class AbstractFeature < ActiveRecord::Base
66
66
  query = base_class.unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
67
67
  .from(scope, "features")
68
68
  .joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
69
- return connection.select_value(query).to_f
69
+ return SpatialFeatures::Utils.select_db_value(query).to_f
70
70
  end
71
71
 
72
72
  def self.intersecting(other)
@@ -147,7 +147,7 @@ class AbstractFeature < ActiveRecord::Base
147
147
  )
148
148
  )
149
149
  SQL
150
- connection.select_value(all.select(sql))
150
+ SpatialFeatures::Utils.select_db_value(all.select(sql))
151
151
  end
152
152
 
153
153
  def feature_bounds
@@ -175,17 +175,17 @@ class AbstractFeature < ActiveRecord::Base
175
175
  private
176
176
 
177
177
  def make_valid
178
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
178
+ self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Buffer('#{sanitize}', 0)")
179
179
  end
180
180
 
181
181
  # Use ST_Force2D to discard z-coordinates that cause failures later in the process
182
182
  def sanitize
183
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
183
+ self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Force2D('#{geog}')")
184
184
  end
185
185
 
186
186
  SRID_CACHE = {}
187
187
  def self.detect_srid(column_name)
188
- SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
188
+ SRID_CACHE[column_name] ||= SpatialFeatures::Utils.select_db_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
189
189
  end
190
190
 
191
191
  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 = ActiveRecord::Base.connection.select_value <<~SQL
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)
@@ -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 = open(file, unzip: unzip, **unzip_options)
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
- def self.open(file, unzip: nil, **unzip_options)
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
- return File.open(Unzip.paths(file, :find => find, **unzip_options))
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)).map do |data|
72
- spatial_importer_from_name(importer_name).new(data, :make_valid => make_valid) if data.present?
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
@@ -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
- def initialize(data, *args)
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
- file = Download.open(data, unzip: [/\.kml$/, /\.shp$/], downcase: true)
24
+ current_file ||= Download.open_each(data, unzip: [/\.kml$/, /\.shp$/], downcase: true).first
9
25
  rescue Unzip::PathNotFound
10
- raise ImportError, "Archive did not contain a .kml or .shp file. Supported formats are KMZ, KML, and zipped ArcGIS shapefiles."
26
+ raise ImportError, INVALID_ARCHIVE
11
27
  end
12
28
 
13
- case ::File.extname(file.path.downcase)
29
+ case ::File.extname(current_file.path.downcase)
14
30
  when '.kml'
15
- __setobj__(KMLFile.new(file, *args))
31
+ __setobj__(KMLFile.new(current_file, *args, **options))
16
32
  when '.shp'
17
- __setobj__(Shapefile.new(file, *args))
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 = ActiveRecord::Base.connection.select_value("SELECT ST_GeomFromKML(#{ActiveRecord::Base.connection.quote(kml.to_s)})")
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, *args, **options)
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 ||= begin
55
- validate_file!
56
- Download.open(archive, unzip: /\.shp$/, downcase: true)
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 validate_file!
61
- return unless Unzip.is_zip?(archive)
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.detect {|path| find.any? {|pattern| path.index(pattern) } }
10
- raise(PathNotFound, "Archive did not contain a file matching #{find}") unless paths.present?
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
- # 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."
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
- obj[ext] = File.basename(f.name, '.*')
33
+ true
22
34
  end
23
35
 
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)}"
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
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "2.17.2"
2
+ VERSION = "2.17.3"
3
3
  end
@@ -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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.17.2
4
+ version: 2.17.3
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: 2021-09-06 00:00:00.000000000 Z
12
+ date: 2021-10-04 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