CLOSER-Cohorts/archivist

View on GitHub
app/models/control_construct.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# frozen_string_literal: true

# The ControlConstruct model is used to hold the positional information
# for all five of the construct models. Every construct must have one
# ControlConstruct and vice-versa.
#
# This is the abstract class that all five of the control constructs are
# eventually derived from.
#
# === Properties
# * Label
# * Position
# * Parent
# * Branch
class ControlConstruct < ApplicationRecord
  self.abstract_class = true

  # Control constructs are comparable to allow sorting into positional order
  include Comparable

  # This model is exportable as DDI
  include Exportable

  # Before creating, populate position fields
  before_create :pre_create_position_prep

  # All categories must belong to an {Instrument}
  belongs_to :instrument, touch: true

  # Each control construct must have one parent, except the top-sequence which has no parent
  belongs_to :parent, polymorphic: true

  # After creating or updating, clear the cache from Redis
  after_save :clear_cache

  # After destroying, clear the cache from Redis
  after_destroy :clear_cache

  # Ensure there are no circular references in the control construct tree
  validate :no_circular_references

  # Recursive search through the parent tree and finds and returns the nearest
  # parent of the target_class
  def find_nearest_parent(target_class)
    columns = ['id', 'parent_id', 'construct_id', 'construct_type']
    columns_joined = columns.join(',')
    sql =
      <<-SQL
        WITH RECURSIVE control_constructs_tree (#{columns_joined}, level)
        AS (
          SELECT
            #{columns_joined},
            0
          FROM control_constructs
          WHERE construct_id = #{id}
          AND construct_type = '#{self.class.name}'

          UNION ALL
          SELECT
            #{columns.map { |col| 'cat.' + col }.join(',')},
            ct.level + 1
          FROM control_constructs cat, control_constructs_tree ct
          WHERE cat.id = ct.parent_id
        )
        SELECT #{target_class.table_name}.*
        FROM control_constructs_tree
        INNER JOIN #{target_class.table_name} ON #{target_class.table_name}.id = control_constructs_tree.construct_id
        WHERE level > 0
        AND construct_type = '#{target_class.name}'
        ORDER BY level, control_constructs_tree.id, construct_id, construct_type
        LIMIT 1;
      SQL
    target_class.find_by_sql(sql.chomp).first
  end

  # Clears the Redis cache of construct positional information
  def clear_cache
    begin
      unless self.parent_id.nil?
        if self.parent_type == 'CcCondition'
          $redis.ping
          # puts 'Connecting to Redis...'
          # puts "Id: #{self.id}"
          # puts "Parent_id: #{self.parent_id}"
          # puts "Parent_type: #{self.parent_type}"
          $redis.hdel 'construct_children:CcCondition:0', self.parent_id
          $redis.hdel 'construct_children:CcCondition:1', self.parent_id
        else
          $redis.ping
          # puts 'Connecting to Redis...'
          # puts "Id: #{self.id}"
          # puts "Parent_id: #{self.parent_id}"
          # puts "Parent_type: #{self.parent_type}"
          $redis.hdel "construct_children:#{self.parent_type}", self.parent_id
        end
      end
      $redis.hdel 'parents', self.id
      $redis.hdel 'is_top', self.id
   rescue => err
     Rails.logger.warn "Cannot connect to Redis [Control Construct] -> Error: '#{err}'"
   end
  end

  def self.find_circular_references(instrument_id, construct_type)
    sql = <<-SQL
      WITH RECURSIVE ancestry_check AS (
        SELECT
          id,
          label,
          parent_id,
          construct_id,
          construct_type,
          instrument_id,
          ARRAY[id] AS ancestry,
          false AS has_cycle
        FROM
          control_constructs
        WHERE
          instrument_id = $1

        UNION ALL

        SELECT
          cc.id,
          cc.label,
          cc.parent_id,
          cc.construct_id,
          cc.construct_type,
          cc.instrument_id,
          array_append(ac.ancestry, cc.id) AS ancestry,
          cc.id = ANY(ac.ancestry) AS has_cycle
        FROM
          control_constructs cc
        JOIN
          ancestry_check ac ON cc.id = ac.parent_id
        WHERE
          NOT ac.has_cycle
          AND cc.instrument_id = $1
      )

      SELECT DISTINCT construct_id
      FROM ancestry_check
      WHERE has_cycle
      AND construct_type = $2;
    SQL

    result = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [[nil, instrument_id], [nil, construct_type]]).to_a
    result.map { |record| record['construct_id'] }
  end  

  def no_circular_references
    if parent_id.present?
      sql = <<-SQL
        WITH RECURSIVE ancestry_check AS (
          SELECT
            id,
            label,
            parent_id,
            instrument_id,
            ARRAY[id] AS ancestry,
            false AS has_cycle
          FROM
            control_constructs
          WHERE
            id = $1
            AND instrument_id = $2
  
          UNION ALL
  
          SELECT
            cc.id,
            cc.label,
            cc.parent_id,
            cc.instrument_id,
            array_append(ac.ancestry, cc.id) AS ancestry,
            cc.id = ANY(ac.ancestry) AS has_cycle
          FROM
            control_constructs cc
          JOIN
            ancestry_check ac ON cc.id = ac.parent_id
          WHERE
            NOT ac.has_cycle
            AND cc.instrument_id = $2
        )
  
        SELECT DISTINCT id, label, ancestry
        FROM ancestry_check
        WHERE has_cycle;
      SQL
  
      circular_references = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [[nil, id], [nil, instrument_id]]).to_a      
  
      if circular_references.present?
        errors.add(:parent_id, "cannot create a circular reference")
      end
    end
  end
  
  private # Private methods

  def pre_create_position_prep
    self.position = parent&.last_child&.position.to_i + 1 if self.position.nil?
    self.branch = 0 if self.branch.nil?
  end
end