Class: Flattener::Base

Inherits:
Object
  • Object
show all
Includes:
ActiveModel::Model
Defined in:
lib/has_helpers/flattener/base.rb

Constant Summary collapse

EXCLUDE_COLUMNS =

e.g. :dw, :api, :importers

["delta", "express_add", "photo_updated_at", "photo_file_name", "photo_content_type", "photo_file_size"]
CONSTANT_COLUMNS =
["abbreviation", "id", "name"]

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.associationsObject



107
108
109
# File 'lib/has_helpers/flattener/base.rb', line 107

def associations
  model_associations.reject(&:removed)
end

.columnsObject



254
255
256
257
258
# File 'lib/has_helpers/flattener/base.rb', line 254

def columns
  @columns ||= model_columns.
    lazy.
    select { |sql_column| include_column?(sql_column) }
end

.default_forObject



266
267
268
# File 'lib/has_helpers/flattener/base.rb', line 266

def default_for
  @default_for ||= {}
end

.is_pivotObject Also known as: is_pivot?

Returns the value of attribute is_pivot.



97
98
99
# File 'lib/has_helpers/flattener/base.rb', line 97

def is_pivot
  @is_pivot
end

.model_klassObject



100
101
102
103
104
105
# File 'lib/has_helpers/flattener/base.rb', line 100

def model_klass
  @model_klass ||= begin
                     klass = singleton_class? ? superclass : self # Singleton classes are not named, but in this case we really want the name of its superclass.
                     klass.name.gsub("Flatteners::", "").constantize
                   end
end

.pivot_associationsObject (readonly)

Returns the value of attribute pivot_associations.



98
99
100
# File 'lib/has_helpers/flattener/base.rb', line 98

def pivot_associations
  @pivot_associations
end

.pivot_columnsObject (readonly)

Returns the value of attribute pivot_columns.



98
99
100
# File 'lib/has_helpers/flattener/base.rb', line 98

def pivot_columns
  @pivot_columns
end

.pivot_countObject (readonly)

Returns the value of attribute pivot_count.



98
99
100
# File 'lib/has_helpers/flattener/base.rb', line 98

def pivot_count
  @pivot_count
end

Instance Attribute Details

#include_forObject



55
56
57
# File 'lib/has_helpers/flattener/base.rb', line 55

def include_for
  @include_for ||= Flattener::Base.default_for.keys
end

Class Method Details

.add(*args, **options, &block) ⇒ Object

Add/update an association to to be included when using a flattener.

Examples

Flattener.for(Advisor).add :bank_accounts, for: [:dw, api] the 'for:' argument will default to all of the options available

class Flatteners::Advisor < Flattener::Base add :errors_and_omissions end



324
325
326
327
328
329
330
331
# File 'lib/has_helpers/flattener/base.rb', line 324

def self.add(*args, **options, &block)
  args.each do |name|
    if (association = find_association(name))
      association.update(**options, for: ::Flattener::Base.default_for.lazy.map { |k| k.first }, removed: false)
      association.flattener = Flattener.for(association.klass).new.tap { |f| f.instance_eval(&block) } if block.present?
    end
  end
end

.add_column(name, sql_type, **options) ⇒ Object

Add a custom column to the flattener

Example

Flattener.for(Advisor).add_column :amount, "numeric(20,5)" Flattener.for(Advisor).add_column :name, "text"

HACK Does this really belong inside of the flattener?



341
342
343
344
# File 'lib/has_helpers/flattener/base.rb', line 341

def self.add_column(name, sql_type, **options)
  sql_column = Flattener::Column.build_sql_column(name, sql_type)
  model_columns << Flattener::Column.new(sql_column: sql_column, klass: model_klass, association: find_column_association(sql_column.name), **options)
end

.belongs_to(name, scope = nil, **options) ⇒ Object

HACK Does this really belong inside of the flattener?



112
113
114
115
116
117
118
# File 'lib/has_helpers/flattener/base.rb', line 112

def belongs_to(name, scope = nil, **options)
  ar_association = ::ActiveRecord::Associations::Builder::BelongsTo.create_reflection(model_klass, name, scope, options)
  Flattener::Association.new(ar_association).tap do |association|
    model_associations << association
    add_column ar_association.foreign_key, "integer"
  end
end

.bypass_constant_column_removals?(bypass = false) ⇒ Boolean

When including HasHelpers::Constant to a class this removes all columns that are not from the constants relation. This will bypass that validation if true including other columns

Returns:

  • (Boolean)


276
277
278
# File 'lib/has_helpers/flattener/base.rb', line 276

def bypass_constant_column_removals?(bypass = false)
  @bypass_constant_column_removals ||= bypass
end

.exclude_columns(*column_names) ⇒ Object



260
261
262
263
264
# File 'lib/has_helpers/flattener/base.rb', line 260

def exclude_columns(*column_names)
  (@exclude_columns ||= []).tap do |exclude_columns|
    exclude_columns.concat column_names if column_names
  end
end

.find_association(name) ⇒ Object



413
414
415
# File 'lib/has_helpers/flattener/base.rb', line 413

def self.find_association(name)
  model_associations.detect { |a| a.ar_association_name == name }
end

.find_column_association(foreign_key) ⇒ Object



417
418
419
# File 'lib/has_helpers/flattener/base.rb', line 417

def self.find_column_association(foreign_key)
  model_associations.detect { |a| a.macro != :has_many && a.ar_association.foreign_key.to_s == foreign_key.to_s }
end

.flatten(trait: nil) ⇒ Object

rubocop:disable Lint/IneffectiveAccessModifier



307
308
309
310
311
# File 'lib/has_helpers/flattener/base.rb', line 307

def self.flatten(trait: nil)
  new.tap do |flattener|
    flattener.instance_eval(&traits[trait]) if traits[trait]
  end.flatten
end

.has_many(name, scope = nil, **options) ⇒ Object

HACK Does this really belong inside of the flattener?



121
122
123
124
125
126
# File 'lib/has_helpers/flattener/base.rb', line 121

def has_many(name, scope = nil, **options)
  ar_association = ::ActiveRecord::Associations::Builder::HasMany.create_reflection(model_klass, name, scope, options)
  Flattener::Association.new(ar_association).tap do |association|
    model_associations << association
  end
end

.include_column?(column) ⇒ Boolean

Returns:

  • (Boolean)


270
271
272
# File 'lib/has_helpers/flattener/base.rb', line 270

def include_column?(column)
  EXCLUDE_COLUMNS.exclude?(column.name) && exclude_columns.exclude?(column.name.to_sym) && (!is_constant?(model_klass) || bypass_constant_column_removals? || CONSTANT_COLUMNS.include?(column.name))
end

.is_constant?(klass = model_klass) ⇒ Boolean

Returns:

  • (Boolean)


280
281
282
# File 'lib/has_helpers/flattener/base.rb', line 280

def is_constant?(klass = model_klass)
  klass.include?(HasHelpers::Constant)
end

.join(name, **options, &block) ⇒ Object

Adds/updates an association, usually on a join table, aliases the foreign key of the added association to :id, and aliases the primary key to :link_id.

This is for the benefit of the end user not having to understand the database relationships

Example

class Flatteners::BankAccountOwner < Flattener::Base alias :bank_account join :bank_account end

In this example, the end result of flattening will produce:

- :bank_account_owner aliased to :bank_account

- an attribute bank_account_id, which is the bank_account_owner's :bank_account_id

- an attribue bank_account_link_id, which is the bank_account_owner's :id



378
379
380
381
382
383
384
385
# File 'lib/has_helpers/flattener/base.rb', line 378

def self.join(name, **options, &block)
  if add(name, always_include: true, merge_into_parent: true, **options, &block)
    # rubocop:disable Rails/SkipsModelValidations
    update_column(name.to_s.foreign_key, alias_name: :id)
    update_column(model_klass.primary_key, alias_name: :link_id)
    # rubocop:enable Rails/SkipsModelValidations
  end
end

.model_associationsObject



284
285
286
# File 'lib/has_helpers/flattener/base.rb', line 284

def model_associations
  @model_associations ||= model_klass.reflect_on_all_associations.map { |a| Flattener::Association.new(a) }
end

.model_columnsObject



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/has_helpers/flattener/base.rb', line 288

def model_columns
  @model_columns ||= begin
                       # Flatteners are loaded before running migrations
                       # Only load columns if the table exists
                       connection = ::ActiveRecord::Base.connection
                       table_name = model_klass.table_name

                       model_klass_columns =
                         if connection.table_exists?(table_name) || connection.view_exists?(table_name)
                           model_klass.columns
                         else
                           []
                         end
                       model_klass_columns.map { |sql_column| Flattener::Column.new(sql_column: sql_column, klass: model_klass, association: find_column_association(sql_column.name)) }
                     end
end

.pivot!(count: 1, expand_on: nil, expand_over: [], excluded: [/id\z/, "created_at"], included: [], is_lookup: nil, &block) ⇒ Object

Defines the pivot for the current flattener. HACK: See notes for belongs_to *

Options

:count The number of pivots (sets of fields) to generate. Defaults to 1. :expand_on Collection or proc returning the names on which to expand. A proc may accept one argument which is the class of the parent flattener. :is_lookup Set this to true if the pivot table requires a look-up.

Examples

pivot!(count: 3) pivot!(count: 5, expand_on: %w[home office other]) pivot!( count: 5, expand_on: -> (klass) { HasContactInfo::AddressLocation.scoped_to(klass)::ALL.map(&:name) } ) pivot!(count: 5, is_lookup: true)



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/has_helpers/flattener/base.rb', line 142

def pivot!(
  count: 1,
  expand_on: nil,
  expand_over: [],
  excluded: [/id\z/, "created_at"],
  included: [], # Overrides excluded values
  is_lookup: nil,
  &block
)
  self.is_pivot = true
  @pivot_count = count
  @pivot_expand_on = expand_on
  @pivot_is_lookup = is_lookup

  if block
    # FIXME: This is too hacky. We need to find an alternative way to define pivots *
    # Get a clone of the current flattener for use in evaluating the
    # given block. This allow the pivot associations and columns to be
    # determined while preserving the columns and associtions on the
    # non-pivoted flattener.
    instance = clone
    if @model_associations.present?
      instance.instance_eval do
        @model_associations = @model_associations.map(&:clone)
      end
    end
    instance.model_klass = model_klass # model_klass may otherwise fail since the instance class has no name.
  else
    instance = self
  end

  ## Determine which associations to use for the pivot
  # Remove associations except for constants (default behavior, perhaps there is something better)
  instance.remove(
    *(associations.reject(&:is_constant?)).map(&:name)
  )
  # Eval the block after removing associations so that changes
  # made in the block remain in effect.
  instance.instance_eval(&block) if block
  @pivot_associations = instance.associations.dup

  # Determine the pivot columns
  @pivot_columns = instance.columns.lazy.
    reject { |col| excluded.any? { |ex| ex.match(col.name) } }.
    to_a # Must force to an array to avoid an infinite loop when adding columns and otherwise incorrect results.

  # Remove columns
  exclude_columns(
    *@pivot_columns.lazy.
      reject do |col|
      # Do not exclude any columns which have been explicitly included.
      included.any? { |inc| inc.match(col.name) }
    end.
      map(&:name).
      map(&:to_sym)
  )

  @pivot_columns.concat(
    instance.send(:model_columns).
      select do |col|
      included.any? { |inc| inc.match(col.name) }
    end.
      to_a
  )

  # Add expansions
  expansions = Array(expand_over)
  expansions << nil if expansions.none? # Add a blank entry which provides a value to iterate over.

  expansions.each do |expansion|
    expansion = "#{ expansion.underscore }_" if expansion
    (1..count).each do |idx|
      # add expanded columns
      @pivot_columns.each do |col|
        add_column("#{ expansion }#{ col.name.to_sym }_#{ idx }", col.sql_type)
      end

      # add expanded associations
      @pivot_associations.each do |assoc|
        add_column("#{ expansion }#{ assoc.foreign_key }_#{ idx }", "integer")
        add_column("#{ expansion }#{ assoc.foreign_key.to_s.remove(/_id\z/) }_#{ idx }", "character varying")
      end
    end
  end
end

.pivot_expand_on(klass = nil) ⇒ Object



246
247
248
249
250
251
252
# File 'lib/has_helpers/flattener/base.rb', line 246

def pivot_expand_on(klass = nil)
  if @pivot_expand_on.respond_to?(:call)
    @pivot_expand_on.call(*klass)
  else
    Array(@pivot_expand_on)
  end
end

.pivot_or_selfObject

If the current flattener is a pivot then this method returns a class which generates only the pivots attributes and associations. Otherwise, it returns the current flattener class.



231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/has_helpers/flattener/base.rb', line 231

def pivot_or_self
  if is_pivot
    # Return a duplicate of the current flattener class with the
    # pivot additions mixed in.
    self.dup.tap do |klass|
      klass.prepend(::Flattener::Pivot)
      klass.is_lookup = @pivot_is_lookup
    end
  else
    self
  end
end

.remove(*args) ⇒ Object

Remove a association(s) from use by the flattener

Example

Flattener.for(Advisor).remove :bank_accounts, :errors_and_omissions



352
353
354
355
356
357
358
# File 'lib/has_helpers/flattener/base.rb', line 352

def self.remove(*args)
  args.each do |name|
    if (association = find_association(name))
      association.remove
    end
  end
end

.trait(trait_name, &block) ⇒ Object

Traits allow you to name groups of flattener settings inside of a block.

Example

class Flatteners::Advisor < Flattener::Base trait :dw do exclude_columns :organization_id remove :adjustments end end

Flattener.for(Advisor).flatten(trait: :dw)



400
401
402
# File 'lib/has_helpers/flattener/base.rb', line 400

def self.trait(trait_name, &block)
  traits[trait_name] = block
end

.update_column(name, **options) ⇒ Object

Raises:

  • (ArgumentError)


404
405
406
407
408
409
410
411
# File 'lib/has_helpers/flattener/base.rb', line 404

def self.update_column(name, **options)
  raise ArgumentError, "nil is not a valid column" if name.nil?
  begin
    columns.detect { |column| column.name.to_s == name.to_s }.update(options)
  rescue NoMethodError
    raise "No column `#{name}` on #{self.name}" if ::HasUtils::Env.development?
  end
end

Instance Method Details

#add(*names, **options, &block) ⇒ Object



17
18
19
20
21
22
23
24
# File 'lib/has_helpers/flattener/base.rb', line 17

def add(*names, **options, &block)
  names.each do |name|
    association = associations.detect { |a| a.ar_association.name == name }
    associations << association = self.class.find_association(name).dup unless association
    association.update(**options, for: Flattener::Base.default_for.keys)
    association.flattener = Flattener.for(association.klass).new.tap { |f| f.instance_eval(&block) } if block.present?
  end
end

#add_column(name, sql_type, **options) ⇒ Object

HACK Does this really belong inside of the flattener?



27
28
29
30
# File 'lib/has_helpers/flattener/base.rb', line 27

def add_column(name, sql_type, **options)
  sql_column = Flattener::Column.build_sql_column(name, sql_type)
  model_columns << Flattener::Column.new(sql_column: sql_column, klass: self.class.model_klass, association: self.class.find_column_association(sql_column.name), **options)
end

#associationsObject



13
14
15
# File 'lib/has_helpers/flattener/base.rb', line 13

def associations
  @associations ||= self.class.associations.select { |a| (a.for.to_a & include_for).present? }.map(&:dup)
end

#belongs_to(*args) ⇒ Object



84
85
86
# File 'lib/has_helpers/flattener/base.rb', line 84

def belongs_to(*args)
  singleton_class.belongs_to(*args)
end

#columnsObject



48
49
50
51
52
53
# File 'lib/has_helpers/flattener/base.rb', line 48

def columns
  @columns ||= model_columns.
    lazy.
    select { |c| include_columns.blank? || include_columns.include?(c.name.to_sym) }.
    reject { |c| exclude_columns.include?(c.name.to_sym) } # FIXME: It seems inconsistent to return string column names (from .columns) yet require symbol names for excluded columns. *
end

#exclude_columns(*column_names) ⇒ Object



65
66
67
68
69
# File 'lib/has_helpers/flattener/base.rb', line 65

def exclude_columns(*column_names)
  (@exclude_columns ||= []).tap do |exclude_columns|
    exclude_columns.concat column_names if column_names
  end
end

#flatten(depth = 1) ⇒ Object



80
81
82
# File 'lib/has_helpers/flattener/base.rb', line 80

def flatten(depth = 1)
  Flattener::Resource.new(flattener: self).flatten(depth)
end

#include_columns(*column_names) ⇒ Object



59
60
61
62
63
# File 'lib/has_helpers/flattener/base.rb', line 59

def include_columns(*column_names)
  (@include_columns ||= []).tap do |include_columns|
    include_columns.concat column_names if column_names
  end
end

#join(name, **options, &block) ⇒ Object



39
40
41
42
43
44
45
46
# File 'lib/has_helpers/flattener/base.rb', line 39

def join(name, **options, &block)
  if add(name, always_include: true, merge_into_parent: true, **options, &block)
    # rubocop:disable Rails/SkipsModelValidations
    update_column(name.to_s.foreign_key, alias_name: :id)
    update_column(self.class.model_klass.primary_key, alias_name: :link_id)
    # rubocop:enable Rails/SkipsModelValidations
  end
end

#remove(*args) ⇒ Object



32
33
34
35
36
37
# File 'lib/has_helpers/flattener/base.rb', line 32

def remove(*args)
  args.each do |name|
    association = associations.detect { |a| a.ar_association_name == name }
    associations.delete(association) if association
  end
end

#update_column(name, **options) ⇒ Object

Raises:

  • (ArgumentError)


71
72
73
74
75
76
77
78
# File 'lib/has_helpers/flattener/base.rb', line 71

def update_column(name, **options)
  raise ArgumentError, "nil is not a valid column" if name.nil?
  begin
    columns.detect { |column| column.name.to_s == name.to_s }.update(options)
  rescue NoMethodError
    raise "No column `#{name}` on #{self.class.name}" if ::HasUtils::Env.development?
  end
end