af83/chouette-core

View on GitHub
app/models/chouette/vehicle_journey.rb

Summary

Maintainability
F
3 days
Test Coverage
# frozen_string_literal: true

module Chouette
  class VehicleJourney < Referential::Model
    include CustomFieldsSupport
    include TransportModeEnumerations

    has_metadata

    enum journey_category: { timed: 0, frequency: 1 }

    # default_scope { where(journey_category: journey_categories[:timed]) }

    attr_reader :time_table_tokens

    def self.nullable_attributes
      [:transport_mode, :published_journey_name, :vehicle_type_identifier, :published_journey_identifier, :comment]
    end

    belongs_to :company
    belongs_to :accessibility_assessment, class_name: '::AccessibilityAssessment', optional: true
    belongs_to :company_light, -> {select(:id, :objectid, :line_referential_id)}, class_name: "Chouette::Company", foreign_key: :company_id
    belongs_to :route
    belongs_to :journey_pattern
    belongs_to :journey_pattern_only_objectid, -> {select("journey_patterns.id, journey_patterns.objectid")}, class_name: "Chouette::JourneyPattern", foreign_key: :journey_pattern_id
    has_array_of :service_facility_sets, class_name: '::ServiceFacilitySet'

    has_many :stop_areas, through: :journey_pattern

    belongs_to_public :stop_area_routing_constraints,
      collection_name: :ignored_stop_area_routing_constraints,
      index_collection: -> { Chouette::VehicleJourney.where.not('ignored_stop_area_routing_constraint_ids = ARRAY[]::bigint[]') }

    has_array_of :line_notices, class_name: 'Chouette::LineNotice'
    belongs_to_public :line_notices,
      index_collection: -> { Chouette::VehicleJourney.where.not('line_notice_ids = ARRAY[]::bigint[]') }

    delegate :line, to: :route, allow_nil: true

    has_and_belongs_to_many :footnotes, :class_name => 'Chouette::Footnote'
    has_array_of :ignored_routing_contraint_zones, class_name: 'Chouette::RoutingConstraintZone'
    has_array_of :ignored_stop_area_routing_constraints, class_name: 'StopAreaRoutingConstraint'

    with_options(if: -> { validation_context != :inserter }) do |except_in_inserter_context|
      except_in_inserter_context.validates :route, presence: true
      except_in_inserter_context.validates :journey_pattern, presence: true
      except_in_inserter_context.before_validation :calculate_vehicle_journey_at_stop_day_offset
    end
    validate :validate_passing_times_chronology
    validates :number, presence: true

    has_many :vehicle_journey_at_stops, -> { includes(:stop_point).order("stop_points.position") }, dependent: :destroy
    has_and_belongs_to_many :time_tables, :class_name => 'Chouette::TimeTable', :foreign_key => "vehicle_journey_id", :association_foreign_key => "time_table_id"
    has_many :stop_points, -> { order("stop_points.position") }, :through => :vehicle_journey_at_stops
    has_many :vehicle_journey_time_table_relationships, class_name: 'Chouette::TimeTablesVehicleJourney'

    before_validation :set_default_values

    scope :with_companies, -> (companies) { joins(route: :line).where(lines: { company_id: companies }) }

    scope :with_stop_area_ids, ->(ids){
      _ids = ids.select(&:present?).map(&:to_i)
      if _ids.present?
        where("array(SELECT stop_points.stop_area_id::integer FROM stop_points INNER JOIN journey_patterns_stop_points ON journey_patterns_stop_points.stop_point_id = stop_points.id WHERE journey_patterns_stop_points.journey_pattern_id = vehicle_journeys.journey_pattern_id) @> array[?]", _ids)
      else
        all
      end
    }

    scope :with_stop_area_id, ->(id){
      if id.present?
        joins(journey_pattern: :stop_points).where('stop_points.stop_area_id = ?', id)
      else
        all
      end
    }

    scope :with_ordered_stop_area_ids, ->(first, second){
      if first.present? && second.present?
        joins(journey_pattern: :stop_points).
          joins('INNER JOIN "journey_patterns" ON "journey_patterns"."id" = "vehicle_journeys"."journey_pattern_id" INNER JOIN "journey_patterns_stop_points" ON "journey_patterns_stop_points"."journey_pattern_id" = "journey_patterns"."id" INNER JOIN "stop_points" as "second_stop_points" ON "second_stop_points"."id" = "journey_patterns_stop_points"."stop_point_id"').
          where('stop_points.stop_area_id = ?', first).
          where('second_stop_points.stop_area_id = ? and stop_points.position < second_stop_points.position', second)
      else
        all
      end
    }

    scope :starting_with, ->(id){
      if id.present?
        joins(journey_pattern: :stop_points).where('stop_points.position = 0 AND stop_points.stop_area_id = ?', id)
      else
        all
      end
    }

    scope :ending_with, ->(id){
      if id.present?
        pattern_ids = all.select(:journey_pattern_id).distinct.map(&:journey_pattern_id)
        pattern_ids = Chouette::JourneyPattern.where(id: pattern_ids).to_a.select{|jp| p "ici: #{jp.stop_points.order(:position).last.stop_area_id}" ; jp.stop_points.order(:position).last.stop_area_id == id.to_i}.map &:id
        where(journey_pattern_id: pattern_ids)
      else
        all
      end
    }

    scope :order_by_departure_time, -> (dir) {
      field = "MIN(current_date + departure_day_offset * interval '24 hours' + departure_time)"
      joins(:vehicle_journey_at_stops)
      .select('id', field)
      .group(:id)
      .order(Arel.sql("#{field} #{dir}"))
    }

    scope :order_by_arrival_time, -> (dir) {
      field = "MAX(current_date + arrival_day_offset * interval '24 hours' + arrival_time)"
      joins(:vehicle_journey_at_stops)
      .select('id', field)
      .group(:id)
      .order(Arel.sql("#{field} #{dir}"))
    }

    def self.with_departure_arrival_second_offsets
      stops = Chouette::VehicleJourneyAtStop.joins(:stop_point).where('vehicle_journey_id = vehicle_journeys.id')

      query = joins("JOIN LATERAL (#{stops.order('stop_points.position').limit(1).select(:departure_time, :departure_day_offset).to_sql}) first_stop ON true")
              .joins("JOIN LATERAL (#{stops.order('stop_points.position': :desc).select(:arrival_time, :arrival_day_offset).limit(1).to_sql}) last_stop ON true")
              .select('*',
                      'EXTRACT(EPOCH FROM first_stop.departure_time) + first_stop.departure_day_offset * 86400 as departure_second_offset',
                      'EXTRACT(EPOCH FROM last_stop.arrival_time) + last_stop.arrival_day_offset * 86400 as arrival_second_offset')

      from(query, :vehicle_journeys)
    end

    scope :without_any_time_table, -> { joins('LEFT JOIN time_tables_vehicle_journeys ON time_tables_vehicle_journeys.vehicle_journey_id = vehicle_journeys.id LEFT JOIN time_tables ON time_tables.id = time_tables_vehicle_journeys.time_table_id').where(:time_tables => { :id => nil}) }
    scope :without_any_passing_time, -> { joins('LEFT JOIN vehicle_journey_at_stops ON vehicle_journey_at_stops.vehicle_journey_id = vehicle_journeys.id').where(vehicle_journey_at_stops: { id: nil }) }
    scope :scheduled, ->(time_tables) { joins(:time_tables).merge(time_tables) }
    scope :with_lines, -> (lines) { joins(:route).where(routes: { line_id: lines }) }

    scope :with_time_tables, -> (time_tables) { joins(:time_tables).where(time_tables: { id: time_tables }) }

    scope :by_text, ->(text) { text.blank? ? all : where('lower(vehicle_journeys.published_journey_name) LIKE :t or lower(vehicle_journeys.objectid) LIKE :t', t: "%#{text.downcase}%") }

    # We need this for the ransack object in the filters
    ransacker :stop_area_ids

    # returns VehicleJourneys with at least 1 day in their time_tables
    # included in the given range
    def self.with_matching_timetable date_range
      scope = Chouette::TimeTable.joins(
        :vehicle_journeys
      ).merge(self.all)
      dates_scope = scope.joins(:dates).select('time_table_dates.date').order('time_table_dates.date').where('time_table_dates.in_out' => true)
      min_date = scope.joins(:periods).select('time_table_periods.period_start').order('time_table_periods.period_start').first&.period_start
      min_date = [min_date, dates_scope.first&.date].compact.min
      max_date = scope.joins(:periods).select('time_table_periods.period_end').order('time_table_periods.period_end').last&.period_end
      max_date = [max_date, dates_scope.last&.date].compact.max

      return none unless min_date && max_date

      date_range = date_range & (min_date..max_date)

      return none unless date_range && date_range.count > 0

      time_table_ids = scope.overlapping(date_range).applied_at_least_once_in_ids(date_range)
      joins(:time_tables).where("time_tables.id" => time_table_ids).distinct
    end

    def self.scheduled_on(date)
      joins(:time_tables).merge(Chouette::TimeTable.scheduled_on(date)).distinct
    end

    # Returns ordered arrival/departure time of days for all Vehicle Journey stops
    def passing_times
      vehicle_journey_at_stops.flat_map do |vehicle_journey_at_stop|
        %w{arrival departure}.map do |part|
          vehicle_journey_at_stop.send "#{part}_time_of_day"
        end
      end
    end

    def validate_passing_times_chronology
      passing_times.each_cons(2) do |previous_time_of_day, time_of_day|
        if time_of_day.present? && previous_time_of_day.present? && time_of_day < previous_time_of_day
          # For the moment, a single/global error is defined
          errors.add :vehicle_journey_at_stops, :invalid_chronology
          return false
        end
      end

      true
    end

    def local_id
      "local-#{self.referential.id}-#{self.route.line.get_objectid.local_id}-#{self.id}"
    end

    def checksum_attributes(db_lookup = true)
      [].tap do |attrs|
        attrs << self.published_journey_name
        attrs << self.published_journey_identifier
        loaded_company = association(:company).loaded? ? company : company_light
        attrs << loaded_company.try(:get_objectid).try(:local_id)
        footnotes = self.footnotes
        footnotes += Footnote.for_vehicle_journey(self) if db_lookup && !self.new_record?
        attrs << footnotes.uniq.map(&:checksum).sort
        attrs << line_notices.uniq.map(&:objectid).sort
        vjas =  self.vehicle_journey_at_stops
        vjas += VehicleJourneyAtStop.where(vehicle_journey_id: self.id) if db_lookup && !self.new_record?
        attrs << vjas.uniq.sort_by { |s| s.stop_point&.position }.map(&:checksum)
        attrs << service_facility_set_ids
        attrs << accessibility_assessment_id

        # The double condition prevents a SQL query "WHERE 1=0"
        if ignored_routing_contraint_zone_ids.present? && ignored_routing_contraint_zones.present?
          attrs << ignored_routing_contraint_zones.map(&:checksum).sort
        end
        if ignored_stop_area_routing_constraint_ids.present? && ignored_stop_area_routing_constraints.present?
          attrs << ignored_stop_area_routing_constraints.map(&:checksum).sort
        end
      end
    end

    has_checksum_children VehicleJourneyAtStop
    has_checksum_children Footnote
    has_checksum_children Chouette::LineNotice
    has_checksum_children StopPoint

    def set_default_values
      if number.nil?
        self.number = 0
      end
    end

    def calculate_vehicle_journey_at_stop_day_offset
      Chouette::VehicleJourneyAtStopsDayOffset.new(
        vehicle_journey_at_stops.sort_by{ |vjas| vjas.stop_point.position }
      ).calculate!
    end

    accepts_nested_attributes_for :vehicle_journey_at_stops, :allow_destroy => true

    def vehicle_journey_at_stops_matrix
      at_stops = self.vehicle_journey_at_stops.to_a.dup
      active_stop_point_ids = journey_pattern.stop_points.map(&:id)

      (route.stop_points.map(&:id) - at_stops.map(&:stop_point_id)).each do |id|
        vjas = Chouette::VehicleJourneyAtStop.new(stop_point_id: id)
        vjas.dummy = !active_stop_point_ids.include?(id)
        at_stops.insert(route.stop_points.map(&:id).index(id), vjas)
      end
      at_stops
    end

    def create_or_find_vjas_from_state vjas
      return vehicle_journey_at_stops.find(vjas['id']) if vjas['id']
      stop_point = Chouette::StopPoint.find_by(objectid: vjas['stop_point_objectid'])
      stop       = vehicle_journey_at_stops.create(stop_point: stop_point)
      vjas['id'] = stop.id
      vjas['new_record'] = true
      stop
    end

    def update_vjas_from_state state
      state.each do |vjas|
        next if vjas["dummy"]
        stop_point = Chouette::StopPoint.find_by(objectid: vjas['stop_point_objectid'])
        stop_area = stop_point&.stop_area
        tz = stop_area&.time_zone
        tz = tz && ActiveSupport::TimeZone[tz]
        utc_offset = tz ? tz.utc_offset : 0

        params = {}

        %w{departure arrival}.each do |part|
          field = "#{part}_time"
          time_of_day = TimeOfDay.new vjas[field]['hour'], vjas[field]['minute'], utc_offset: utc_offset
          params["#{part}_time_of_day".to_sym] = time_of_day
        end
        params[:stop_area_id] = vjas['specific_stop_area_id']
        stop = create_or_find_vjas_from_state(vjas)
        stop.update_attributes(params)
        vjas.delete('errors')
        vjas['errors'] = stop.errors if stop.errors.any?
      end
    end

    def manage_referential_codes_from_state state
      # Delete removed referential_codes
      referential_codes = state["referential_codes"] || []
      defined_codes = referential_codes.map{ |c| c["id"] }
      codes.where.not(id: defined_codes).delete_all

      # Update or create other codes
      referential_codes.each do |code_item|
        ref_code = code_item["id"].present? ? codes.find(code_item["id"]) : codes.build
        ref_code.update_attributes({
          code_space_id: code_item["code_space_id"],
          value: code_item["value"]
        })
      end
    end

    def update_has_and_belongs_to_many_from_state item
      ['time_tables', 'footnotes', 'line_notices'].each do |assos|
        next unless item[assos]

        saved = self.send(assos).map(&:id)

        (saved - item[assos].map{|t| t['id']}).each do |id|
          self.send(assos).delete(self.send(assos).find(id))
        end

        item[assos].each do |t|
          klass = "Chouette::#{assos.classify}".constantize
          unless saved.include?(t['id'])
            self.send(assos) << klass.find(t['id'])
          end
        end
      end
    end

    def self.state_update route, state
      objects = []
      transaction do
        state.each do |item|
          item.delete('errors')
          vj = find_by(objectid: item['objectid']) || state_create_instance(route, item)
          next if item['deletable'] && vj.persisted? && vj.destroy
          objects << vj

          vj.update_vjas_from_state(item['vehicle_journey_at_stops'])
          vj.update_attributes(state_permited_attributes(item))
          vj.update_has_and_belongs_to_many_from_state(item)
          vj.manage_referential_codes_from_state(item)
          vj.update_checksum!
          item['errors']   = vj.errors.full_messages.uniq if vj.errors.any?
          item['checksum'] = vj.checksum
        end

        # Delete ids of new object from state if we had to rollback
        if state.any? {|item| item['errors']}
          state.map do |item|
            item.delete('objectid') if item['new_record']
            item['vehicle_journey_at_stops'].map {|vjas| vjas.delete('id') if vjas['new_record'] }
          end
          raise ::ActiveRecord::Rollback
        end
      end

      # Remove new_record flag && deleted item from state if transaction has been saved
      state.map do |item|
        item.delete('new_record')
        item['vehicle_journey_at_stops'].map {|vjas| vjas.delete('new_record') }
      end
      state.delete_if {|item| item['deletable']}
      objects
    end

    def self.state_create_instance route, item
      # Flag new record, so we can unset object_id if transaction rollback
      vj = route.vehicle_journeys.create(state_permited_attributes(item))
      vj.after_commit_objectid
      item['objectid'] = vj.objectid
      item['short_id'] = vj.get_objectid.short_id
      item['new_record'] = true
      vj
    end

    def self.state_permited_attributes item
      attrs = item.slice(
        'published_journey_identifier',
        'published_journey_name',
        'journey_pattern_id',
        'company_id',
        'ignored_routing_contraint_zone_ids',
        'ignored_stop_area_routing_constraint_ids'
      ).to_hash

      if item['journey_pattern']
        attrs['journey_pattern_id'] = item['journey_pattern']['id']
      end

      attrs['company_id'] = item['company'] ? item['company']['id'] : nil

      attrs["custom_field_values"] = Hash[
        *(item["custom_fields"] || {})
          .map { |k, v| [k, v["value"]] }
          .flatten
      ]
      attrs
    end

    def time_table_tokens=(ids)
      self.time_table_ids = ids.split(",")
    end

    def bounding_dates
      dates = []

      time_tables.each do |tm|
        dates << tm.start_date if tm.start_date
        dates << tm.end_date if tm.end_date
      end

      dates.empty? ? [] : [dates.min, dates.max]
    end

    def fill_passing_times!
      encountered_empty_vjas = []
      previous_stop = nil
      vehicle_journey_at_stops.each do |vjas|
        sp = vjas.stop_point
        if vjas.arrival_time.nil? && vjas.departure_time.nil?
          encountered_empty_vjas << vjas
        else
          if encountered_empty_vjas.any?
            raise "Cannot extrapolate passing times without an initial time" if previous_stop.nil?
            distance_between_known = 0
            distance_from_last_known = 0
            cost = journey_pattern.costs_between previous_stop.stop_point, encountered_empty_vjas.first.stop_point
            raise "MISSING cost between #{previous_stop.stop_point.stop_area.registration_number} AND #{encountered_empty_vjas.first.stop_point.stop_area.registration_number}" unless cost.present?
            distance_between_known += cost[:distance].to_f
            cost = journey_pattern.costs_between encountered_empty_vjas.last.stop_point, sp
            raise "MISSING cost between #{encountered_empty_vjas.last.stop_point.stop_area.registration_number} AND #{sp.stop_area.registration_number}" unless cost.present?
            distance_between_known += cost[:distance].to_f
            distance_between_known += encountered_empty_vjas.each_cons(2).inject(0) do |sum, slice|
              cost = journey_pattern.costs_between slice.first.stop_point, slice.last.stop_point
              raise "MISSING cost between #{slice.first.stop_point.stop_area.registration_number} AND #{slice.last.stop_point.stop_area.registration_number}" unless cost.present?
              sum + cost[:distance].to_f
            end

            previous = previous_stop
            encountered_empty_vjas.each do |empty_vjas|
              cost = journey_pattern.costs_between previous.stop_point, empty_vjas.stop_point
              raise "MISSING cost between #{previous.stop_point.stop_area.registration_number} AND #{empty_vjas.stop_point.stop_area.registration_number}" unless cost.present?
              distance_from_last_known += cost[:distance]

              arrival_time_of_day = vjas.arrival_time_of_day
              previous_time_of_day = previous_stop.departure_time_of_day

              ratio = distance_from_last_known.to_f / distance_between_known.to_f
              delta = arrival_time_of_day-previous_time_of_day

              time_of_day = previous_time_of_day.add(seconds: ratio * delta)

              empty_vjas.update_attribute :arrival_time_of_day, time_of_day
              empty_vjas.update_attribute :departure_time_of_day, time_of_day

              previous = empty_vjas
            end
            encountered_empty_vjas = []
          end
          previous_stop = vjas
        end
      end
    end

    def self.matrix(vehicle_journeys)
      Hash[*VehicleJourneyAtStop.where(vehicle_journey_id: vehicle_journeys.pluck(:id)).map do |vjas|
        [ "#{vjas.vehicle_journey_id}-#{vjas.stop_point_id}", vjas]
      end.flatten]
    end

    def self.with_stops
      self
        .joins(:journey_pattern)
        .joins('
          LEFT JOIN "vehicle_journey_at_stops"
            ON "vehicle_journey_at_stops"."vehicle_journey_id" =
              "vehicle_journeys"."id"
            AND "vehicle_journey_at_stops"."stop_point_id" =
              "journey_patterns"."departure_stop_point_id"
        ')
        .order(Arel.sql('"vehicle_journey_at_stops"."departure_time"'))
    end

    # Requires a SELECT DISTINCT and a join with
    # "vehicle_journey_at_stops".
    #
    # Example:
    #   .select('DISTINCT "vehicle_journeys".*')
    #   .joins('
    #     LEFT JOIN "vehicle_journey_at_stops"
    #       ON "vehicle_journey_at_stops"."vehicle_journey_id" =
    #         "vehicle_journeys"."id"
    #   ')
    #   .where_departure_time_between('08:00', '09:45')
    def self.where_departure_time_between(
      start_time,
      end_time,
      allow_empty: false
    )
      self
        .where(
          %Q(
            "vehicle_journey_at_stops"."departure_time" >= ?
            AND "vehicle_journey_at_stops"."departure_time" <= ?
            #{
              if allow_empty
                'OR "vehicle_journey_at_stops"."id" IS NULL'
              end
            }
          ),
          "2000-01-01 #{start_time}:00 UTC",
          "2000-01-01 #{end_time}:00 UTC"
        )
    end

    def self.without_time_tables
      # Joins the VehicleJourney–TimeTable through table to select only those
      # VehicleJourneys that don't have an associated TimeTable.
      self
        .joins('
          LEFT JOIN "time_tables_vehicle_journeys"
            ON "time_tables_vehicle_journeys"."vehicle_journey_id" =
              "vehicle_journeys"."id"
        ')
        .where('"time_tables_vehicle_journeys"."vehicle_journey_id" IS NULL')
    end

    def trim_period period
      return unless period
      period.period_start = period.range.find{|date| Chouette::TimeTable.day_by_mask period.int_day_types, Chouette::TimeTable::RUBY_WEEKDAYS[date.wday] }
      period.period_end = period.range.reverse_each.find{|date| Chouette::TimeTable.day_by_mask period.int_day_types, Chouette::TimeTable::RUBY_WEEKDAYS[date.wday] }
      period
    end

    def merge_flattened_periods periods
      return [trim_period(periods.last)].compact unless periods.size > 1

      merged = []
      current = periods[0]
      any_day_matching = Proc.new {|period|
        period.range.any? do |date|
          Chouette::TimeTable.day_by_mask period.int_day_types, Chouette::TimeTable::RUBY_WEEKDAYS[date.wday]
        end
      }
      periods[1..-1].each do |period|
        if period.int_day_types == current.int_day_types \
          && (period.period_start - 1.day) <= current.period_end

          current.period_end = period.period_end if period.period_end > current.period_end
        else
          if any_day_matching.call(current)
            merged << trim_period(current)
          end

          current = period
        end
      end
      if any_day_matching.call(current)
        merged << trim_period(current)
      end
      merged
    end

    # Don't use for massive operations. Not optimized !
    def flattened_circulation_periods
      periods = time_tables.map(&:periods).flatten
      out = []
      dates = periods.map {|p| [p.period_start, p.period_end + 1.day]}

      included_dates = Hash[*time_tables.map do |t|
                              t.dates.select(&:in?).map {|d|
                                int_day_types = t.int_day_types
                                int_day_types = int_day_types | 2**(d.date.days_to_week_start + 2)
                                [d.date, int_day_types]
                              }
                            end.flatten]

      excluded_dates = Hash.new { |hash, key| hash[key] = [] }
      time_tables.each do |t|
        t.dates.select(&:out?).each {|d| excluded_dates[d.date] += t.periods.to_a }
      end

      (included_dates.keys + excluded_dates.keys).uniq.each do |d|
        dates << d
        dates << d + 1.day
      end

      dates = dates.flatten.uniq.sort
      dates.each_cons(2) do |from, to|
        to = to - 1.day
        if from == to
          matching = periods.select{|p| p.range.include?(from) }
        else
          # Find the elements that are both in a and b
          matching = periods.select{|p| (from..to) & p.range }
        end
        # Remove the portential excluded service date from the returned matching periods / dates
        matching -= excluded_dates[from] || []
        date_matching = included_dates[from]
        if matching.any? || date_matching
          int_day_types = 0
          matching.each {|p| int_day_types = int_day_types | p.time_table.int_day_types}
          int_day_types = int_day_types | date_matching if date_matching
          out << FlattennedCirculationPeriod.new(from, to, int_day_types)
        end
      end

      merge_flattened_periods out
    end
    alias operating_periods flattened_circulation_periods

    class FlattennedCirculationPeriod
      include ApplicationDaysSupport

      attr_accessor :period_start, :period_end, :int_day_types

      def initialize _start, _end, _days=nil
        @period_start = _start
        @period_end = _end
        @int_day_types = _days
      end

      def range
        (period_start..period_end)
      end

      def weekdays
        ([0]*7).tap{|days| valid_days.each do |v| days[v - 1] = 1 end}.join(',')
      end

      def <=> period
        period_start <=> period.period_start
      end

      def ==(other)
        other.respond_to?(:range) && other.respond_to?(:int_day_types) &&
          range == other.range && int_day_types == other.int_day_types
      end

      def hash
        [ range, int_day_types ].hash
      end
    end

    def self.clean!
      current_scope = self.current_scope || all

      return 0 unless current_scope.present?
      # There are several "DELETE CASCADE" in the schema like:
      #
      # TABLE "vehicle_journey_at_stops" CONSTRAINT "vjas_vj_fkey" FOREIGN KEY (vehicle_journey_id) REFERENCES vehicle_journeys(id) ON DELETE CASCADE
      # TABLE "time_tables_vehicle_journeys" CONSTRAINT "vjtm_vj_fkey" FOREIGN KEY (vehicle_journey_id) REFERENCES vehicle_journeys(id) ON DELETE CASCADE
      #
      # The ruby code makes the expected deletions
      # and the delete cascade will be the fallback

      Chouette::VehicleJourneyAtStop.where(vehicle_journey: current_scope).delete_all
      ReferentialCode.where(resource: current_scope).delete_all

      reflections.values.select do |r|
        r.is_a?(::ActiveRecord::Reflection::HasAndBelongsToManyReflection)
      end.each do |reflection|
        sql = %[
          DELETE FROM #{reflection.join_table}
          WHERE #{reflection.foreign_key} IN (#{current_scope.select(:id).to_sql});
        ]
        connection.execute sql
      end

      delete_all
    end

  end
end