3scale/porta

View on GitHub
lib/three_scale/tenant_id_integrity_checker.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module ThreeScale
  class TenantIDIntegrityChecker
    attr_reader :tenant_id

    def initialize(attribute = :tenant_id)
      @attribute = attribute
    end

    def check
      processed = []

      models_with_tenant_id.inject([]) do |inconsistent_found, model|
        Rails.logger.info "Tenant integrity of #{model}"
        inconsistent_found.concat associated_inconsistent_pairs(model, processed: processed)
      end
    end

    private

    def associated_inconsistent_pairs(model, processed: [])
      model.reflect_on_all_associations.inject([]) do |inconsistent_found, association|
        next inconsistent_found if can_skip_asociation?(association, processed: processed)

        processed << association

        inconsistent_found.concat inconsistent_pairs_for(model, association)
      end
    end

    def inconsistent_pairs_for(model, association)
      table = model.arel_table.name
      table_alias = last_table_alias_from_sql(model.joins(association.name).to_sql)
      found = model.joins(association.name).where.not("#{table_alias}.tenant_id = #{table}.tenant_id")
      found = found.merge(Account.where(provider: false).or(Account.where(provider: nil))) if model == Account && association.name == :provider_account
      found = pluck_pks(found, association: association, table: table, assoc_table: table_alias)
      found.map { ["#{model}#{_1}", association.name, "#{association.klass}#{_2}"] }
    end

    def pluck_pks(joined_relation, association:, assoc_table:, table:)
      model_pk = pk_fields association.active_record
      assoc_pk = pk_fields association.klass
      res = joined_relation.reorder('').pluck(*model_pk.map{"#{table}.#{_1}"}, *assoc_pk.map{"#{assoc_table}.#{_1}"})
      res.map { [_1.slice(0, model_pk.size), _1.slice(model_pk.size..-1)] }
    end

    def pk_fields(model)
      # in oracle-enhanced model.connection.schema_cache.primary_keys returns nil for composite so can't use the cache
      model.primary_key ? Array(model.primary_key) : model.connection.primary_keys(model.table_name)
    end

    def can_skip_asociation?(association, processed: [])
      # we can ignore these as they can't be automatically excluded but are redundant for the check anyway
      ignored = {
        Service => %i[all_metrics], # all metrics of service and APIs used by service so is redundant
        # only master has provider_accounts and it is normal that all will mismatch
        # bought_* are redundant with contracts
        # email_templates is redundant with templates
        Account => %i[provider_accounts bought_account_contract bought_cinstances bought_service_contracts email_templates],
        Cinstance => %i[plan], # this is redundant with Contract.plan but overrides it so is not auto-detected
        ApplicationPlan => %i[cinstances], # same as Cinstance.plan, this is covered by Plan.contracts
      }
      model = association.active_record

      return true if ignored[model]&.include?(association.name)

      # we live in a perfect world where all associations have an inverse so we can skip polymorphic ones
      return true if association.polymorphic?

      # arity can be one when association has a scope defined with a proc taking current object as argument
      # We can't handle such associations but we can ignore them if the inverse one we can handle
      if association.scope&.arity&.public_send(:>, 0)
        return true unless association.inverse_of.polymorphic? || association.inverse_of.scope&.arity&.public_send(:>, 0)
        raise "we can't handle #{association.name} of #{model}"
      end

      return true unless association.klass.attribute_names.include?("tenant_id")

      # skip indirect associations where the "through association" has tenant_id, because we will check that
      #   indirect association through the "through association" later (or we did already)
      return true if association.through_reflection&.try(:klass)&.attribute_names&.include?("tenant_id")

      processed.any? {_1 == association || _1 == association.inverse_of }
    end

    def last_table_alias_from_sql(sql)
      matcher = /.*INNER JOIN [`'"]([\S]+)[`'"] (?:[`'"]([\S]+)[`'"] )?ON/i.match(sql)
      matcher[2] || matcher[1]
    end

    def models_with_tenant_id
      Rails.autoloaders.main.eager_load_dir("#{Rails.root}/app/models")
      all_models = ApplicationRecord.descendants.select(&:arel_table).reject(&:abstract_class?)
      all_models.select! { _1.attribute_names.include? "tenant_id" }
    end
  end
end