spatial_features 2.17.2 → 2.17.3

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 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