app/models/feature.rb in spatial_features-2.7.8 vs app/models/feature.rb in spatial_features-2.8.0

- old
+ new

@@ -1,170 +1,62 @@ -class Feature < ActiveRecord::Base - belongs_to :spatial_model, :polymorphic => :true, :autosave => false +class Feature < AbstractFeature + class_attribute :automatically_refresh_aggregate + self.automatically_refresh_aggregate = true - attr_writer :make_valid + class_attribute :lowres_precision + self.lowres_precision = 5 - FEATURE_TYPES = %w(polygon point line) + has_one :aggregate_feature, lambda { |feature| where(:spatial_model_type => feature.spatial_model_type) }, :foreign_key => :spatial_model_id, :primary_key => :spatial_model_id - before_validation :sanitize_feature_type - validates_presence_of :geog - validate :validate_geometry validates_inclusion_of :feature_type, :in => FEATURE_TYPES - before_save :sanitize - after_save :cache_derivatives - def self.cache_key - "#{maximum(:id)}-#{count}" - end + after_save :refresh_aggregate, if: :automatically_refresh_aggregate? - def self.with_metadata(k, v) - if k.present? && v.present? - where('metadata->? = ?', k, v) - else - all - end - end + def self.defer_aggregate_refresh(&block) + start_at = Feature.maximum(:id).to_i + 1 + output = without_aggregate_refresh(&block) - def self.polygons - where(:feature_type => 'polygon') - end + where(:id => start_at..Float::INFINITY).refresh_aggregates - def self.lines - where(:feature_type => 'line') + return output end - def self.points - where(:feature_type => 'point') + def self.without_aggregate_refresh + old = Feature.automatically_refresh_aggregate + Feature.automatically_refresh_aggregate = false + yield + ensure + Feature.automatically_refresh_aggregate = old end - def self.area_in_square_meters(geom = 'geom_lowres') - current_scope = all.polygons - unscoped { connection.select_value(select("ST_Area(ST_Union(#{geom}))").from(current_scope, :features)).to_f } - end + def self.refresh_aggregates + # Find one feature from each spatial model and trigger the aggregate feature refresh + ids = select('MAX(id)') + .where.not(:spatial_model_type => nil, :spatial_model_id => nil) + .group('spatial_model_type, spatial_model_id') - def self.total_intersection_area_in_square_meters(other_features, geom = 'geom_lowres') - scope = unscope(:select).select("ST_Union(#{geom}) AS geom").polygons - other_scope = other_features.polygons - - query = unscoped.select('ST_Area(ST_Intersection(ST_Union(features.geom), ST_Union(other_features.geom)))') - .from(scope, "features") - .joins("INNER JOIN (#{other_scope.to_sql}) AS other_features ON ST_Intersects(features.geom, other_features.geom)") - return connection.select_value(query).to_f + where(:id => ids).find_each(&:refresh_aggregate) end - def self.intersecting(other) - join_other_features(other).where('ST_Intersects(features.geom_lowres, other_features.geom_lowres)').uniq + def refresh_aggregate + # puts "Refreshing AggregateFeature for #{spatial_model_type} #{spatial_model_id}" + build_aggregate_feature unless aggregate_feature&.persisted? + aggregate_feature.refresh end - def self.invalid(column = 'geog::geometry') - select("features.*, ST_IsValidReason(#{column}) AS invalid_geometry_message").where.not("ST_IsValid(#{column})") + def automatically_refresh_aggregate? + # Check if there is a spatial model id because nothing prevents is from creating a Feature without one. Depending on + # how you assign a feature to a record, you may end up saving it before assigning it to a record, thereby leaving + # this field blank. + spatial_model_id? && automatically_refresh_aggregate end - def self.valid - where('ST_IsValid(geog::geometry)') - end - - def envelope(buffer_in_meters = 0) - 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) - envelope_json = envelope_json["coordinates"].first - - raise "Can't calculate envelope for Feature #{self.id}" if envelope_json.blank? - - return envelope_json.values_at(0,2) - end - + # Features are used for display so we also cache their KML representation def self.cache_derivatives(options = {}) - options.reverse_merge! :lowres_simplification => 2, :lowres_precision => 5 - + super update_all <<-SQL.squish - geom = ST_Transform(geog::geometry, #{detect_srid('geom')}), - north = ST_YMax(geog::geometry), - east = ST_XMax(geog::geometry), - south = ST_YMin(geog::geometry), - west = ST_XMin(geog::geometry), - area = ST_Area(geog), - centroid = ST_PointOnSurface(geog::geometry) - SQL - - invalid('geom').update_all <<-SQL.squish - geom = ST_Buffer(geom, 0) - SQL - - update_all <<-SQL.squish - geom_lowres = ST_SimplifyPreserveTopology(geom, #{options[:lowres_simplification]}) - SQL - - invalid('geom_lowres').update_all <<-SQL.squish - geom_lowres = ST_Buffer(geom_lowres, 0) - SQL - - update_all <<-SQL.squish kml = ST_AsKML(geog, 6), - kml_lowres = ST_AsKML(geom_lowres, #{options[:lowres_precision]}), + kml_lowres = ST_AsKML(geom_lowres, #{options.fetch(:lowres_precision, lowres_precision)}), kml_centroid = ST_AsKML(centroid) SQL - end - - def feature_bounds - {n: north, e: east, s: south, w: west} - end - - def cache_derivatives(*args) - self.class.where(:id => self.id).cache_derivatives(*args) - end - - def kml(options = {}) - geometry = options[:lowres] ? kml_lowres : super() - geometry = "<MultiGeometry>#{geometry}#{kml_centroid}</MultiGeometry>" if options[:centroid] - return geometry - end - - def make_valid? - @make_valid - end - - private - - def make_valid - self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Buffer('#{sanitize}', 0)") - end - - # Use ST_Force2D to discard z-coordinates that cause failures later in the process - def sanitize - self.geog = ActiveRecord::Base.connection.select_value("SELECT ST_Force2D('#{geog}')") - end - - SRID_CACHE = {} - def self.detect_srid(column_name) - SRID_CACHE[column_name] ||= connection.select_value("SELECT Find_SRID('public', '#{table_name}', '#{column_name}')") - end - - def self.join_other_features(other) - joins('INNER JOIN features AS other_features ON true').where(:other_features => {:id => other}) - end - - def validate_geometry - return unless geog? - - error = geometry_validation_message - if error && make_valid? - make_valid - self.make_valid = false - validate_geometry - elsif error - errors.add :geog, error - end - end - - def geometry_validation_message - 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}")) - return error.fetch('invalid_geometry_message') if error - end - - def sanitize_feature_type - self.feature_type = FEATURE_TYPES.find {|type| self.feature_type.to_s.strip.downcase.include?(type) } - end - - def sanitize_input_for_sql(input) - self.class.send(:sanitize_sql_for_conditions, input) end end