spatial_features 2.7.8 → 2.8.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: 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