activeitem 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,591 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-dynamodb'
4
+ require 'active_support/core_ext/string/inflections'
5
+ require 'active_support/core_ext/hash/indifferent_access'
6
+ require 'active_support/core_ext/array/extract_options'
7
+ require 'active_support/callbacks'
8
+ require 'active_support/concern'
9
+ require 'active_model'
10
+ require 'securerandom'
11
+
12
+ module ActiveItem
13
+ class Base
14
+ include ActiveModel::Validations
15
+ include ActiveSupport::Callbacks
16
+ include Associations
17
+ include Logging
18
+
19
+ def self.const_missing(name)
20
+ ActiveItem.const_defined?(name) ? ActiveItem.const_get(name) : super
21
+ end
22
+
23
+ prepend ComposedOf
24
+
25
+ extend DatabaseHelpers
26
+ extend QueryHelpers
27
+ extend Validations
28
+
29
+ define_callbacks :save, :create, :update, :destroy, :validation
30
+ define_model_callbacks :initialize, only: :after
31
+
32
+ attr_accessor :id, :created_at, :updated_at, :dbrecord
33
+
34
+ def id=(value)
35
+ @id = (value.to_s.strip.empty? ? nil : value)
36
+ end
37
+
38
+ set_callback :create, :before, :generate_primary_key
39
+ set_callback :create, :before, :set_created_timestamp
40
+ set_callback :destroy, :before, :check_dependent_associations
41
+
42
+ def initialize(attributes = {})
43
+ @previously_changed = {}
44
+ @pending_changes = {}
45
+ @_preloaded_counts = {}
46
+ @_preloaded_associations = {}
47
+ @new_record = true
48
+
49
+ if attributes.is_a?(Hash)
50
+ attributes.each do |key, value|
51
+ setter = "#{key}="
52
+ send(setter, value) if respond_to?(setter)
53
+ end
54
+ end
55
+ end
56
+
57
+ def _preloaded_counts
58
+ @_preloaded_counts ||= {}
59
+ end
60
+
61
+ def _preloaded_associations
62
+ @_preloaded_associations ||= {}
63
+ end
64
+
65
+ def self.attribute_names
66
+ @attribute_names ||= begin
67
+ instance_methods.grep(/\A[a-z_][a-z0-9_]*=\z/).map { |m| m.to_s.chomp('=') }.sort
68
+ end
69
+ end
70
+
71
+ def populate_attributes_from_item(item)
72
+ self.class.attribute_names.each do |attr_name|
73
+ next if attr_name == 'id'
74
+
75
+ value = nil
76
+ found = false
77
+ self.class.dynamo_key_variants(attr_name).each do |key|
78
+ if item.key?(key)
79
+ value = item[key]
80
+ found = true
81
+ break
82
+ end
83
+ end
84
+
85
+ instance_variable_set("@#{attr_name}", value) if found
86
+ end
87
+
88
+ @created_at = item['createdAt'] || item['created_at']
89
+ @updated_at = item['updatedAt'] || item['updated_at']
90
+
91
+ populate_custom_attributes_from_item(item) if respond_to?(:populate_custom_attributes_from_item, true)
92
+ populate_composed_attributes_from_item(item) if self.class.respond_to?(:compositions) && self.class.compositions.any?
93
+ end
94
+
95
+ class << self
96
+ def attr_accessor(*attrs)
97
+ attrs.each do |attr|
98
+ attr_name = attr.to_s
99
+
100
+ define_method(attr_name) do
101
+ instance_variable_get("@#{attr_name}")
102
+ end
103
+
104
+ define_method("#{attr_name}=") do |value|
105
+ old_value = instance_variable_get("@#{attr_name}")
106
+ instance_variable_set("@#{attr_name}", value)
107
+
108
+ if old_value != value && instance_variable_defined?(:@pending_changes)
109
+ @pending_changes ||= {}
110
+ @pending_changes[attr_name] ||= [old_value, nil]
111
+ @pending_changes[attr_name][1] = value
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def primary_key
118
+ @primary_key ||= 'id'
119
+ end
120
+
121
+ def primary_key=(value)
122
+ remove_method primary_key.to_sym
123
+ remove_method "#{primary_key}=".to_sym
124
+
125
+ @primary_key = value.to_s
126
+
127
+ alias_method primary_key.to_sym, :id
128
+ alias_method "#{primary_key}=".to_sym, :id=
129
+ end
130
+
131
+ def table_name
132
+ @table_name || default_table_name
133
+ end
134
+
135
+ def table_name=(value)
136
+ @table_name = value.to_s
137
+ end
138
+
139
+ def dynamodb
140
+ @dynamodb ||= Aws::DynamoDB::Client.new(http_wire_trace: false)
141
+ end
142
+
143
+ def dynamodb=(client)
144
+ @dynamodb = client
145
+ end
146
+
147
+ def dynamo_attribute_map(mappings = nil)
148
+ if mappings
149
+ @dynamo_attribute_map = mappings.transform_keys(&:to_s)
150
+ else
151
+ @dynamo_attribute_map || {}
152
+ end
153
+ end
154
+
155
+ def to_dynamo_key(attr_name)
156
+ attr_str = attr_name.to_s
157
+ return dynamo_attribute_map[attr_str] if dynamo_attribute_map.key?(attr_str)
158
+ attr_str.camelize(:lower)
159
+ end
160
+
161
+ def from_dynamo_key(dynamo_key)
162
+ key_str = dynamo_key.to_s
163
+ reverse_map = dynamo_attribute_map.invert
164
+ return reverse_map[key_str] if reverse_map.key?(key_str)
165
+ key_str.underscore
166
+ end
167
+
168
+ def dynamo_key_variants(attr_name)
169
+ attr_str = attr_name.to_s
170
+ primary_key = to_dynamo_key(attr_str)
171
+ camel_case = attr_str.camelize(:lower)
172
+ [primary_key, camel_case, attr_str].uniq
173
+ end
174
+
175
+ def instantiate(item)
176
+ normalized_item = normalize_dynamodb_values(item)
177
+
178
+ record = allocate
179
+ record.instance_variable_set(:@id, normalized_item[self.primary_key])
180
+ record.send(:populate_attributes_from_item, normalized_item)
181
+ record.instance_variable_set(:@new_record, false)
182
+ record.instance_variable_set(:@previously_changed, {})
183
+ record.instance_variable_set(:@pending_changes, {})
184
+ record.instance_variable_set(:@dbrecord, normalized_item)
185
+ record
186
+ end
187
+
188
+ def normalize_dynamodb_values(obj)
189
+ case obj
190
+ when BigDecimal
191
+ obj.frac.zero? ? obj.to_i : obj.to_f
192
+ when Hash
193
+ obj.transform_values { |v| normalize_dynamodb_values(v) }
194
+ when Array
195
+ obj.map { |v| normalize_dynamodb_values(v) }
196
+ else
197
+ obj
198
+ end
199
+ end
200
+
201
+ def find_or_create_by(attributes, &block)
202
+ record = find_by(**attributes)
203
+ return record if record
204
+
205
+ record = new(**attributes)
206
+ block.call(record) if block_given?
207
+ record.save
208
+ record
209
+ end
210
+
211
+ # Callback DSL
212
+ def before_save(*args, &block)
213
+ options = args.extract_options!
214
+ if options[:on]
215
+ case options[:on].to_sym
216
+ when :create then set_callback(:create, :before, *args, &block)
217
+ when :update then set_callback(:update, :before, *args, &block)
218
+ else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
219
+ end
220
+ else
221
+ set_callback(:save, :before, *args, &block)
222
+ end
223
+ end
224
+
225
+ def after_save(*args, &block)
226
+ options = args.extract_options!
227
+ if options[:on]
228
+ case options[:on].to_sym
229
+ when :create then set_callback(:create, :after, *args, &block)
230
+ when :update then set_callback(:update, :after, *args, &block)
231
+ else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
232
+ end
233
+ else
234
+ set_callback(:save, :after, *args, &block)
235
+ end
236
+ end
237
+
238
+ def before_create(*args, &block) = set_callback(:create, :before, *args, &block)
239
+ def after_create(*args, &block) = set_callback(:create, :after, *args, &block)
240
+ def before_update(*args, &block) = set_callback(:update, :before, *args, &block)
241
+ def after_update(*args, &block) = set_callback(:update, :after, *args, &block)
242
+ def before_validation(*args, &block) = set_callback(:validation, :before, *args, &block)
243
+ def after_validation(*args, &block) = set_callback(:validation, :after, *args, &block)
244
+ def before_destroy(*args, &block) = set_callback(:destroy, :before, *args, &block)
245
+ def after_destroy(*args, &block) = set_callback(:destroy, :after, *args, &block)
246
+
247
+ def scope(name, body)
248
+ raise ArgumentError, "scope body must be callable (Proc/Lambda)" unless body.respond_to?(:call)
249
+ _scopes[name.to_sym] = body
250
+ define_singleton_method(name) { all.instance_exec(&body) }
251
+ end
252
+
253
+ def _scopes
254
+ @_scopes ||= {}
255
+ end
256
+
257
+ private
258
+
259
+ def default_table_name
260
+ raise "Cannot generate table name for anonymous class" unless name
261
+ ActiveItem.configuration.table_name_for(name)
262
+ end
263
+
264
+ def inherited(subclass)
265
+ super
266
+ subclass.class_eval do
267
+ alias_method primary_key.to_sym, :id
268
+ alias_method "#{primary_key}=".to_sym, :id=
269
+ end
270
+ end
271
+ end
272
+
273
+ def new_record?
274
+ @new_record != false
275
+ end
276
+
277
+ def persisted?
278
+ !new_record?
279
+ end
280
+
281
+ def reload
282
+ raise "Cannot reload a new record" if new_record?
283
+ fresh_record = self.class.find(id)
284
+ raise "Record not found: #{self.class.name} with id #{id}" unless fresh_record
285
+
286
+ self.class.attribute_names.each do |attr_name|
287
+ next if attr_name == 'dbrecord'
288
+ value = fresh_record.instance_variable_get("@#{attr_name}")
289
+ instance_variable_set("@#{attr_name}", value)
290
+ end
291
+
292
+ @created_at = fresh_record.created_at
293
+ @updated_at = fresh_record.updated_at
294
+ @dbrecord = fresh_record.dbrecord
295
+ @pending_changes = {}
296
+ @previously_changed = {}
297
+ self
298
+ end
299
+
300
+ def has_changes_to_save?
301
+ changes.any?
302
+ end
303
+
304
+ def to_h
305
+ attributes.with_indifferent_access
306
+ end
307
+
308
+ def attributes
309
+ attrs = {}
310
+ pk_name = self.class.primary_key
311
+ pk_value = send(pk_name) rescue instance_variable_get("@#{pk_name}")
312
+ attrs['id'] = pk_value
313
+ attrs[pk_name] = pk_value
314
+
315
+ self.class.attribute_names.each do |attr_name|
316
+ next if attr_name == 'dbrecord'
317
+ value = instance_variable_get("@#{attr_name}")
318
+ attrs[attr_name] = value unless value.nil?
319
+ end
320
+
321
+ attrs['created_at'] = @created_at
322
+ attrs['updated_at'] = @updated_at
323
+ attrs
324
+ end
325
+
326
+ def inspect
327
+ pk_value = send(self.class.primary_key) rescue id
328
+ attr_strs = self.class.attribute_names.filter_map do |attr|
329
+ next if attr == 'dbrecord'
330
+ value = instance_variable_get("@#{attr}")
331
+ next if value.nil?
332
+ "#{attr}: #{value.inspect}"
333
+ end
334
+ "#<#{self.class.name} #{attr_strs.join(', ')}>"
335
+ end
336
+
337
+ def update(attributes)
338
+ assign_attributes(attributes)
339
+ save
340
+ end
341
+
342
+ def update!(attributes)
343
+ assign_attributes(attributes)
344
+ save!
345
+ end
346
+
347
+ def save(validate: true)
348
+ return false if validate && !run_validations
349
+
350
+ result = run_callbacks :save do
351
+ if new_record?
352
+ run_callbacks(:create) { perform_create }
353
+ else
354
+ run_callbacks(:update) { perform_update }
355
+ end
356
+ end
357
+
358
+ return false if result == false
359
+ changes_applied
360
+ true
361
+ rescue => e
362
+ dynamo_logger.error("Failed to save #{self.class.name}: #{e.message}")
363
+ raise e
364
+ end
365
+
366
+ def save!
367
+ raise StandardError, "Validation failed: #{errors.full_messages.join(', ')}" unless save
368
+ end
369
+
370
+ def self.create(attributes = {})
371
+ obj = new(attributes)
372
+ obj.save
373
+ obj
374
+ end
375
+
376
+ def self.create!(attributes = {})
377
+ obj = new(attributes)
378
+ obj.save!
379
+ obj
380
+ end
381
+
382
+ def self.transaction
383
+ txn = Transaction.new
384
+ yield txn
385
+ txn.execute!
386
+ end
387
+
388
+ def self.transaction_find(items)
389
+ return [] if items.empty?
390
+ raise TransactionError, "DynamoDB transactions are limited to 100 items (got #{items.length})" if items.length > 100
391
+
392
+ transact_items = items.map do |item|
393
+ { get: { table_name: item[:model].table_name, key: { item[:model].primary_key.to_s => item[:key] } } }
394
+ end
395
+
396
+ client = items.first[:model].dynamodb
397
+ response = client.transact_get_items(transact_items: transact_items)
398
+
399
+ response.responses.each_with_index.map do |resp, idx|
400
+ items[idx][:model].instantiate(resp.item) if resp.item
401
+ end
402
+ rescue Aws::DynamoDB::Errors::TransactionCanceledException => e
403
+ raise TransactionError, "Transaction read cancelled: #{e.message}"
404
+ end
405
+
406
+ def destroy
407
+ result = run_callbacks(:destroy) { perform_destroy }
408
+ return false if result == false
409
+ true
410
+ rescue DeleteRestrictionError
411
+ false
412
+ rescue => e
413
+ dynamo_logger.error("Failed to destroy #{self.class.name}: #{e.message}")
414
+ errors.add(:base, e.message)
415
+ false
416
+ end
417
+
418
+ def delete
419
+ perform_destroy
420
+ true
421
+ rescue => e
422
+ dynamo_logger.error("Failed to delete #{self.class.name}: #{e.message}")
423
+ false
424
+ end
425
+
426
+ def assign_attributes(attributes)
427
+ attributes.each do |key, value|
428
+ setter = "#{key}="
429
+ if respond_to?(setter)
430
+ old_value = send(key)
431
+ @pending_changes[key.to_s] = [old_value, value] if old_value != value
432
+ send(setter, value)
433
+ end
434
+ end
435
+ end
436
+
437
+ def attribute_changed?(attr_name)
438
+ @pending_changes.key?(attr_name.to_s)
439
+ end
440
+
441
+ def attribute_was(attr_name)
442
+ @pending_changes.dig(attr_name.to_s, 0)
443
+ end
444
+
445
+ def changes
446
+ @pending_changes
447
+ end
448
+
449
+ def previous_changes
450
+ @previously_changed
451
+ end
452
+
453
+ def changes_applied
454
+ @previously_changed = @pending_changes.dup
455
+ @pending_changes = {}
456
+ @new_record = false
457
+ end
458
+
459
+ def valid?(context = nil)
460
+ return super(context) if defined?(@running_validations) && @running_validations
461
+
462
+ @running_validations = true
463
+ begin
464
+ run_callbacks(:validation) { super(context) }
465
+ ensure
466
+ @running_validations = false
467
+ end
468
+ end
469
+
470
+ private
471
+
472
+ def generate_primary_key
473
+ @id = nil if @id.to_s.strip.empty?
474
+ @id ||= SecureRandom.uuid
475
+
476
+ pk = self.class.primary_key
477
+ instance_variable_set("@#{pk}", @id) if pk != 'id'
478
+ end
479
+
480
+ def set_created_timestamp
481
+ @created_at ||= Time.now.utc.iso8601
482
+ end
483
+
484
+ def dynamodb
485
+ self.class.dynamodb
486
+ end
487
+
488
+ def table_name
489
+ self.class.table_name
490
+ end
491
+
492
+ def run_validations
493
+ context = new_record? ? :create : :update
494
+ valid?(context)
495
+ end
496
+
497
+ def perform_create
498
+ item = build_dynamodb_item
499
+ item['createdAt'] = @created_at
500
+ item['updatedAt'] = Time.now.utc.iso8601
501
+
502
+ dynamodb.put_item(
503
+ table_name: table_name,
504
+ item: item,
505
+ condition_expression: 'attribute_not_exists(#pk)',
506
+ expression_attribute_names: { '#pk' => self.class.primary_key.to_s }
507
+ )
508
+
509
+ dynamo_logger.info("#{self.class.name} created (#{self.class.primary_key}: #{id})")
510
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
511
+ errors.add(:id, "already exists")
512
+ false
513
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
514
+ raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
515
+ operation: 'PutItem', original_error: e)
516
+ end
517
+
518
+ def build_dynamodb_item
519
+ item = { self.class.primary_key.to_s => id }
520
+
521
+ dynamodb_attributes.each do |attr|
522
+ value = instance_variable_get("@#{attr}")
523
+ next if value.nil?
524
+ dynamo_key = self.class.to_dynamo_key(attr)
525
+ item[dynamo_key] = value
526
+ end
527
+
528
+ item
529
+ end
530
+
531
+ def dynamodb_attributes
532
+ attrs = self.class.attribute_names - [self.class.primary_key.sub('_id', ''), 'id', 'dbrecord']
533
+
534
+ if self.class.respond_to?(:compositions) && self.class.compositions.any?
535
+ composed_attrs = self.class.compositions.values.flat_map { |c| c[:mapping].keys.map(&:to_s) }
536
+ attrs -= composed_attrs
537
+ end
538
+
539
+ attrs
540
+ end
541
+
542
+ def perform_update
543
+ return if changes.empty?
544
+
545
+ update_parts = []
546
+ remove_parts = []
547
+ attr_values = {}
548
+ attr_names = {}
549
+
550
+ changes.each_with_index do |(field, (_old_val, new_val)), idx|
551
+ dynamo_key = self.class.to_dynamo_key(field)
552
+ if new_val.nil?
553
+ remove_parts << "#field#{idx}"
554
+ attr_names["#field#{idx}"] = dynamo_key
555
+ else
556
+ update_parts << "#field#{idx} = :val#{idx}"
557
+ attr_names["#field#{idx}"] = dynamo_key
558
+ attr_values[":val#{idx}"] = new_val
559
+ end
560
+ end
561
+
562
+ update_parts << "updatedAt = :updatedAt"
563
+ attr_values[':updatedAt'] = Time.now.utc.iso8601
564
+
565
+ update_expression = "SET #{update_parts.join(', ')}"
566
+ update_expression += " REMOVE #{remove_parts.join(', ')}" if remove_parts.any?
567
+
568
+ params = {
569
+ table_name: table_name,
570
+ key: { self.class.primary_key.to_s => id },
571
+ update_expression: update_expression,
572
+ expression_attribute_values: attr_values
573
+ }
574
+ params[:expression_attribute_names] = attr_names if attr_names.any?
575
+
576
+ dynamodb.update_item(params)
577
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
578
+ raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
579
+ operation: 'UpdateItem', original_error: e)
580
+ end
581
+
582
+ def perform_destroy
583
+ key = self.class.primary_key.to_s
584
+ dynamodb.delete_item(table_name: table_name, key: { key => send(key) })
585
+ dynamo_logger.info("#{self.class.name} deleted (#{key}: #{send(key)})")
586
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
587
+ raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
588
+ operation: 'DeleteItem', original_error: e)
589
+ end
590
+ end
591
+ end