spatial_features 2.7.8 → 2.8.0

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: 43b6f41ecc826e85e2be68987e93993eadf956bd9b97e8cf690ad3f8e5afda6f
4
- data.tar.gz: 0bb2d37dc10ac0ab0c7e1a69d35f41c1a3bd5770c2a899846064227eb2f5ccc7
3
+ metadata.gz: b3acf24e6695a55827725efd2ca6290b96a4ba54d230e8aa176a7bbd356fe6fe
4
+ data.tar.gz: 48e9527307f5daabd632acf5e76f9d544bcfc3beaeadacb349c37184986321fa
5
5
  SHA512:
6
- metadata.gz: 3eb2027c5e4f5ab75f592834d9fcfa496c9211c5ae626e1d29fadb3ebd9cbccd7516de96a978da7601f5aa0bbb277a32b8eac50796f5321c2bf81d7018664aea
7
- data.tar.gz: 00ea8df16128ca618626c4d2b2db34c7551863cc1951f1fcea9282e821cc98ff53a1234df4efd65384364e7b58c6d69e0101a03ccdfdc1a07a7e5945ef26aee8
6
+ metadata.gz: 668a6afb0582a4470d1cab7b2e582392ae8fe392514cc0ac363488458052dd6f90dff13c5004aa5423b25f843bc7b79eec0e1e3e056802d80963b40bd4c6f7e9
7
+ data.tar.gz: 7c17ddc09d78bd90ef1d75a5f0cb67ee0ebb47fe286c6a272f3812f5b733e2e29dfce265e2ea8b91512d6dabcb1d2ccad089acf5b3e15c0a7368c41eda2a11e3
data/README.md CHANGED
@@ -24,6 +24,7 @@ Adds spatial methods to a model.
24
24
 
25
25
  CREATE TABLE features (
26
26
  id integer NOT NULL,
27
+ type character varying(255),
27
28
  spatial_model_type character varying(255),
28
29
  spatial_model_id integer,
29
30
  name character varying(255),
@@ -0,0 +1,167 @@
1
+ class AbstractFeature < ActiveRecord::Base
2
+ self.table_name = 'features'
3
+
4
+ class_attribute :lowres_simplification
5
+ self.lowres_simplification = 2 # Threshold in meters
6
+
7
+ belongs_to :spatial_model, :polymorphic => :true, :autosave => false
8
+
9
+ attr_writer :make_valid
10
+
11
+ FEATURE_TYPES = %w(polygon point line)
12
+
13
+ before_validation :sanitize_feature_type
14
+ validates_presence_of :geog
15
+ validate :validate_geometry
16
+ before_save :sanitize
17
+ after_save :cache_derivatives
18
+
19
+ def self.cache_key
20
+ "#{maximum(:id)}-#{count}"
21
+ end
22
+
23
+ def self.with_metadata(k, v)
24
+ if k.present? && v.present?
25
+ where('metadata->? = ?', k, v)
26
+ else
27
+ all
28
+ end
29
+ end
30
+
31
+ def self.polygons
32
+ where(:feature_type => 'polygon')
33
+ end
34
+
35
+ def self.lines
36
+ where(:feature_type => 'line')
37
+ end
38
+
39
+ def self.points
40
+ where(:feature_type => 'point')
41
+ end
42
+
43
+ def self.area_in_square_meters(geom = 'geom_lowres')
44
+ current_scope = all.polygons
45
+ unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
46
+ end
47
+
48
+ def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
49
+ scope = unscope(:select).select("ST_Union(#{geom}) AS geom").polygons
50
+ other_scope = other_features.polygons
51
+
52
+ query = base_class.unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
53
+ .from(scope, "features")
54
+ .joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
55
+ return connection.select_value(query).to_f
56
+ end
57
+
58
+ def self.intersecting(other)
59
+ join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
60
+ end
61
+
62
+ def self.invalid(column = 'geog::geometry')
63
+ select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
64
+ end
65
+
66
+ def self.valid
67
+ where('ST_IsValid(geog::geometry)')
68
+ end
69
+
70
+ def envelope(buffer_in_meters = 0)
71
+ envelope_json = JSON.parse(self.class.select("ST_AsGeoJSON(ST_Envelope(ST_Buffer(features.geog, #{buffer_in_meters})::geometry)) AS result").where(:id => id).first.result)
72
+ envelope_json = envelope_json["coordinates"].first
73
+
74
+ raise "Can't calculate envelope for Feature #{self.id}" if envelope_json.blank?
75
+
76
+ return envelope_json.values_at(0,2)
77
+ end
78
+
79
+ def self.cache_derivatives(options = {})
80
+ update_all <<-SQL.squish
81
+ geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
82
+ north = ST_YMax(geog::geometry),
83
+ east = ST_XMax(geog::geometry),
84
+ south = ST_YMin(geog::geometry),
85
+ west = ST_XMin(geog::geometry),
86
+ area = ST_Area(geog),
87
+ centroid = ST_PointOnSurface(geog::geometry)
88
+ SQL
89
+
90
+ invalid('geom').update_all <<-SQL.squish
91
+ geom = ST_Buffer(geom, 0)
92
+ SQL
93
+
94
+ update_all <<-SQL.squish
95
+ geom_lowres = ST_SimplifyPreserveTopology(geom, #{options.fetch(:lowres_simplification, lowres_simplification)})
96
+ SQL
97
+
98
+ invalid('geom_lowres').update_all <<-SQL.squish
99
+ geom_lowres = ST_Buffer(geom_lowres, 0)
100
+ SQL
101
+ end
102
+
103
+ def feature_bounds
104
+ {n: north, e: east, s: south, w: west}
105
+ end
106
+
107
+ def cache_derivatives(*args)
108
+ self.class.where(:id => self.id).cache_derivatives(*args)
109
+ end
110
+
111
+ def kml(options = {})
112
+ geometry = options[:lowres] ? kml_lowres : super()
113
+ geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid]
114
+ return geometry
115
+ end
116
+
117
+ def make_valid?
118
+ @make_valid
119
+ end
120
+
121
+ private
122
+
123
+ def make_valid
124
+ self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
125
+ end
126
+
127
+ # Use ST_Force2D to discard z-coordinates that cause failures later in the process
128
+ def sanitize
129
+ self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
130
+ end
131
+
132
+ SRID_CACHE = {}
133
+ def self.detect_srid(column_name)
134
+ SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
135
+ end
136
+
137
+ def self.join_other_features(other)
138
+ joins('INNER JOIN features AS other_features ON true').where(:other_features => {:id => other})
139
+ end
140
+
141
+ def validate_geometry
142
+ return unless geog?
143
+
144
+ error = geometry_validation_message
145
+ if error && make_valid?
146
+ make_valid
147
+ self.make_valid = false
148
+ validate_geometry
149
+ elsif error
150
+ errors.add :geog, error
151
+ end
152
+ end
153
+
154
+ def geometry_validation_message
155
+ klass = self.class.base_class # Use the base class because we don't want to have to include a type column in our select
156
+ error = klass.connection.select_one(klass.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geometry AS geog) #{klass.table_name}"))
157
+ return error.fetch('invalid_geometry_message') if error
158
+ end
159
+
160
+ def sanitize_feature_type
161
+ self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) }
162
+ end
163
+
164
+ def sanitize_input_for_sql(input)
165
+ self.class.send(:sanitize_sql_for_conditions, input)
166
+ end
167
+ end
@@ -0,0 +1,17 @@
1
+ require_dependency SpatialFeatures::Engine.root.join('app/models/abstract_feature')
2
+
3
+ class AggregateFeature < AbstractFeature
4
+ has_many :features, lambda { |aggregate| where(:spatial_model_type => aggregate.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id
5
+
6
+ # Aggregate the features for the spatial model into a single feature
7
+ def refresh
8
+ self.geog = ActiveRecord::Base.connection.select_value <<~SQL
9
+ SELECT ST_Collect(ARRAY[
10
+ (#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 1))').to_sql}),
11
+ (#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 2))').to_sql}),
12
+ (#{features.select('ST_UNION(ST_CollectionExtract(geog::geometry, 3))').to_sql})
13
+ ])::geography
14
+ SQL
15
+ self.save!
16
+ end
17
+ end
@@ -1,170 +1,62 @@
1
- class Feature < ActiveRecord::Base
2
- belongs_to :spatial_model, :polymorphic => :true, :autosave => false
1
+ class Feature < AbstractFeature
2
+ class_attribute :automatically_refresh_aggregate
3
+ self.automatically_refresh_aggregate = true
3
4
 
4
- attr_writer :make_valid
5
+ class_attribute :lowres_precision
6
+ self.lowres_precision = 5
5
7
 
6
- FEATURE_TYPES = %w(polygon point line)
8
+ has_one :aggregate_feature, lambda { |feature| where(:spatial_model_type => feature.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id
7
9
 
8
- before_validation :sanitize_feature_type
9
- validates_presence_of :geog
10
- validate :validate_geometry
11
10
  validates_inclusion_of :feature_type, :in => FEATURE_TYPES
12
- before_save :sanitize
13
- after_save :cache_derivatives
14
11
 
15
- def self.cache_key
16
- "#{maximum(:id)}-#{count}"
17
- end
18
-
19
- def self.with_metadata(k, v)
20
- if k.present? && v.present?
21
- where('metadata->? = ?', k, v)
22
- else
23
- all
24
- end
25
- end
12
+ after_save :refresh_aggregate, if: :automatically_refresh_aggregate?
26
13
 
27
- def self.polygons
28
- where(:feature_type => 'polygon')
29
- end
30
-
31
- def self.lines
32
- where(:feature_type => 'line')
33
- end
14
+ def self.defer_aggregate_refresh(&block)
15
+ start_at = Feature.maximum(:id).to_i + 1
16
+ output = without_aggregate_refresh(&block)
34
17
 
35
- def self.points
36
- where(:feature_type => 'point')
37
- end
18
+ where(:id => start_at..Float::INFINITY).refresh_aggregates
38
19
 
39
- def self.area_in_square_meters(geom = 'geom_lowres')
40
- current_scope = all.polygons
41
- unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f }
20
+ return output
42
21
  end
43
22
 
44
- def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres')
45
- scope = unscope(:select).select("ST_Union(#{geom}) AS geom").polygons
46
- other_scope = other_features.polygons
47
-
48
- query = unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))')
49
- .from(scope, "features")
50
- .joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)")
51
- return connection.select_value(query).to_f
23
+ def self.without_aggregate_refresh
24
+ old = Feature.automatically_refresh_aggregate
25
+ Feature.automatically_refresh_aggregate = false
26
+ yield
27
+ ensure
28
+ Feature.automatically_refresh_aggregate = old
52
29
  end
53
30
 
54
- def self.intersecting(other)
55
- join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq
56
- end
31
+ def self.refresh_aggregates
32
+ # 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)
35
+ .group('spatial_model_type, spatial_model_id')
57
36
 
58
- def self.invalid(column = 'geog::geometry')
59
- select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})")
37
+ where(:id => ids).find_each(&:refresh_aggregate)
60
38
  end
61
39
 
62
- def self.valid
63
- where('ST_IsValid(geog::geometry)')
40
+ def refresh_aggregate
41
+ # puts "Refreshing AggregateFeature for #{spatial_model_type} #{spatial_model_id}"
42
+ build_aggregate_feature unless aggregate_feature&.persisted?
43
+ aggregate_feature.refresh
64
44
  end
65
45
 
66
- def envelope(buffer_in_meters = 0)
67
- envelope_json = JSON.parse(self.class.select("ST_AsGeoJSON(ST_Envelope(ST_Buffer(features.geog, #{buffer_in_meters})::geometry)) AS result").where(:id => id).first.result)
68
- envelope_json = envelope_json["coordinates"].first
69
-
70
- raise "Can't calculate envelope for Feature #{self.id}" if envelope_json.blank?
71
-
72
- return envelope_json.values_at(0,2)
46
+ def automatically_refresh_aggregate?
47
+ # Check if there is a spatial model id because nothing prevents is from creating a Feature without one. Depending on
48
+ # how you assign a feature to a record, you may end up saving it before assigning it to a record, thereby leaving
49
+ # this field blank.
50
+ spatial_model_id? && automatically_refresh_aggregate
73
51
  end
74
52
 
53
+ # Features are used for display so we also cache their KML representation
75
54
  def self.cache_derivatives(options = {})
76
- options.reverse_merge! :lowres_simplification => 2, :lowres_precision => 5
77
-
78
- update_all <<-SQL.squish
79
- geom = ST_Transform(geog::geometry, #{detect_srid('geom')}),
80
- north = ST_YMax(geog::geometry),
81
- east = ST_XMax(geog::geometry),
82
- south = ST_YMin(geog::geometry),
83
- west = ST_XMin(geog::geometry),
84
- area = ST_Area(geog),
85
- centroid = ST_PointOnSurface(geog::geometry)
86
- SQL
87
-
88
- invalid('geom').update_all <<-SQL.squish
89
- geom = ST_Buffer(geom, 0)
90
- SQL
91
-
92
- update_all <<-SQL.squish
93
- geom_lowres = ST_SimplifyPreserveTopology(geom, #{options[:lowres_simplification]})
94
- SQL
95
-
96
- invalid('geom_lowres').update_all <<-SQL.squish
97
- geom_lowres = ST_Buffer(geom_lowres, 0)
98
- SQL
99
-
55
+ super
100
56
  update_all <<-SQL.squish
101
57
  kml = ST_AsKML(geog, 6),
102
- kml_lowres = ST_AsKML(geom_lowres, #{options[:lowres_precision]}),
58
+ kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}),
103
59
  kml_centroid = ST_AsKML(centroid)
104
60
  SQL
105
61
  end
106
-
107
- def feature_bounds
108
- {n: north, e: east, s: south, w: west}
109
- end
110
-
111
- def cache_derivatives(*args)
112
- self.class.where(:id => self.id).cache_derivatives(*args)
113
- end
114
-
115
- def kml(options = {})
116
- geometry = options[:lowres] ? kml_lowres : super()
117
- geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid]
118
- return geometry
119
- end
120
-
121
- def make_valid?
122
- @make_valid
123
- end
124
-
125
- private
126
-
127
- def make_valid
128
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)")
129
- end
130
-
131
- # Use ST_Force2D to discard z-coordinates that cause failures later in the process
132
- def sanitize
133
- self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')")
134
- end
135
-
136
- SRID_CACHE = {}
137
- def self.detect_srid(column_name)
138
- SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')")
139
- end
140
-
141
- def self.join_other_features(other)
142
- joins('INNER JOIN features AS other_features ON true').where(:other_features => {:id => other})
143
- end
144
-
145
- def validate_geometry
146
- return unless geog?
147
-
148
- error = geometry_validation_message
149
- if error && make_valid?
150
- make_valid
151
- self.make_valid = false
152
- validate_geometry
153
- elsif error
154
- errors.add :geog, error
155
- end
156
- end
157
-
158
- def geometry_validation_message
159
- error = self.class.connection.select_one(self.class.unscoped.invalid.from("(SELECT '#{sanitize_input_for_sql(self.geog)}'::geometry AS geog) #{self.class.table_name}"))
160
- return error.fetch('invalid_geometry_message') if error
161
- end
162
-
163
- def sanitize_feature_type
164
- self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) }
165
- end
166
-
167
- def sanitize_input_for_sql(input)
168
- self.class.send(:sanitize_sql_for_conditions, input)
169
- end
170
62
  end
@@ -79,9 +79,11 @@ module SpatialFeatures
79
79
 
80
80
  def import_features(imports, skip_invalid)
81
81
  self.features.delete_all
82
- valid, invalid = imports.flat_map(&:features).partition do |feature|
83
- feature.spatial_model = self
84
- feature.save
82
+ valid, invalid = Feature.defer_aggregate_refresh do
83
+ imports.flat_map(&:features).partition do |feature|
84
+ feature.spatial_model = self
85
+ feature.save
86
+ end
85
87
  end
86
88
 
87
89
  errors = imports.flat_map(&:errors)
@@ -11,6 +11,7 @@ module SpatialFeatures
11
11
  include FeatureImport
12
12
 
13
13
  has_many :features, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete_all
14
+ has_one :aggregate_feature, lambda { extending FeaturesAssociationExtensions }, :as => :spatial_model, :dependent => :delete
14
15
 
15
16
  scope :with_features, lambda { joins(:features).uniq }
16
17
  scope :without_features, lambda { joins("LEFT OUTER JOIN features ON features.spatial_model_type = '#{Utils.base_class(name)}' AND features.spatial_model_id = #{table_name}.id").where("features.id IS NULL") }
@@ -128,24 +129,8 @@ module SpatialFeatures
128
129
  scope = spatial_join(other, buffer_in_meters)
129
130
  scope = scope.select(options[:columns])
130
131
 
131
- # Ensure records with multiple features don't appear multiple times
132
- if options[:distance] || options[:intersection_area]
133
- scope = scope.group("#{table_name}.#{primary_key}") # Aggregate functions require grouping
134
- else
135
- scope = scope.distinct
136
- end
137
-
138
- scope = scope.select("MIN(ST_Distance(features.geom, other_features.geom)) AS distance_in_meters") if options[:distance]
139
- scope = scope.select <<~SQL if options[:intersection_area]
140
- ST_Area(
141
- ST_Intersection(
142
- /* Extract only polygons to calculations since we're calculating area */
143
- /* Union to aggregate features from all records in the query before intersection to flatten geometry and avoid possible self intersection */
144
- ST_Union(ST_CollectionExtract(features.geom, 3)),
145
- ST_Union(ST_CollectionExtract(other_features.geom, 3))
146
- )
147
- ) AS intersection_area_in_square_meters
148
- SQL
132
+ scope = scope.select("ST_Distance(features.geom, other_features.geom) AS distance_in_meters") if options[:distance]
133
+ scope = scope.select("ST_Area(ST_Intersection(features.geom, other_features.geom)) AS intersection_area_in_square_meters") if options[:intersection_area]
149
134
 
150
135
  return scope
151
136
  end
@@ -165,7 +150,7 @@ module SpatialFeatures
165
150
  end
166
151
 
167
152
  def features_scope(other)
168
- scope = Feature
153
+ scope = AggregateFeature
169
154
  scope = scope.where(:spatial_model_type => Utils.base_class_of(other).to_s)
170
155
  scope = scope.where(:spatial_model_id => other) unless Utils.class_of(other) == other
171
156
  return scope
@@ -1,3 +1,3 @@
1
1
  module SpatialFeatures
2
- VERSION = "2.7.8"
2
+ VERSION = "2.8.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.7.8
4
+ version: 2.8.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: 2019-09-10 00:00:00.000000000 Z
12
+ date: 2019-10-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -167,6 +167,8 @@ files:
167
167
  - MIT-LICENSE
168
168
  - README.md
169
169
  - Rakefile
170
+ - app/models/abstract_feature.rb
171
+ - app/models/aggregate_feature.rb
170
172
  - app/models/feature.rb
171
173
  - app/models/spatial_cache.rb
172
174
  - app/models/spatial_proximity.rb