Class: ClosureTree::Support

Inherits:
Object
  • Object
show all
Includes:
ActiveRecordSupport, ArelHelpers, HashTreeSupport, SupportAttributes, SupportFlags
Defined in:
lib/closure_tree/support.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from ArelHelpers

#aliased_table, #build_hierarchy_delete_query, #build_hierarchy_insert_query, #hierarchy_table, #hierarchy_table_for, #model_table, #to_sql_with_connection

Methods included from HashTreeSupport

#build_hash_tree, #default_tree_scope, #hash_tree

Methods included from ActiveRecordSupport

#remove_prefix_and_suffix

Methods included from SupportAttributes

#advisory_lock_name, #advisory_lock_options, #dont_order_roots, #hierarchy_class_name, #name_column, #name_sym, #nulls_last_order_by, #order_by, #order_by_order, #order_column, #order_column_sym, #parent_column_name, #parent_column_sym, #primary_key_column, #primary_key_type, #quoted_hierarchy_table_name, #quoted_id_column_name, #quoted_name_column, #quoted_order_column, #quoted_parent_column_name, #quoted_table_name, #quoted_value, #require_order_column, #short_hierarchy_class_name, #t_alias_keyword

Methods included from SupportFlags

#has_inheritance_column?, #has_name?, #order_is_numeric?, #order_option?, #subclass?

Constructor Details

#initialize(model_class, options) ⇒ Support

Returns a new instance of Support.

Raises:

  • (ArgumentError)


14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/closure_tree/support.rb', line 14

def initialize(model_class, options)
  @model_class = model_class

  @options = {
    parent_column_name: 'parent_id',
    dependent: :nullify, # or :destroy, :delete_all, or :adopt -- see the README
    name_column: 'name',
    with_advisory_lock: true, # This will be overridden by adapter support
    advisory_lock_timeout_seconds: nil,
    numeric_order: false
  }.merge(options)
  raise ArgumentError, "name_column can't be 'path'" if options[:name_column] == 'path'

  if options[:scope]
    scope_option = options[:scope]
    unless scope_option.is_a?(Symbol) || (scope_option.is_a?(Array) && scope_option.all? { |item| item.is_a?(Symbol) })
      raise ArgumentError, "scope option must be a Symbol or an Array of Symbols (e.g., :user_id or [:user_id, :group_id])"
    end
  end

  if !@options[:with_advisory_lock] && @options[:advisory_lock_timeout_seconds].present?
    raise ArgumentError, "advisory_lock_timeout_seconds can't be specified when advisory_lock is disabled"
  end

  return unless order_is_numeric?

  extend NumericOrderSupport.adapter_for_connection(connection)
end

Instance Attribute Details

#model_classObject (readonly)

Returns the value of attribute model_class.



12
13
14
# File 'lib/closure_tree/support.rb', line 12

def model_class
  @model_class
end

#optionsObject (readonly)

Returns the value of attribute options.



12
13
14
# File 'lib/closure_tree/support.rb', line 12

def options
  @options
end

Instance Method Details

#abstract_base_classObject

Find the abstract base class for database connection This ensures hierarchy class uses the same database but doesn’t inherit validations/callbacks from STI parent classes (issue #392)



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/closure_tree/support.rb', line 46

def abstract_base_class
  klass = model_class
  while klass.superclass != ActiveRecord::Base
    parent = klass.superclass
    # Stop at abstract class (ApplicationRecord, SecondaryRecord, etc.)
    return parent if parent.abstract_class?
    # Stop at connection boundary (handles non-abstract parents with custom connections)
    return parent if parent.connection_specification_name != parent.superclass.connection_specification_name
    klass = parent
  end
  ActiveRecord::Base
end

#apply_scope_conditions(scope, instance = nil) ⇒ Object



299
300
301
302
303
304
# File 'lib/closure_tree/support.rb', line 299

def apply_scope_conditions(scope, instance = nil)
  return scope unless options[:scope] && instance

  scope_values = scope_values_from_instance(instance)
  scope_values.any? ? scope.where(scope_values) : scope
end

#build_ancestry_attr_path(path, attributes) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
# File 'lib/closure_tree/support.rb', line 171

def build_ancestry_attr_path(path, attributes)
  path = path.is_a?(Array) ? path.dup : [path]
  unless path.first.is_a?(Hash)
    if subclass? && has_inheritance_column?
      attributes = attributes.with_indifferent_access
      attributes[inheritance_column] ||= sti_name
    end
    path = path.map { |ea| attributes.merge(name_column => ea) }
  end
  path
end

#build_scope_where_clause(scope_conditions) ⇒ Object

Builds SQL WHERE conditions for scope columns Returns a string that can be appended to a WHERE clause



146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/closure_tree/support.rb', line 146

def build_scope_where_clause(scope_conditions)
  return '' unless scope_conditions.is_a?(Hash) && scope_conditions.any?

  conditions = scope_conditions.map do |column, value|
    if value.nil?
      "#{connection.quote_column_name(column.to_s)} IS NULL"
    else
      "#{connection.quote_column_name(column.to_s)} = #{quoted_value(value)}"
    end
  end

  " AND #{conditions.join(' AND ')}"
end

#create(model_class, attributes) ⇒ Object



215
216
217
# File 'lib/closure_tree/support.rb', line 215

def create(model_class, attributes)
  creator_class(model_class, attributes.with_indifferent_access[inheritance_column]).new(attributes)
end

#create!(model_class, attributes) ⇒ Object



219
220
221
# File 'lib/closure_tree/support.rb', line 219

def create!(model_class, attributes)
  create(model_class, attributes).tap(&:save!)
end

#creator_class(model_class, sti_class) ⇒ Object



207
208
209
210
211
212
213
# File 'lib/closure_tree/support.rb', line 207

def creator_class(model_class, sti_class)
  if sti_class.present?
    base_class.send(:find_sti_class, sti_class)
  else
    model_class
  end
end

#find_by_large_path(path, attributes = {}, parent_id = nil) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
# File 'lib/closure_tree/support.rb', line 195

def find_by_large_path(path, attributes = {}, parent_id = nil)
  next_parent_id = parent_id
  child = nil
  path.in_groups(max_join_tables, false).each do |subpath|
    child = model_class.find_by_path(subpath, attributes, next_parent_id)
    return nil if child.nil?

    next_parent_id = child._ct_id
  end
  child
end

#has_many_order_with_option(order_by_opt = nil) ⇒ Object



124
125
126
127
128
129
130
# File 'lib/closure_tree/support.rb', line 124

def has_many_order_with_option(order_by_opt = nil)
  order_options = [order_by_opt, order_by].compact
  [lambda {
    order_options = order_options.map { |o| o.is_a?(Proc) ? o.call : o }
    order(order_options)
  }]
end

#has_many_order_without_option(order_by_opt) ⇒ Object

lambda-ize the order, but don’t apply the default order_option



120
121
122
# File 'lib/closure_tree/support.rb', line 120

def has_many_order_without_option(order_by_opt)
  [-> { order(order_by_opt.call) }]
end

#hierarchy_class_for_modelObject



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/closure_tree/support.rb', line 59

def hierarchy_class_for_model
  parent_class = model_class.module_parent
  hierarchy_class = parent_class.const_set(short_hierarchy_class_name, Class.new(abstract_base_class))
  model_class_name = model_class.to_s
  hierarchy_class.class_eval do
    # Rails 8.1+ requires an implicit_order_column for models without a primary key
    self.implicit_order_column = 'ancestor_id'

    # Rails uses the primary key to correctly match associations when using a join to preload (e.g. via `eager_load`).
    # The migration generator adds a unique index across these three columns so this is safe.
    self.primary_key = [:ancestor_id, :descendant_id, :generations]

    belongs_to :ancestor, class_name: model_class_name
    belongs_to :descendant, class_name: model_class_name
    def ==(other)
      self.class == other.class && ancestor_id == other.ancestor_id && descendant_id == other.descendant_id
    end
    alias :eql? :==
    def hash
      (ancestor_id.hash << 31) ^ descendant_id.hash
    end
  end
  hierarchy_class.table_name = hierarchy_table_name
  hierarchy_class
end

#hierarchy_table_nameObject



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/closure_tree/support.rb', line 85

def hierarchy_table_name
  # We need to use the table_name, not something like ct_class.to_s.demodulize + "_hierarchies",
  # because they may have overridden the table name, which is what we want to be consistent with
  # in order for the schema to make sense.
  if options[:hierarchy_table_name]
    tablename = options[:hierarchy_table_name]
  else
    base_table = remove_prefix_and_suffix(table_name, model_class)

    # Handle PostgreSQL schema-qualified table names (e.g., "my_schema.table_name")
    schema, _, table = base_table.rpartition('.')
    if schema.present?
      tablename = "#{schema}.#{table.singularize}_hierarchies"
    else
      tablename = "#{table.singularize}_hierarchies"
    end
  end

  [model_class.table_name_prefix, tablename, model_class.table_name_suffix].join
end

#ids_from(scope) ⇒ Object



132
133
134
# File 'lib/closure_tree/support.rb', line 132

def ids_from(scope)
  scope.pluck(model_class.primary_key)
end

#max_join_tablesObject



190
191
192
193
# File 'lib/closure_tree/support.rb', line 190

def max_join_tables
  # MySQL doesn't support more than 61 joined tables (!!):
  50
end

#previous_scope_values_from_instance(instance) ⇒ Object



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/closure_tree/support.rb', line 260

def previous_scope_values_from_instance(instance)
  return {} unless options[:scope] && instance

  scope_option = options[:scope]
  scope_hash = {}

  case scope_option
  when Symbol
    value = instance.attribute_before_last_save(scope_option)
    scope_hash[scope_option] = value
  when Array
    scope_option.each do |item|
      if item.is_a?(Symbol)
        value = instance.attribute_before_last_save(item)
        scope_hash[item] = value
      end
    end
  end

  scope_hash
end

#scope_changed?(instance) ⇒ Boolean

Returns:

  • (Boolean)


282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/closure_tree/support.rb', line 282

def scope_changed?(instance)
  return false unless options[:scope] && instance

  scope_option = options[:scope]

  case scope_option
  when Symbol
    instance.saved_change_to_attribute?(scope_option)
  when Array
    scope_option.any? do |item|
      item.is_a?(Symbol) && instance.saved_change_to_attribute?(item)
    end
  else
    false
  end
end

#scope_columnsObject



223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/closure_tree/support.rb', line 223

def scope_columns
  return [] unless options[:scope]

  scope_option = options[:scope]

  case scope_option
  when Symbol
    [scope_option]
  when Array
    scope_option.select { |item| item.is_a?(Symbol) }
  else
    []
  end
end

#scope_values_from_instance(instance) ⇒ Object



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/closure_tree/support.rb', line 238

def scope_values_from_instance(instance)
  return {} unless options[:scope] && instance

  scope_option = options[:scope]
  scope_hash = {}

  case scope_option
  when Symbol
    value = instance.read_attribute(scope_option)
    scope_hash[scope_option] = value
  when Array
    scope_option.each do |item|
      if item.is_a?(Symbol)
        value = instance.read_attribute(item)
        scope_hash[item] = value
      end
    end
  end

  scope_hash
end

#scope_with_order(scope, additional_order_by = nil) ⇒ Object



111
112
113
114
115
116
117
# File 'lib/closure_tree/support.rb', line 111

def scope_with_order(scope, additional_order_by = nil)
  if order_option?
    scope.order(*[additional_order_by, order_by].compact)
  else
    additional_order_by ? scope.order(additional_order_by) : scope
  end
end

#scoped_attributes(scope, attributes, target_table = model_class.table_name) ⇒ Object



183
184
185
186
187
188
# File 'lib/closure_tree/support.rb', line 183

def scoped_attributes(scope, attributes, target_table = model_class.table_name)
  table_prefixed_attributes = attributes.transform_keys do |column_name|
    "#{target_table}.#{column_name}"
  end
  scope.where(table_prefixed_attributes)
end

#where_eq(column_name, value) ⇒ Object



136
137
138
139
140
141
142
# File 'lib/closure_tree/support.rb', line 136

def where_eq(column_name, value)
  if value.nil?
    "#{connection.quote_column_name(column_name)} IS NULL"
  else
    "#{connection.quote_column_name(column_name)} = #{quoted_value(value)}"
  end
end

#with_advisory_lock(&block) ⇒ Object



160
161
162
163
164
165
166
167
168
169
# File 'lib/closure_tree/support.rb', line 160

def with_advisory_lock(&block)
  lock_method = options[:advisory_lock_timeout_seconds].present? ? :with_advisory_lock! : :with_advisory_lock
  if options[:with_advisory_lock] && connection.supports_advisory_locks? && model_class.respond_to?(lock_method)
    model_class.public_send(lock_method, advisory_lock_name, advisory_lock_options) do
      transaction(&block)
    end
  else
    yield
  end
end

#with_order_option(opts) ⇒ Object



106
107
108
109
# File 'lib/closure_tree/support.rb', line 106

def with_order_option(opts)
  opts[:order] = [opts[:order], order_by].compact.join(',') if order_option?
  opts
end