spatial_features 2.12.2 → 2.15.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: 0051ead788fe05361e242dd7043efe1dee46c31260f4b3fdc782c06af5238e73
4
- data.tar.gz: f9658375cee39987dd047839222781eb12b35eab73eb950e62c4349e47955dbf
3
+ metadata.gz: 79fb559903943c11893b5d338f5c8030971f23051cf3611a326d0e013720f5e5
4
+ data.tar.gz: 1311241026ec5dc76a90e04f8fcc9e93c92965875f0967e1b27a0e1f64694ab3
5
5
  SHA512:
6
- metadata.gz: 2738a619531511cf2d90052a6ffdab481b5edfc875271cae30df287759ccdc09dcb1df8291c63192e5e618291410befd2d5cfcd3a3155f852a5d59c44b5b6aff
7
- data.tar.gz: 114543074b9bdfea813e979505829d6282ec49524fe9ad4093b1119e7099feec2205f8b5a33b447ca8cac05470ae74b12ee93b9a7a3c6f7e66d9497ce43fa2ca
6
+ metadata.gz: 8080e32e08c262fb3c44c6a228b0c599a1030ec52f9daea7862a06a98aa72b46dad77fb73127d5d40e6e999549ea44ebde0b77e826b1776e92eefe6b4a14898c
7
+ data.tar.gz: 3d591754fdc5a1ddb92146d805074aa5dc4bc65f5261eb0c169546f886b5dee784de7f24f56829a359641ae778960ffd54923c6b2535261d2e1194e0c9db0859
@@ -1,6 +1,9 @@
1
1
  class AbstractFeature < ActiveRecord::Base
2
2
  self.table_name = 'features'
3
3
 
4
+ class_attribute :automatically_cache_derivatives
5
+ self.automatically_cache_derivatives = true
6
+
4
7
  class_attribute :lowres_simplification
5
8
  self.lowres_simplification = 2 # Threshold in meters
6
9
 
@@ -12,15 +15,21 @@ class AbstractFeature < ActiveRecord::Base
12
15
 
13
16
  before_validation :sanitize_feature_type
14
17
  validates_presence_of :geog
15
- validate :validate_geometry
16
- before_save :sanitize
17
- after_save :cache_derivatives, :if => :saved_change_to_geog?
18
+ validate :validate_geometry, if: :will_save_change_to_geog?
19
+ before_save :sanitize, if: :will_save_change_to_geog?
20
+ after_save :cache_derivatives, :if => [:automatically_cache_derivatives?, :saved_change_to_geog?]
18
21
 
19
22
  def self.cache_key
20
23
  result = connection.select_one(all.select('max(id) AS max, count(*) AS count').to_sql)
21
24
  "#{result['max']}-#{result['count']}"
22
25
  end
23
26
 
27
+ # for Rails >= 5 ActiveRecord collections we override the collection_cache_key
28
+ # to prevent Rails doing its default query on `updated_at`
29
+ def self.collection_cache_key(_collection, _timestamp_column)
30
+ self.cache_key
31
+ end
32
+
24
33
  def self.with_metadata(k, v)
25
34
  if k.present? && v.present?
26
35
  where('metadata->? = ?', k, v)
@@ -82,6 +91,14 @@ class AbstractFeature < ActiveRecord::Base
82
91
  return envelope_json.values_at(0,2)
83
92
  end
84
93
 
94
+ def self.without_caching_derivatives(&block)
95
+ old = automatically_cache_derivatives
96
+ self.automatically_cache_derivatives = false
97
+ block.call
98
+ ensure
99
+ self.automatically_cache_derivatives = old
100
+ end
101
+
85
102
  def self.cache_derivatives(options = {})
86
103
  update_all <<-SQL.squish
87
104
  geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
@@ -132,7 +149,7 @@ class AbstractFeature < ActiveRecord::Base
132
149
  end
133
150
 
134
151
  def cache_derivatives(*args)
135
- self.class.where(:id => self.id).cache_derivatives(*args)
152
+ self.class.default_scoped.where(:id => self.id).cache_derivatives(*args)
136
153
  end
137
154
 
138
155
  def kml(options = {})
@@ -149,10 +166,6 @@ class AbstractFeature < ActiveRecord::Base
149
166
  @make_valid
150
167
  end
151
168
 
152
- def wkt
153
- ActiveRecord::Base.connection.select_value("SELECT ST_ASEWKT('#{geom}')")
154
- end
155
-
156
169
  private
157
170
 
158
171
  def make_valid
@@ -11,6 +11,16 @@ class Feature < AbstractFeature
11
11
 
12
12
  after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
13
13
 
14
+ # Features are used for display so we also cache their KML representation
15
+ def self.cache_derivatives(options = {})
16
+ super
17
+ update_all <<-SQL.squish
18
+ kml = ST_AsKML(geog, 6),
19
+ kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
20
+ kml_centroid = ST_AsKML(centroid)
21
+ SQL
22
+ end
23
+
14
24
  def self.defer_aggregate_refresh(&block)
15
25
  start_at = Feature.maximum(:id).to_i + 1
16
26
  output = without_aggregate_refresh(&block)
@@ -30,9 +40,10 @@ class Feature < AbstractFeature
30
40
 
31
41
  def self.refresh_aggregates
32
42
  # Find one feature from each spatial model and trigger the aggregate feature refresh
33
- ids = select('MAX(id)')
34
- .where.not(:spatial_model_type => nil, :spatial_model_id => nil)
43
+ ids = where.not(:spatial_model_type => nil)
44
+ .where.not(:spatial_model_id => nil)
35
45
  .group('spatial_model_type, spatial_model_id')
46
+ .pluck('MAX(id)')
36
47
 
37
48
  # Unscope so that newly built AggregateFeatures get their type column set correctly
38
49
  AbstractFeature.unscoped { where(:id => ids).find_each(&:refresh_aggregate) }
@@ -49,14 +60,4 @@ class Feature < AbstractFeature
49
60
  # this field blank.
50
61
  spatial_model_id? && automatically_refresh_aggregate && saved_change_to_geog?
51
62
  end
52
-
53
- # Features are used for display so we also cache their KML representation
54
- def self.cache_derivatives(options = {})
55
- super
56
- update_all <<-SQL.squish
57
- kml = ST_AsKML(geog, 6),
58
- kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
59
- kml_centroid = ST_AsKML(centroid)
60
- SQL
61
- end
62
63
  end
@@ -3,9 +3,6 @@ require 'delayed_job_active_record'
3
3
  require 'rgeo/shapefile'
4
4
  require 'nokogiri'
5
5
  require 'zip'
6
- require 'googleauth'
7
- require 'google/apis/fusiontables_v2'
8
- require 'google/apis/drive_v3'
9
6
 
10
7
  # LIB
11
8
  require 'spatial_features/caching'
@@ -15,16 +12,12 @@ require 'spatial_features/controller_helpers/spatial_extensions'
15
12
  require 'spatial_features/download'
16
13
  require 'spatial_features/unzip'
17
14
  require 'spatial_features/utils'
15
+ require 'spatial_features/validation'
18
16
 
19
17
  require 'spatial_features/has_spatial_features'
20
18
  require 'spatial_features/has_spatial_features/queued_spatial_processing'
21
19
  require 'spatial_features/has_spatial_features/feature_import'
22
20
 
23
- require 'spatial_features/has_fusion_table_features'
24
- require 'spatial_features/has_fusion_table_features/api'
25
- require 'spatial_features/has_fusion_table_features/configuration'
26
- require 'spatial_features/has_fusion_table_features/service'
27
-
28
21
  require 'spatial_features/importers/base'
29
22
  require 'spatial_features/importers/file'
30
23
  require 'spatial_features/importers/kml'
@@ -38,7 +31,6 @@ require 'spatial_features/engine'
38
31
 
39
32
  # Load the act method
40
33
  ActiveRecord::Base.send :extend, SpatialFeatures::ActMethod
41
- ActiveRecord::Base.send :extend, SpatialFeatures::FusionTables::ActMethod
42
34
 
43
35
  # Suppress date warnings when unzipping KMZ saved by Google Earth, see https://github.com/rubyzip/rubyzip/issues/112
44
36
  Zip.warn_invalid_date = false
@@ -6,8 +6,6 @@ module SpatialExtensions
6
6
  model.failed_feature_update_jobs.destroy_all
7
7
  model.delay_update_features!
8
8
  end
9
-
10
- redirect_to :back
11
9
  end
12
10
 
13
11
  def abstract_proximity_action(scope, target, distance, &block)
@@ -12,7 +12,7 @@ module SpatialFeatures
12
12
  def self.open(file, unzip: nil, **unzip_options)
13
13
  file = Kernel.open(file)
14
14
  file = normalize_file(file) if file.is_a?(StringIO)
15
- if Unzip.is_zip?(file)
15
+ if unzip && Unzip.is_zip?(file)
16
16
  file = find_in_zip(file, find: unzip, **unzip_options)
17
17
  end
18
18
  return file
@@ -26,6 +26,12 @@ module SpatialFeatures
26
26
  end
27
27
  end
28
28
 
29
+ def self.entries(file)
30
+ file = Kernel.open(file)
31
+ file = normalize_file(file) if file.is_a?(StringIO)
32
+ Unzip.entries(file)
33
+ end
34
+
29
35
  def self.find_in_zip(file, find:, **unzip_options)
30
36
  return File.open(Unzip.paths(file, :find => find, **unzip_options))
31
37
  end
@@ -88,16 +88,16 @@ module SpatialFeatures
88
88
 
89
89
  # Returns true if the model stores a hash of the features so we don't need to process the features if they haven't changed
90
90
  def has_spatial_features_hash?
91
- column_names.include? 'features_hash'
91
+ owner_class_has_loaded_column?('features_hash')
92
92
  end
93
93
 
94
94
  # Returns true if the model stores a cache of the features area
95
95
  def has_features_area?
96
- column_names.include? 'features_area'
96
+ owner_class_has_loaded_column?('features_area')
97
97
  end
98
98
 
99
99
  def area_in_square_meters
100
- features.area_in_square_meters
100
+ features.area
101
101
  end
102
102
 
103
103
  private
@@ -160,6 +160,12 @@ module SpatialFeatures
160
160
  scope = scope.where(:spatial_model_id => other) unless Utils.class_of(other) == other
161
161
  return scope
162
162
  end
163
+
164
+ def owner_class_has_loaded_column?(column_name)
165
+ return false unless connected?
166
+ return false unless table_exists?
167
+ column_names.include? column_name
168
+ end
163
169
  end
164
170
 
165
171
  module InstanceMethods
@@ -184,7 +190,11 @@ module SpatialFeatures
184
190
  end
185
191
 
186
192
  def features?
187
- features.present?
193
+ if features.loaded?
194
+ features.present?
195
+ else
196
+ features.exists?
197
+ end
188
198
  end
189
199
 
190
200
  def intersects?(other)
@@ -198,7 +208,7 @@ module SpatialFeatures
198
208
  end
199
209
 
200
210
  def features_area_in_square_meters
201
- @features_area_in_square_meters ||= features.area
211
+ aggregate_feature&.area
202
212
  end
203
213
 
204
214
  def total_intersection_area_in_square_meters(other)
@@ -79,14 +79,24 @@ module SpatialFeatures
79
79
  end
80
80
 
81
81
  def import_features(imports, skip_invalid)
82
- self.features.delete_all
82
+ features.delete_all
83
83
  valid, invalid = Feature.defer_aggregate_refresh do
84
- imports.flat_map(&:features).partition do |feature|
85
- feature.spatial_model = self
86
- feature.save
84
+ Feature.without_caching_derivatives do
85
+ imports.flat_map(&:features).partition do |feature|
86
+ feature.spatial_model = self
87
+ feature.save
88
+ end
87
89
  end
88
90
  end
89
91
 
92
+ if persisted?
93
+ features.reset # Reset the association cache because we've updated the features
94
+ features.cache_derivatives
95
+ else
96
+ self.features = valid # Assign the features so when we save this record we update the foreign key on the features
97
+ Feature.where(id: features).cache_derivatives
98
+ end
99
+
90
100
  errors = imports.flat_map(&:errors)
91
101
  invalid.each do |feature|
92
102
  errors << "Feature #{feature.name}: #{feature.errors.full_messages.to_sentence}"
@@ -98,7 +108,7 @@ module SpatialFeatures
98
108
  raise ImportError, "Error updating #{self.class} #{self.id}. #{errors.to_sentence}"
99
109
  end
100
110
 
101
- self.features = valid
111
+ return features
102
112
  end
103
113
 
104
114
  def features_cache_key_matches?(cache_key)
@@ -14,10 +14,14 @@ module SpatialFeatures
14
14
  else
15
15
  metadata = {}
16
16
  end
17
-
17
+
18
18
  next if blank_feature?(feature)
19
19
 
20
- yield OpenStruct.new(:feature_type => sql_type, :geog => geom_from_kml(feature), :name => name, :metadata => metadata)
20
+ geog = geom_from_kml(feature)
21
+
22
+ next if geog.blank?
23
+
24
+ yield OpenStruct.new(:feature_type => sql_type, :geog => geog, :name => name, :metadata => metadata)
21
25
  end
22
26
  end
23
27
  end
@@ -27,7 +31,16 @@ module SpatialFeatures
27
31
  end
28
32
 
29
33
  def geom_from_kml(kml)
30
- ActiveRecord::Base.connection.select_value("SELECT ST_GeomFromKML(#{ActiveRecord::Base.connection.quote(kml.to_s)})")
34
+ geom = nil
35
+
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
+ Thread.new do
38
+ geom = ActiveRecord::Base.connection.select_value("SELECT ST_GeomFromKML(#{ActiveRecord::Base.connection.quote(kml.to_s)})")
39
+ rescue ActiveRecord::StatementInvalid => e # Discard Invalid KML features
40
+ geom = nil
41
+ end.join
42
+
43
+ return geom
31
44
  end
32
45
 
33
46
  def extract_metadata(placemark)
@@ -12,7 +12,7 @@ module SpatialFeatures
12
12
  end
13
13
 
14
14
  def cache_key
15
- @cache_key ||= Digest::MD5.hexdigest(features.to_json)
15
+ @cache_key ||= Digest::MD5.file(archive).to_s
16
16
  end
17
17
 
18
18
  private
@@ -49,13 +49,27 @@ module SpatialFeatures
49
49
  SQL
50
50
  end
51
51
 
52
+
52
53
  def file
53
- @file ||= Download.open(@data, unzip: /\.shp$/, downcase: true)
54
+ @file ||= begin
55
+ validate_file!
56
+ Download.open(archive, unzip: /\.shp$/, downcase: true)
57
+ end
58
+ end
59
+
60
+ def validate_file!
61
+ return unless Unzip.is_zip?(archive)
62
+ Validation.validate_shapefile_archive!(Download.entries(archive), default_proj4_projection: default_proj4_projection)
63
+ end
64
+
65
+ def archive
66
+ @archive ||= Download.open(@data)
54
67
  end
55
68
  end
56
69
 
57
70
  # ERRORS
58
71
  class IndeterminateShapefileProjection < SpatialFeatures::ImportError; end
59
72
  class IncompleteShapefileArchive < SpatialFeatures::ImportError; end
73
+ class InvalidShapefileArchive < SpatialFeatures::ImportError; end
60
74
  end
61
75
  end
@@ -0,0 +1,55 @@
1
+ module SpatialFeatures
2
+ module Validation
3
+ # SHP file must come first
4
+ REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS = %w[shp shx dbf prj].freeze
5
+
6
+ # Check if a shapefile archive includes the required component files, otherwise
7
+ # raise an exception.
8
+ #
9
+ # @param [Zip::File] zip_file A Zip::File object
10
+ # @param [String] default_proj4_projection Optional, if supplied we don't raise an exception when we're missing a .PRJ file
11
+ # @param [Boolean] allow_generic_zip_files When true, we skip validation entirely if the archive does not contain a .SHP file
12
+ def self.validate_shapefile_archive!(zip_file, default_proj4_projection: nil, allow_generic_zip_files: false)
13
+ zip_file_entries = zip_file.entries.each_with_object({}) do |f, obj|
14
+ ext = File.extname(f.name).downcase[1..-1]
15
+ next unless ext
16
+
17
+ if ext.casecmp?("shp") && obj.key?(ext)
18
+ raise ::SpatialFeatures::Importers::InvalidShapefileArchive, "Zip files that contain multiple Shapefiles are not supported. Please separate each Shapefile into its own zip file."
19
+ end
20
+
21
+ obj[ext] = File.basename(f.name, '.*')
22
+ end
23
+
24
+ shapefile_basename = zip_file_entries["shp"]
25
+ unless shapefile_basename
26
+ # not a shapefile archive but we don't care
27
+ return if allow_generic_zip_files
28
+
29
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a SHP file"
30
+ end
31
+
32
+ REQUIRED_SHAPEFILE_COMPONENT_EXTENSIONS[1..-1].each do |ext|
33
+ ext_basename = zip_file_entries[ext]
34
+ next if ext_basename&.casecmp?(shapefile_basename)
35
+
36
+ case ext
37
+ when "prj"
38
+ # special case for missing projection files to allow using default_proj4_projection
39
+ next if default_proj4_projection
40
+
41
+ raise ::SpatialFeatures::Importers::IndeterminateShapefileProjection, "Shapefile archive is missing a projection file: #{expected_component_path(shapefile_basename, ext)}"
42
+ else
43
+ # for all un-handled cases of missing files raise the more generic error
44
+ raise ::SpatialFeatures::Importers::IncompleteShapefileArchive, "Shapefile archive is missing a required file: #{expected_component_path(shapefile_basename, ext)}"
45
+ end
46
+ end
47
+
48
+ true
49
+ end
50
+
51
+ def self.expected_component_path(basename, ext)
52
+ "#{basename}.#{ext}"
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "2.12.2"
2
+ VERSION = "2.15.0"
3
3
  end
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.12.2
4
+ version: 2.15.0
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: 2020-08-23 00:00:00.000000000 Z
12
+ date: 2021-07-06 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -20,7 +20,7 @@ dependencies:
20
20
  version: '4.2'
21
21
  - - "<"
22
22
  - !ruby/object:Gem::Version
23
- version: '6.0'
23
+ version: '7.0'
24
24
  type: :runtime
25
25
  prerelease: false
26
26
  version_requirements: !ruby/object:Gem::Requirement
@@ -30,7 +30,7 @@ dependencies:
30
30
  version: '4.2'
31
31
  - - "<"
32
32
  - !ruby/object:Gem::Version
33
- version: '6.0'
33
+ version: '7.0'
34
34
  - !ruby/object:Gem::Dependency
35
35
  name: delayed_job_active_record
36
36
  requirement: !ruby/object:Gem::Requirement
@@ -51,28 +51,28 @@ dependencies:
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '2.0'
54
+ version: '3.0'
55
55
  type: :runtime
56
56
  prerelease: false
57
57
  version_requirements: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '2.0'
61
+ version: '3.0'
62
62
  - !ruby/object:Gem::Dependency
63
63
  name: rubyzip
64
64
  requirement: !ruby/object:Gem::Requirement
65
65
  requirements:
66
- - - "~>"
66
+ - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '1.1'
68
+ version: 1.0.0
69
69
  type: :runtime
70
70
  prerelease: false
71
71
  version_requirements: !ruby/object:Gem::Requirement
72
72
  requirements:
73
- - - "~>"
73
+ - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '1.1'
75
+ version: 1.0.0
76
76
  - !ruby/object:Gem::Dependency
77
77
  name: nokogiri
78
78
  requirement: !ruby/object:Gem::Requirement
@@ -87,34 +87,6 @@ dependencies:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
89
  version: '1.6'
90
- - !ruby/object:Gem::Dependency
91
- name: googleauth
92
- requirement: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - "~>"
95
- - !ruby/object:Gem::Version
96
- version: 0.5.1
97
- type: :runtime
98
- prerelease: false
99
- version_requirements: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - "~>"
102
- - !ruby/object:Gem::Version
103
- version: 0.5.1
104
- - !ruby/object:Gem::Dependency
105
- name: google-api-client
106
- requirement: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - "~>"
109
- - !ruby/object:Gem::Version
110
- version: '0.9'
111
- type: :runtime
112
- prerelease: false
113
- version_requirements: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - "~>"
116
- - !ruby/object:Gem::Version
117
- version: '0.9'
118
90
  - !ruby/object:Gem::Dependency
119
91
  name: chroma
120
92
  requirement: !ruby/object:Gem::Requirement
@@ -135,14 +107,14 @@ dependencies:
135
107
  requirements:
136
108
  - - "~>"
137
109
  - !ruby/object:Gem::Version
138
- version: '0'
110
+ version: '1'
139
111
  type: :development
140
112
  prerelease: false
141
113
  version_requirements: !ruby/object:Gem::Requirement
142
114
  requirements:
143
115
  - - "~>"
144
116
  - !ruby/object:Gem::Version
145
- version: '0'
117
+ version: '1'
146
118
  - !ruby/object:Gem::Dependency
147
119
  name: rspec
148
120
  requirement: !ruby/object:Gem::Requirement
@@ -180,10 +152,6 @@ files:
180
152
  - lib/spatial_features/controller_helpers/spatial_extensions.rb
181
153
  - lib/spatial_features/download.rb
182
154
  - lib/spatial_features/engine.rb
183
- - lib/spatial_features/has_fusion_table_features.rb
184
- - lib/spatial_features/has_fusion_table_features/api.rb
185
- - lib/spatial_features/has_fusion_table_features/configuration.rb
186
- - lib/spatial_features/has_fusion_table_features/service.rb
187
155
  - lib/spatial_features/has_spatial_features.rb
188
156
  - lib/spatial_features/has_spatial_features/feature_import.rb
189
157
  - lib/spatial_features/has_spatial_features/queued_spatial_processing.rb
@@ -198,6 +166,7 @@ files:
198
166
  - lib/spatial_features/uncached_result.rb
199
167
  - lib/spatial_features/unzip.rb
200
168
  - lib/spatial_features/utils.rb
169
+ - lib/spatial_features/validation.rb
201
170
  - lib/spatial_features/venn_polygons.rb
202
171
  - lib/spatial_features/version.rb
203
172
  - lib/tasks/spatial_features_tasks.rake
@@ -1,95 +0,0 @@
1
- module SpatialFeatures
2
- module FusionTables
3
- module ActMethod
4
- def has_fusion_table_features(options = {})
5
- class_attribute :fusion_table_features_options
6
- self.fusion_table_features_options = options
7
-
8
- after_update_features :expire_fusion_table
9
-
10
- include InstanceMethods
11
- extend ClassMethods
12
- end
13
- end
14
-
15
- module ClassMethods
16
- def to_fusion_condition
17
- sanitize_sql(["spatial_model_id IN (?)", pluck(:id)])
18
- end
19
-
20
- def update_fusion_tables(group_options = {})
21
- fusion_table_groups(group_options) do |fusion_table_id, records, group_features|
22
- API.set_features(fusion_table_id, group_features, :colour => fusion_table_features_options[:colour])
23
- end
24
- end
25
-
26
- def delete_fusion_tables(group_options = {})
27
- fusion_table_groups(group_options) do |fusion_table_id, records, group_features|
28
- API.delete_table(fusion_table_id)
29
- end
30
- @fusion_table_id_cache = nil
31
- end
32
-
33
- def acts_like_fusion_table_features?
34
- true
35
- end
36
-
37
- def fusion_table_id_cache
38
- @fusion_table_id_cache ||= Hash.new {|hash, table_name| hash[table_name] = API.find_or_create_table(table_name) }
39
- .merge(API.tables.collect {|table| [table.name, table.table_id] }.to_h) # Warm the cache
40
- end
41
-
42
- private
43
-
44
- def fusion_table_groups(only: [], except: [])
45
- groups = all.group_by(&:fusion_table_id)
46
- groups.select! {|fusion_table_id, _| Array.wrap(only).include?(fusion_table_id) } if only.present?
47
- groups.select! {|fusion_table_id, _| !Array.wrap(except).include?(fusion_table_id) } if except.present?
48
- groups.each do |fusion_table_id, records|
49
- yield fusion_table_id, records, features.where(:spatial_model_id => records)
50
- end
51
- end
52
- end
53
-
54
- module InstanceMethods
55
- def acts_like_fusion_table_features?
56
- true
57
- end
58
-
59
- def stale_fusion_table?
60
- @stale_fusion_table
61
- end
62
-
63
- def expire_fusion_table
64
- @stale_fusion_table = true
65
- end
66
-
67
- def update_fusion_table
68
- self.class.update_fusion_tables(only: fusion_table_id)
69
- end
70
-
71
- def delete_fusion_table
72
- self.class.delete_fusion_tables(only: fusion_table_id)
73
- end
74
-
75
- def fusion_table_id
76
- self.class.fusion_table_id_cache[fusion_table_name]
77
- end
78
-
79
- def fusion_table_name
80
- case fusion_table_features_options[:table_name]
81
- when Symbol
82
- send(fusion_table_features_options[:table_name])
83
- when String
84
- fusion_table_features_options[:table_name]
85
- else
86
- self.class.table_name
87
- end
88
- end
89
-
90
- def to_fusion_condition
91
- self.class.where(:id => self).to_fusion_condition
92
- end
93
- end
94
- end
95
- end
@@ -1,111 +0,0 @@
1
- module SpatialFeatures
2
- module FusionTables
3
- module API
4
- extend self
5
-
6
- FEATURE_COLUMNS = {
7
- :name => 'STRING',
8
- :spatial_model_type => 'STRING',
9
- :spatial_model_id => 'NUMBER',
10
- :kml_lowres => 'LOCATION',
11
- :colour => 'STRING',
12
- :metadata => 'STRING'
13
- }
14
- TABLE_STYLE = {
15
- :polygon_options => { :fill_color_styler => { :kind => 'fusiontables#fromColumn', :column_name => 'colour' },
16
- :stroke_color_styler => { :kind => 'fusiontables#fromColumn', :column_name => 'colour' },
17
- :stroke_weight => 1
18
- },
19
- :polyline_options => { :stroke_color_styler => { :kind => 'fusiontables#fromColumn', :column_name => 'colour'} }
20
- }
21
-
22
- TABLE_TEMPLATE = {
23
- :body => "<h3>{name}</h3>{metadata}"
24
- }
25
-
26
- def find_or_create_table(name)
27
- find_table(name) || create_table(name)
28
- end
29
-
30
- def create_table(name)
31
- table_id = service.create_table(name, FEATURE_COLUMNS.collect {|name, type| {:name => name, :type => type} })
32
- service.share_table(table_id)
33
- service.insert_style(table_id, TABLE_STYLE)
34
- service.insert_template(table_id, TABLE_TEMPLATE)
35
- return table_id
36
- end
37
-
38
- def find_table(name)
39
- service.tables.find {|table| table.name == name }.try(:table_id)
40
- end
41
-
42
- def delete_table(table_id)
43
- service.delete_table(table_id)
44
- end
45
-
46
- def tables
47
- service.tables
48
- end
49
-
50
- def set_features(table_id, features, colour: nil)
51
- service.replace_rows(table_id, features_to_csv(features, colour))
52
- end
53
-
54
- def set_style(table_id, style)
55
- service.style_ids(table_id).each do |style_id|
56
- service.delete_style(table_id, style_id)
57
- end
58
- service.insert_style(table_id, style)
59
- end
60
-
61
- def service
62
- @service ||= Service.new(Configuration.service_account_credentials)
63
- end
64
-
65
- private
66
-
67
- def features_to_csv(features, colour)
68
- ActiveRecord::Associations::Preloader.new.preload(features, :spatial_model) if colour.is_a?(Symbol)
69
-
70
- csv = CSV.generate do |csv|
71
- features.each do |feature|
72
- csv << FEATURE_COLUMNS.keys.collect do |attribute|
73
- case attribute
74
- when :colour
75
- render_feature_colour(feature, colour)
76
- when :metadata
77
- render_feature_metadata(feature)
78
- else
79
- feature.send(attribute)
80
- end
81
- end
82
- end
83
- end
84
-
85
- file = Tempfile.new('features')
86
- file.write(csv)
87
- return file
88
- end
89
-
90
- def render_feature_metadata(feature)
91
- feature.metadata.collect do |name, val|
92
- "<b>#{name}:</b> #{val}"
93
- end.join('<br/>')
94
- end
95
-
96
- def render_feature_colour(feature, colour)
97
- case colour
98
- when Symbol
99
- feature.spatial_model.send(colour)
100
- when Proc
101
- colour.call(feature)
102
- else
103
- colour
104
- end.paint.to_ft_hex
105
-
106
- rescue Chroma::Errors::UnrecognizedColor
107
- nil
108
- end
109
- end
110
- end
111
- end
@@ -1,15 +0,0 @@
1
- module SpatialFeatures
2
- module FusionTables
3
- def self.config
4
- if block_given?
5
- yield Configuration
6
- else
7
- Configuration
8
- end
9
- end
10
-
11
- module Configuration
12
- mattr_accessor :service_account_credentials
13
- end
14
- end
15
- end
@@ -1,104 +0,0 @@
1
- module SpatialFeatures
2
- module FusionTables
3
- class Service
4
- APPLICATION_NAME = 'Fusion Tables + Spatial Features'
5
- GOOGLE_AUTH_SCOPES = %w(https://www.googleapis.com/auth/fusiontables https://www.googleapis.com/auth/drive)
6
-
7
- def initialize(service_account_credentials_path)
8
- @authorization = get_authorization(service_account_credentials_path, GOOGLE_AUTH_SCOPES)
9
- end
10
-
11
- def table_ids
12
- tables.collect(&:table_id)
13
- end
14
-
15
- def tables
16
- fusion_tables_service.list_tables(max_results: 10000).items || []
17
- end
18
-
19
- def create_table(name, columns = [], table_options = {})
20
- table_object = {:name => name, :columns => columns, :is_exportable => true}.merge(table_options)
21
- fusion_tables_service.insert_table(table_object, :fields => 'table_id').table_id
22
- end
23
-
24
- def delete_table(table_id)
25
- fusion_tables_service.delete_table(table_id)
26
- end
27
-
28
- def style_ids(table_id)
29
- styles(table_id).collect(&:style_id)
30
- end
31
-
32
- def styles(table_id)
33
- fusion_tables_service.list_styles(table_id).items
34
- end
35
-
36
- def delete_style(table_id, style_id)
37
- fusion_tables_service.delete_style(table_id, style_id, :fields => nil)
38
- end
39
-
40
- def insert_style(table_id, style)
41
- style.reverse_merge! 'name' => 'default_table_style', 'isDefaultForTable' => true
42
- fusion_tables_service.insert_style(table_id, style, :fields => 'styleId')
43
- end
44
-
45
- def delete_template(table_id, template_id)
46
- fusion_tables_service.delete_template(table_id, template_id, :fields => nil)
47
- end
48
-
49
- def insert_template(table_id, template)
50
- template.reverse_merge! 'name' => 'default_table_template'
51
- fusion_tables_service.insert_template(table_id, template, :fields => 'templateId')
52
- end
53
-
54
- def delete_row(table_id, row_id)
55
- fusion_tables_service.sql_query("DELETE FROM #{table_id} WHERE ROWID = #{row_id}")
56
- end
57
-
58
- def row_ids(table_id, conditions = {})
59
- clause = conditions.collect {|column, value| ActiveRecord::Base.send(:sanitize_sql_array, ["? IN (?)", column, value]) }.join(' AND ')
60
- where = "WHERE #{clause}" if clause.present?
61
- return fusion_tables_service.sql_query_get("SELECT rowid FROM #{table_id} #{where}}").rows.flatten
62
- end
63
-
64
- # Process mutliple commands in a single HTTP request
65
- def bulk(&block)
66
- fusion_tables_service.batch do
67
- block.call(self)
68
- end
69
- end
70
-
71
- def replace_rows(table_id, csv)
72
- fusion_tables_service.replace_table_rows(table_id, :upload_source => csv, :options => {:open_timeout_sec => 1.hour})
73
- end
74
-
75
- def upload_rows(table_id, csv)
76
- fusion_tables_service.import_rows(table_id, :upload_source => csv, :options => {:open_timeout_sec => 1.hour})
77
- end
78
-
79
- def share_table(table_id)
80
- permission = {:type => 'anyone', :role => 'reader', :withLink => true}
81
- drive_service.create_permission(table_id, permission, :fields => 'id')
82
- end
83
-
84
- def fusion_tables_service
85
- @fusion_tables_service ||= Google::Apis::FusiontablesV2::FusiontablesService.new.tap do |service|
86
- service.client_options.application_name = APPLICATION_NAME
87
- service.authorization = @authorization
88
- end
89
- end
90
-
91
- def drive_service
92
- @drive_service ||= Google::Apis::DriveV3::DriveService.new.tap do |drive|
93
- drive.client_options.application_name = APPLICATION_NAME
94
- drive.authorization = @authorization
95
- end
96
- end
97
-
98
- def get_authorization(service_account_credentials_path, scopes)
99
- ENV['GOOGLE_APPLICATION_CREDENTIALS'] = service_account_credentials_path
100
- return Google::Auth.get_application_default(scopes)
101
- end
102
- end
103
- end
104
- end