spatial_features 2.17.1 → 2.19.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b61f238cf2d0fa0dd936d9258c617708ea7e4e4ec4be41257b0a3869cde54d4d
4
- data.tar.gz: 5ea8025d332dd1fedeab4a90c878568567710d139eba76547cf695e0bbedfd66
3
+ metadata.gz: a0d49ba8050fb39cb929127a633f92bddc901d7851e5e02ac7459db8edbfe790
4
+ data.tar.gz: 3f0c2d8ed22051efe5e11f0735c5430d8f9a0d11c94550b2719be338531e0f1e
5
5
  SHA512:
6
- metadata.gz: 8089ccc6e35bf54547f72d11332d3112388ba86ba2dd4828080f7be12a075ab3f33c3d181d79dd7c4403bfd7d9c2e8d6ff7842d57dcfc6ad2a775361d1ce5844
7
- data.tar.gz: 6ae887ec028239d8d0431ecdbf2006f0f5b7a8ecf04e3c1b45adadd5a080a44b2b447787e178614610f1627a5cfe244cfee2fabd9670fa617deb516a87813512
6
+ metadata.gz: c6d53a0c2c448b51f9344e39be1e271f8a084ccee0e1d3aeefd0252c3d9dfb2b7afcb8ab0b6822e1485a44aecdf79f62615f59f6f169073c74fc61ce70107d32
7
+ data.tar.gz: 7dc96d943a8a8695d05ad246c4ffaaa166ff4be40fe10745a671ac4ae89b72ecbae810241fd1dde605d72c57989695d7cbb07a55575799dd8b65eb32d8819d61
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
 
@@ -49,14 +49,13 @@ class AbstractFeature < ActiveRecord::Base
49
49
  where(:feature_type => 'point')
50
50
  end
51
51
 
52
- def self.within_distance(lat, lng, distance_in_meters)
53
- # 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)
54
53
  where("ST_DWithin(features.geog, ST_Point(:lng, :lat), :distance)", :lat => lat, :lng => lng, :distance => distance_in_meters)
55
54
  end
56
55
 
57
56
  def self.area_in_square_meters(geom = 'geom_lowres')
58
57
  current_scope = all.polygons
59
- unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
58
+ unscoped { SpatialFeatures::Utils.select_db_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
60
59
  end
61
60
 
62
61
  def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
@@ -66,13 +65,17 @@ class AbstractFeature < ActiveRecord::Base
66
65
  query = base_class.unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
67
66
  .from(scope, "features")
68
67
  .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
68
+ return SpatialFeatures::Utils.select_db_value(query).to_f
70
69
  end
71
70
 
72
71
  def self.intersecting(other)
73
72
  join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
74
73
  end
75
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
+
76
79
  def self.invalid(column = 'geog::geometry')
77
80
  select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
78
81
  end
@@ -147,7 +150,7 @@ class AbstractFeature < ActiveRecord::Base
147
150
  )
148
151
  )
149
152
  SQL
150
- connection.select_value(all.select(sql))
153
+ SpatialFeatures::Utils.select_db_value(all.select(sql))
151
154
  end
152
155
 
153
156
  def feature_bounds
@@ -175,17 +178,17 @@ class AbstractFeature < ActiveRecord::Base
175
178
  private
176
179
 
177
180
  def make_valid
178
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
181
+ self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Buffer('#{sanitize}', 0)")
179
182
  end
180
183
 
181
184
  # Use ST_Force2D to discard z-coordinates that cause failures later in the process
182
185
  def sanitize
183
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
186
+ self.geog = SpatialFeatures::Utils.select_db_value("SELECT ST_Force2D('#{geog}')")
184
187
  end
185
188
 
186
189
  SRID_CACHE = {}
187
190
  def self.detect_srid(column_name)
188
- SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
191
+ SRID_CACHE[column_name] ||= SpatialFeatures::Utils.select_db_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
189
192
  end
190
193
 
191
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 = 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
@@ -11,7 +11,22 @@ module SpatialFeatures
11
11
  end
12
12
 
13
13
  def updating_features?
14
- running_feature_update_jobs.exists?
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('queue LIKE ?', "#{spatial_processing_queue_name}#{suffix}%")
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
- delay(:queue => spatial_processing_queue_name + method_name).send(method_name, *args)
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
- "#{self.class}/#{self.id}/"
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.feature_bounds
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&.area
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
- 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.1"
2
+ VERSION = "2.19.0"
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,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spatial_features
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.17.1
4
+ version: 2.19.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-09-05 00:00:00.000000000 Z
12
+ date: 2021-11-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -87,20 +87,6 @@ dependencies:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
89
  version: '1.6'
90
- - !ruby/object:Gem::Dependency
91
- name: chroma
92
- requirement: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: 0.1.0
97
- type: :runtime
98
- prerelease: false
99
- version_requirements: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: 0.1.0
104
90
  - !ruby/object:Gem::Dependency
105
91
  name: pg
106
92
  requirement: !ruby/object:Gem::Requirement
@@ -144,7 +130,6 @@ files:
144
130
  - app/models/feature.rb
145
131
  - app/models/spatial_cache.rb
146
132
  - app/models/spatial_proximity.rb
147
- - config/initializers/chroma_serializers.rb
148
133
  - config/initializers/mime_types.rb
149
134
  - config/initializers/register_oids.rb
150
135
  - lib/spatial_features.rb
@@ -157,6 +142,7 @@ files:
157
142
  - lib/spatial_features/has_spatial_features/queued_spatial_processing.rb
158
143
  - lib/spatial_features/importers/base.rb
159
144
  - lib/spatial_features/importers/file.rb
145
+ - lib/spatial_features/importers/geojson.rb
160
146
  - lib/spatial_features/importers/geomark.rb
161
147
  - lib/spatial_features/importers/json_arcgis.rb
162
148
  - lib/spatial_features/importers/kml.rb
@@ -174,7 +160,7 @@ homepage: https://github.com/culturecode/spatial_features
174
160
  licenses:
175
161
  - MIT
176
162
  metadata: {}
177
- post_install_message:
163
+ post_install_message:
178
164
  rdoc_options: []
179
165
  require_paths:
180
166
  - lib
@@ -189,8 +175,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
189
175
  - !ruby/object:Gem::Version
190
176
  version: '0'
191
177
  requirements: []
192
- rubygems_version: 3.0.8
193
- signing_key:
178
+ rubygems_version: 3.0.3.1
179
+ signing_key:
194
180
  specification_version: 4
195
181
  summary: Adds spatial methods to a model.
196
182
  test_files: []
@@ -1,15 +0,0 @@
1
- module Chroma
2
- class Color
3
- module Serializers
4
- # Google's Fusion Table colouring expects the alpha value in the last position, not the first
5
- def to_ft_hex
6
- [
7
- to_2char_hex(@rgb.r),
8
- to_2char_hex(@rgb.g),
9
- to_2char_hex(@rgb.b),
10
- to_2char_hex(alpha * 255)
11
- ].join('')
12
- end
13
- end
14
- end
15
- end