Class: ActiveItem::Base

Inherits:
Object
  • Object
show all
Extended by:
DatabaseHelpers, QueryHelpers, Validations
Includes:
Associations, ComposedOf, Logging, ActiveModel::Validations, ActiveSupport::Callbacks
Defined in:
lib/active_item/base.rb

Constant Summary

Constants included from QueryHelpers

QueryHelpers::RECENT_INDEX

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from DatabaseHelpers

exists?, get, put, query, scan

Methods included from QueryHelpers

all, all_records, batch_find, batch_write, count, delete_all, exists?, find, find_by, first, includes, indexes, last, none, recent, where

Methods included from Validations

validates_format_of, validates_length_of, validates_numericality_of, validates_uniqueness_of

Methods included from Associations

#check_dependent_associations

Methods included from ModelLoader

#safe_constantize_model

Methods included from ComposedOf

#populate_composed_attributes_from_item

Constructor Details

#initialize(attributes = {}) ⇒ Base

Returns a new instance of Base.



42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/active_item/base.rb', line 42

def initialize(attributes = {})
  @previously_changed = {}
  @pending_changes = {}
  @_preloaded_counts = {}
  @_preloaded_associations = {}
  @new_record = true

  if attributes.is_a?(Hash)
    attributes.each do |key, value|
      setter = "#{key}="
      send(setter, value) if respond_to?(setter)
    end
  end
end

Instance Attribute Details

#created_atObject

Returns the value of attribute created_at.



32
33
34
# File 'lib/active_item/base.rb', line 32

def created_at
  @created_at
end

#dbrecordObject

Returns the value of attribute dbrecord.



32
33
34
# File 'lib/active_item/base.rb', line 32

def dbrecord
  @dbrecord
end

#idObject

Returns the value of attribute id.



32
33
34
# File 'lib/active_item/base.rb', line 32

def id
  @id
end

#updated_atObject

Returns the value of attribute updated_at.



32
33
34
# File 'lib/active_item/base.rb', line 32

def updated_at
  @updated_at
end

Class Method Details

._scopesObject



253
254
255
# File 'lib/active_item/base.rb', line 253

def _scopes
  @_scopes ||= {}
end

.after_create(*args, &block) ⇒ Object



239
# File 'lib/active_item/base.rb', line 239

def after_create(*args, &block) = set_callback(:create, :after, *args, &block)

.after_destroy(*args, &block) ⇒ Object



245
# File 'lib/active_item/base.rb', line 245

def after_destroy(*args, &block) = set_callback(:destroy, :after, *args, &block)

.after_save(*args, &block) ⇒ Object



225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/active_item/base.rb', line 225

def after_save(*args, &block)
  options = args.extract_options!
  if options[:on]
    case options[:on].to_sym
    when :create then set_callback(:create, :after, *args, &block)
    when :update then set_callback(:update, :after, *args, &block)
    else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
    end
  else
    set_callback(:save, :after, *args, &block)
  end
end

.after_update(*args, &block) ⇒ Object



241
# File 'lib/active_item/base.rb', line 241

def after_update(*args, &block) = set_callback(:update, :after, *args, &block)

.after_validation(*args, &block) ⇒ Object



243
# File 'lib/active_item/base.rb', line 243

def after_validation(*args, &block) = set_callback(:validation, :after, *args, &block)

.attr_accessor(*attrs) ⇒ Object



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/active_item/base.rb', line 96

def attr_accessor(*attrs)
  attrs.each do |attr|
    attr_name = attr.to_s

    define_method(attr_name) do
      instance_variable_get("@#{attr_name}")
    end

    define_method("#{attr_name}=") do |value|
      old_value = instance_variable_get("@#{attr_name}")
      instance_variable_set("@#{attr_name}", value)

      if old_value != value && instance_variable_defined?(:@pending_changes)
        @pending_changes ||= {}
        @pending_changes[attr_name] ||= [old_value, nil]
        @pending_changes[attr_name][1] = value
      end
    end
  end
end

.attribute_namesObject



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

def self.attribute_names
  @attribute_names ||= begin
    instance_methods.grep(/\A[a-z_][a-z0-9_]*=\z/).map { |m| m.to_s.chomp('=') }.sort
  end
end

.before_create(*args, &block) ⇒ Object



238
# File 'lib/active_item/base.rb', line 238

def before_create(*args, &block) = set_callback(:create, :before, *args, &block)

.before_destroy(*args, &block) ⇒ Object



244
# File 'lib/active_item/base.rb', line 244

def before_destroy(*args, &block) = set_callback(:destroy, :before, *args, &block)

.before_save(*args, &block) ⇒ Object

Callback DSL



212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/active_item/base.rb', line 212

def before_save(*args, &block)
  options = args.extract_options!
  if options[:on]
    case options[:on].to_sym
    when :create then set_callback(:create, :before, *args, &block)
    when :update then set_callback(:update, :before, *args, &block)
    else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
    end
  else
    set_callback(:save, :before, *args, &block)
  end
end

.before_update(*args, &block) ⇒ Object



240
# File 'lib/active_item/base.rb', line 240

def before_update(*args, &block) = set_callback(:update, :before, *args, &block)

.before_validation(*args, &block) ⇒ Object



242
# File 'lib/active_item/base.rb', line 242

def before_validation(*args, &block) = set_callback(:validation, :before, *args, &block)

.const_missing(name) ⇒ Object



19
20
21
# File 'lib/active_item/base.rb', line 19

def self.const_missing(name)
  ActiveItem.const_defined?(name) ? ActiveItem.const_get(name) : super
end

.create(attributes = {}) ⇒ Object



370
371
372
373
374
# File 'lib/active_item/base.rb', line 370

def self.create(attributes = {})
  obj = new(attributes)
  obj.save
  obj
end

.create!(attributes = {}) ⇒ Object



376
377
378
379
380
# File 'lib/active_item/base.rb', line 376

def self.create!(attributes = {})
  obj = new(attributes)
  obj.save!
  obj
end

.dynamo_attribute_map(mappings = nil) ⇒ Object



147
148
149
150
151
152
153
# File 'lib/active_item/base.rb', line 147

def dynamo_attribute_map(mappings = nil)
  if mappings
    @dynamo_attribute_map = mappings.transform_keys(&:to_s)
  else
    @dynamo_attribute_map || {}
  end
end

.dynamo_key_variants(attr_name) ⇒ Object



168
169
170
171
172
173
# File 'lib/active_item/base.rb', line 168

def dynamo_key_variants(attr_name)
  attr_str = attr_name.to_s
  primary_key = to_dynamo_key(attr_str)
  camel_case = attr_str.camelize(:lower)
  [primary_key, camel_case, attr_str].uniq
end

.dynamodbObject



139
140
141
# File 'lib/active_item/base.rb', line 139

def dynamodb
  @dynamodb ||= Aws::DynamoDB::Client.new(http_wire_trace: false)
end

.dynamodb=(client) ⇒ Object



143
144
145
# File 'lib/active_item/base.rb', line 143

def dynamodb=(client)
  @dynamodb = client
end

.find_or_create_by(attributes, &block) ⇒ Object



201
202
203
204
205
206
207
208
209
# File 'lib/active_item/base.rb', line 201

def find_or_create_by(attributes, &block)
  record = find_by(**attributes)
  return record if record

  record = new(**attributes)
  block.call(record) if block_given?
  record.save
  record
end

.from_dynamo_key(dynamo_key) ⇒ Object



161
162
163
164
165
166
# File 'lib/active_item/base.rb', line 161

def from_dynamo_key(dynamo_key)
  key_str = dynamo_key.to_s
  reverse_map = dynamo_attribute_map.invert
  return reverse_map[key_str] if reverse_map.key?(key_str)
  key_str.underscore
end

.instantiate(item) ⇒ Object



175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/active_item/base.rb', line 175

def instantiate(item)
  normalized_item = normalize_dynamodb_values(item)

  record = allocate
  record.instance_variable_set(:@id, normalized_item[self.primary_key])
  record.send(:populate_attributes_from_item, normalized_item)
  record.instance_variable_set(:@new_record, false)
  record.instance_variable_set(:@previously_changed, {})
  record.instance_variable_set(:@pending_changes, {})
  record.instance_variable_set(:@dbrecord, normalized_item)
  record
end

.normalize_dynamodb_values(obj) ⇒ Object



188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/active_item/base.rb', line 188

def normalize_dynamodb_values(obj)
  case obj
  when BigDecimal
    obj.frac.zero? ? obj.to_i : obj.to_f
  when Hash
    obj.transform_values { |v| normalize_dynamodb_values(v) }
  when Array
    obj.map { |v| normalize_dynamodb_values(v) }
  else
    obj
  end
end

.primary_keyObject



117
118
119
# File 'lib/active_item/base.rb', line 117

def primary_key
  @primary_key ||= 'id'
end

.primary_key=(value) ⇒ Object



121
122
123
124
125
126
127
128
129
# File 'lib/active_item/base.rb', line 121

def primary_key=(value)
  remove_method primary_key.to_sym
  remove_method "#{primary_key}=".to_sym

  @primary_key = value.to_s

  alias_method primary_key.to_sym, :id
  alias_method "#{primary_key}=".to_sym, :id=
end

.scope(name, body) ⇒ Object

Raises:

  • (ArgumentError)


247
248
249
250
251
# File 'lib/active_item/base.rb', line 247

def scope(name, body)
  raise ArgumentError, "scope body must be callable (Proc/Lambda)" unless body.respond_to?(:call)
  _scopes[name.to_sym] = body
  define_singleton_method(name) { all.instance_exec(&body) }
end

.table_nameObject



131
132
133
# File 'lib/active_item/base.rb', line 131

def table_name
  @table_name || default_table_name
end

.table_name=(value) ⇒ Object



135
136
137
# File 'lib/active_item/base.rb', line 135

def table_name=(value)
  @table_name = value.to_s
end

.to_dynamo_key(attr_name) ⇒ Object



155
156
157
158
159
# File 'lib/active_item/base.rb', line 155

def to_dynamo_key(attr_name)
  attr_str = attr_name.to_s
  return dynamo_attribute_map[attr_str] if dynamo_attribute_map.key?(attr_str)
  attr_str.camelize(:lower)
end

.transaction {|txn| ... } ⇒ Object

Yields:

  • (txn)


382
383
384
385
386
# File 'lib/active_item/base.rb', line 382

def self.transaction
  txn = Transaction.new
  yield txn
  txn.execute!
end

.transaction_find(items) ⇒ Object



388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/active_item/base.rb', line 388

def self.transaction_find(items)
  return [] if items.empty?
  raise TransactionError, "DynamoDB transactions are limited to 100 items (got #{items.length})" if items.length > 100

  transact_items = items.map do |item|
    { get: { table_name: item[:model].table_name, key: { item[:model].primary_key.to_s => item[:key] } } }
  end

  client = items.first[:model].dynamodb
  response = client.transact_get_items(transact_items: transact_items)

  response.responses.each_with_index.map do |resp, idx|
    items[idx][:model].instantiate(resp.item) if resp.item
  end
rescue Aws::DynamoDB::Errors::TransactionCanceledException => e
  raise TransactionError, "Transaction read cancelled: #{e.message}"
end

Instance Method Details

#_preloaded_associationsObject



61
62
63
# File 'lib/active_item/base.rb', line 61

def _preloaded_associations
  @_preloaded_associations ||= {}
end

#_preloaded_countsObject



57
58
59
# File 'lib/active_item/base.rb', line 57

def _preloaded_counts
  @_preloaded_counts ||= {}
end

#assign_attributes(attributes) ⇒ Object



426
427
428
429
430
431
432
433
434
435
# File 'lib/active_item/base.rb', line 426

def assign_attributes(attributes)
  attributes.each do |key, value|
    setter = "#{key}="
    if respond_to?(setter)
      old_value = send(key)
      @pending_changes[key.to_s] = [old_value, value] if old_value != value
      send(setter, value)
    end
  end
end

#attribute_changed?(attr_name) ⇒ Boolean

Returns:

  • (Boolean)


437
438
439
# File 'lib/active_item/base.rb', line 437

def attribute_changed?(attr_name)
  @pending_changes.key?(attr_name.to_s)
end

#attribute_was(attr_name) ⇒ Object



441
442
443
# File 'lib/active_item/base.rb', line 441

def attribute_was(attr_name)
  @pending_changes.dig(attr_name.to_s, 0)
end

#attributesObject



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/active_item/base.rb', line 308

def attributes
  attrs = {}
  pk_name = self.class.primary_key
  pk_value = send(pk_name) rescue instance_variable_get("@#{pk_name}")
  attrs['id'] = pk_value
  attrs[pk_name] = pk_value

  self.class.attribute_names.each do |attr_name|
    next if attr_name == 'dbrecord'
    value = instance_variable_get("@#{attr_name}")
    attrs[attr_name] = value unless value.nil?
  end

  attrs['created_at'] = @created_at
  attrs['updated_at'] = @updated_at
  attrs
end

#changesObject



445
446
447
# File 'lib/active_item/base.rb', line 445

def changes
  @pending_changes
end

#changes_appliedObject



453
454
455
456
457
# File 'lib/active_item/base.rb', line 453

def changes_applied
  @previously_changed = @pending_changes.dup
  @pending_changes = {}
  @new_record = false
end

#deleteObject



418
419
420
421
422
423
424
# File 'lib/active_item/base.rb', line 418

def delete
  perform_destroy
  true
rescue => e
  dynamo_logger.error("Failed to delete #{self.class.name}: #{e.message}")
  false
end

#destroyObject



406
407
408
409
410
411
412
413
414
415
416
# File 'lib/active_item/base.rb', line 406

def destroy
  result = run_callbacks(:destroy) { perform_destroy }
  return false if result == false
  true
rescue DeleteRestrictionError
  false
rescue => e
  dynamo_logger.error("Failed to destroy #{self.class.name}: #{e.message}")
  errors.add(:base, e.message)
  false
end

#has_changes_to_save?Boolean

Returns:

  • (Boolean)


300
301
302
# File 'lib/active_item/base.rb', line 300

def has_changes_to_save?
  changes.any?
end

#inspectObject



326
327
328
329
330
331
332
333
334
335
# File 'lib/active_item/base.rb', line 326

def inspect
  pk_value = send(self.class.primary_key) rescue id
  attr_strs = self.class.attribute_names.filter_map do |attr|
    next if attr == 'dbrecord'
    value = instance_variable_get("@#{attr}")
    next if value.nil?
    "#{attr}: #{value.inspect}"
  end
  "#<#{self.class.name} #{attr_strs.join(', ')}>"
end

#new_record?Boolean

Returns:

  • (Boolean)


273
274
275
# File 'lib/active_item/base.rb', line 273

def new_record?
  @new_record != false
end

#persisted?Boolean

Returns:

  • (Boolean)


277
278
279
# File 'lib/active_item/base.rb', line 277

def persisted?
  !new_record?
end

#populate_attributes_from_item(item) ⇒ Object



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/active_item/base.rb', line 71

def populate_attributes_from_item(item)
  self.class.attribute_names.each do |attr_name|
    next if attr_name == 'id'

    value = nil
    found = false
    self.class.dynamo_key_variants(attr_name).each do |key|
      if item.key?(key)
        value = item[key]
        found = true
        break
      end
    end

    instance_variable_set("@#{attr_name}", value) if found
  end

  @created_at = item['createdAt'] || item['created_at']
  @updated_at = item['updatedAt'] || item['updated_at']

  populate_custom_attributes_from_item(item) if respond_to?(:populate_custom_attributes_from_item, true)
  populate_composed_attributes_from_item(item) if self.class.respond_to?(:compositions) && self.class.compositions.any?
end

#previous_changesObject



449
450
451
# File 'lib/active_item/base.rb', line 449

def previous_changes
  @previously_changed
end

#reloadObject



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

def reload
  raise "Cannot reload a new record" if new_record?
  fresh_record = self.class.find(id)
  raise "Record not found: #{self.class.name} with id #{id}" unless fresh_record

  self.class.attribute_names.each do |attr_name|
    next if attr_name == 'dbrecord'
    value = fresh_record.instance_variable_get("@#{attr_name}")
    instance_variable_set("@#{attr_name}", value)
  end

  @created_at = fresh_record.created_at
  @updated_at = fresh_record.updated_at
  @dbrecord = fresh_record.dbrecord
  @pending_changes = {}
  @previously_changed = {}
  self
end

#save(validate: true) ⇒ Object



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/active_item/base.rb', line 347

def save(validate: true)
  return false if validate && !run_validations

  result = run_callbacks :save do
    if new_record?
      run_callbacks(:create) { perform_create }
    else
      run_callbacks(:update) { perform_update }
    end
  end

  return false if result == false
  changes_applied
  true
rescue => e
  dynamo_logger.error("Failed to save #{self.class.name}: #{e.message}")
  raise e
end

#save!Object

Raises:

  • (StandardError)


366
367
368
# File 'lib/active_item/base.rb', line 366

def save!
  raise StandardError, "Validation failed: #{errors.full_messages.join(', ')}" unless save
end

#to_hObject



304
305
306
# File 'lib/active_item/base.rb', line 304

def to_h
  attributes.with_indifferent_access
end

#update(attributes) ⇒ Object



337
338
339
340
# File 'lib/active_item/base.rb', line 337

def update(attributes)
  assign_attributes(attributes)
  save
end

#update!(attributes) ⇒ Object



342
343
344
345
# File 'lib/active_item/base.rb', line 342

def update!(attributes)
  assign_attributes(attributes)
  save!
end

#valid?(context = nil) ⇒ Boolean

Returns:

  • (Boolean)


459
460
461
462
463
464
465
466
467
468
# File 'lib/active_item/base.rb', line 459

def valid?(context = nil)
  return super(context) if defined?(@running_validations) && @running_validations

  @running_validations = true
  begin
    run_callbacks(:validation) { super(context) }
  ensure
    @running_validations = false
  end
end