collectiveidea/acts_as_geocodable

View on GitHub
lib/acts_as_geocodable.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require "active_record"
require "active_support"
require "graticule"

require "acts_as_geocodable/geocoding"
require "acts_as_geocodable/geocode"
require "acts_as_geocodable/remote_location"

module ActiveSupport::Callbacks::ClassMethods
  def without_callback(*args, &block)
    skip_callback(*args)
    yield
    set_callback(*args)
  end
end

module ActsAsGeocodable
  # Make a model geocodable.
  #
  #  class Event < ActiveRecord::Base
  #    acts_as_geocodable
  #  end
  #
  # == Options
  # * <tt>:address</tt>: A hash that maps geocodable attirbutes (<tt>:street</tt>,
  #   <tt>:locality</tt>, <tt>:region</tt>, <tt>:postal_code</tt>, <tt>:country</tt>)
  #   to your model's address fields, or a symbol to store the entire address in one field
  # * <tt>:normalize_address</tt>: If set to true, you address fields will be updated
  #   using the address fields returned by the geocoder. (Default is +false+)
  # * <tt>:units</tt>: Default units-<tt>:miles</tt> or <tt>:kilometers</tt>-used for
  #   distance calculations and queries. (Default is <tt>:miles</tt>)
  #
  def acts_as_geocodable(options = {})
    options = {
      address: {
        street: :street, locality: :locality, region: :region,
        postal_code: :postal_code, country: :country},
      normalize_address: false,
      distance_column: "distance",
      units: :miles
    }.merge(options)

    class_attribute :acts_as_geocodable_options
    self.acts_as_geocodable_options = options

    define_callbacks :geocoding

    if ActiveRecord::VERSION::MAJOR >= 4
      has_one :geocoding, -> { includes :geocode }, as: :geocodable, dependent: :destroy
    else
      has_one :geocoding, as: :geocodable, include: :geocode, dependent: :destroy
    end

    after_save :attach_geocode

    # Would love to do a simpler scope here, like:
    # scope :with_geocode_fields, includes(:geocoding)
    # But we need to use select() and it would get overwritten.
    scope :with_geocode_fields, lambda {
      joins("JOIN geocodings ON
          #{table_name}.#{primary_key} = geocodings.geocodable_id AND
            geocodings.geocodable_type = '#{model_name}'
          JOIN geocodes ON geocodings.geocode_id = geocodes.id")
    }

    # Use ActiveRecord ARel style syntax for finding records.
    #
    #   Model.origin("Chicago, IL", within: 10)
    #
    # a +distance+ attribute indicating the distance
    # to the origin is added to each of the results:
    #
    #   Model.origin("Portland, OR").first.distance #=> 388.383
    #
    # == Options
    #
    # * <tt>origin</tt>: A Geocode, String, or geocodable model that specifies
    #   the origin
    # * <tt>:within</tt>: Limit to results within this radius of the origin
    # * <tt>:beyond</tt>: Limit to results outside of this radius from the origin
    # * <tt>:units</tt>: Units to use for <tt>:within</tt> or <tt>:beyond</tt>.
    #   Default is <tt>:miles</tt> unless specified otherwise in the +acts_as_geocodable+
    #   declaration.
    #
    scope :origin, lambda { |*args|
      origin = location_to_geocode(args[0])
      options = {
        units: acts_as_geocodable_options[:units],
      }.merge(args[1] || {})
      distance_sql = sql_for_distance(origin, options[:units])

      scope = with_geocode_fields.select("#{table_name}.*, #{distance_sql} AS
           #{acts_as_geocodable_options[:distance_column]}")

      scope = scope.where("#{distance_sql} > #{options[:beyond]}") if options[:beyond]
      if options[:within]
        scope = scope.where("(geocodes.latitude = :lat AND geocodes.longitude = :long) OR (#{distance_sql} <= #{options[:within]})", { lat: origin.latitude, long: origin.longitude })
      end
      scope
    }

    scope :near, -> { order("#{acts_as_geocodable_options[:distance_column]} ASC") }
    scope :far, -> { order("#{acts_as_geocodable_options[:distance_column]} DESC") }

    include ActsAsGeocodable::Model
  end

  module Model
    def self.included(base)
      base.extend(ClassMethods)
    end

    module ClassMethods
      # Find the nearest location to the given origin
      #
      #   Model.origin("Grand Rapids, MI").nearest
      #
      def nearest
        near.first
      end

      # Find the farthest location to the given origin
      #
      #   Model.origin("Grand Rapids, MI").farthest
      #
      def farthest
        far.first
      end

      # Convert the given location to a Geocode
      def location_to_geocode(location)
        case location
        when Geocode then location
        when ActsAsGeocodable::Model then location.geocode
        when String, Fixnum then Geocode.find_or_create_by_query(location.to_s)
        end
      end

      # Validate that the model can be geocoded
      #
      # Options:
      # * <tt>:message</tt>: Added to errors base (Default: Address could not be geocoded.)
      # * <tt>:allow_nil</tt>: If all the address attributes are blank, then don't try to
      #   validate the geocode (Default: false)
      # * <tt>:precision</tt>: Require a minimum geocoding precision
      #
      # validates_as_geocodable also takes a block that you can use to performa additional
      # checks on the geocode. If this block returns false, then validation will fail.
      #
      #   validates_as_geocodable do |geocode|
      #     geocode.country == "US"
      #   end
      #
      def validates_as_geocodable(options = {})
        options = options.reverse_merge message: "Address could not be geocoded.", allow_nil: false
        validate do |model|
          is_blank = model.to_location.attributes.except(:precision).all?(&:blank?)
          unless options[:allow_nil] && is_blank
            geocode = model.send(:attach_geocode)
            if !geocode ||
                (options[:precision] && geocode.precision < options[:precision]) ||
                (block_given? && yield(geocode) == false)
              model.errors.add(:base, options[:message])
            end
          end
        end
      end

      private

      def sql_for_distance(origin, units = acts_as_geocodable_options[:units])
        Graticule::Distance::Spherical.to_sql(
          latitude: origin.latitude,
          longitude: origin.longitude,
          latitude_column: "geocodes.latitude",
          longitude_column: "geocodes.longitude",
          units: units
        )
      end
    end

    # Get the geocode for this model
    def geocode
      geocoding.geocode if geocoding
    end

    # Create a Graticule::Location
    def to_location
      Graticule::Location.new.tap do |location|
        [:street, :locality, :region, :postal_code, :country].each do |attr|
          location.send("#{attr}=", geo_attribute(attr))
        end
      end
    end

    # Get the distance to the given destination. The destination can be an
    # acts_as_geocodable model, a Geocode, or a string
    #
    #   myhome.distance_to "Chicago, IL"
    #   myhome.distance_to "49423"
    #   myhome.distance_to other_model
    #
    # == Options
    # * <tt>:units</tt>: <tt>:miles</tt> or <tt>:kilometers</tt>
    # * <tt>:formula</tt>: The formula to use to calculate the distance. This can
    #   be any formula supported by Graticule. The default is <tt>:haversine</tt>.
    #
    def distance_to(destination, options = {})
      units = options[:units] || acts_as_geocodable_options[:units]
      formula = options[:formula] || :haversine

      geocode = self.class.location_to_geocode(destination)
      self.geocode.distance_to(geocode, units, formula)
    end

    protected

    # Perform the geocoding
    def attach_geocode
      new_geocode = Geocode.find_or_create_by_location(self.to_location) unless self.to_location.blank?
      if new_geocode && self.geocode != new_geocode
        run_callbacks :geocoding do
          self.geocoding = Geocoding.new(geocode: new_geocode)
          self.update_address self.acts_as_geocodable_options[:normalize_address]
        end
      elsif !new_geocode && self.geocoding
        self.geocoding.destroy
      end
      new_geocode
    rescue Graticule::Error => error
      logger.warn error.message
    end

    def update_address(force = false)
      unless self.geocode.blank?
        if self.acts_as_geocodable_options[:address].is_a? Symbol
          method = self.acts_as_geocodable_options[:address]
          if self.respond_to?("#{method}=") && (self.send(method).blank? || force)
            self.send("#{method}=", self.geocode.to_location.to_s)
          end
        else
          self.acts_as_geocodable_options[:address].each do |attribute,method|
            if self.respond_to?("#{method}=") && (self.send(method).blank? || force)
              self.send("#{method}=", self.geocode.send(attribute))
            end
          end
        end

        self.class.without_callback(:save, :after, :attach_geocode) do
          save
        end
      end
    end

    def geo_attribute(attr_key)
      if self.acts_as_geocodable_options[:address].is_a? Symbol
        attr_name = self.acts_as_geocodable_options[:address]
        attr_key == :street ? self.send(attr_name) : nil
      else
        attr_name = self.acts_as_geocodable_options[:address][attr_key]
        attr_name && self.respond_to?(attr_name) ? self.send(attr_name) : nil
      end
    end
  end
end

ActiveRecord::Base.send(:extend, ActsAsGeocodable)