activeitem 0.0.1 → 0.0.2

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.
@@ -4,6 +4,7 @@ module ActiveItem
4
4
  class RecordNotFound < StandardError; end
5
5
  class TransactionError < StandardError; end
6
6
 
7
+ # Raised when an IAM policy denies a DynamoDB operation on a table.
7
8
  class AccessDeniedError < StandardError
8
9
  attr_reader :model_name, :table, :operation, :original_error
9
10
 
@@ -13,10 +14,12 @@ module ActiveItem
13
14
  @operation = operation
14
15
  @original_error = original_error
15
16
  super("#{model_name} is not allowed to #{operation} on #{table}. " \
16
- "Ensure the IAM role has access to this table.")
17
+ 'Ensure the IAM role has access to this table.')
17
18
  end
18
19
  end
19
20
 
21
+ # Raised when a record cannot be deleted because dependent associations
22
+ # with :restrict_with_exception still exist.
20
23
  class DeleteRestrictionError < StandardError
21
24
  attr_reader :association_name
22
25
 
@@ -9,6 +9,8 @@ module ActiveItem
9
9
  def debug(*); end
10
10
  end
11
11
 
12
+ # Provides a +dynamo_logger+ helper that delegates to the globally
13
+ # configured ActiveItem logger.
12
14
  module Logging
13
15
  private
14
16
 
@@ -3,21 +3,13 @@
3
3
  require 'active_support/inflector'
4
4
 
5
5
  module ActiveItem
6
+ # Utility for resolving association class names to constants, attempting
7
+ # common model file paths when the constant is not yet loaded.
6
8
  module ModelLoader
7
9
  def safe_constantize_model(class_name)
8
- return class_name.constantize if Object.const_defined?(class_name)
10
+ raise ArgumentError, "Invalid class name: #{class_name}" unless class_name.match?(/\A[A-Z][A-Za-z0-9]*(::[A-Z][A-Za-z0-9]*)*\z/)
9
11
 
10
- file_name = class_name.underscore
11
- # Try common model paths
12
- ['.', 'models', 'app/models'].each do |dir|
13
- path = File.join(dir, "#{file_name}.rb")
14
- if File.exist?(path)
15
- require path
16
- break
17
- end
18
- end
19
-
20
- class_name.constantize
12
+ class_name.safe_constantize || raise(NameError, "Unknown model: #{class_name}")
21
13
  end
22
14
  end
23
15
  end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
+ # Cursor-based pagination for in-memory arrays of ActiveItem records.
5
+ # Provides a +paginate_array+ helper and a +PaginatedResult+ wrapper.
4
6
  module Pagination
5
7
  DEFAULT_PER_PAGE = 25
6
8
  MAX_PER_PAGE = 100
7
9
 
8
10
  def self.paginate_array(items, cursor = nil, per_page: DEFAULT_PER_PAGE)
9
- per_page = [[per_page.to_i, 1].max, MAX_PER_PAGE].min
11
+ per_page = per_page.to_i.clamp(1, MAX_PER_PAGE)
10
12
 
11
13
  if cursor && !cursor.empty?
12
14
  cursor_time, cursor_id = cursor.split('|', 2)
13
- items = items.drop_while { |i| ([(i.created_at || ''), i.id] <=> [cursor_time, cursor_id]) >= 0 }
15
+ items = items.drop_while { |i| ([i.created_at || '', i.id] <=> [cursor_time, cursor_id]) >= 0 }
14
16
  end
15
17
 
16
18
  page_items = items.first(per_page)
@@ -21,6 +23,7 @@ module ActiveItem
21
23
  PaginatedResult.new(items: page_items, next_cursor: next_cursor, per_page: per_page)
22
24
  end
23
25
 
26
+ # Enumerable wrapper around a page of results with cursor metadata.
24
27
  class PaginatedResult
25
28
  include Enumerable
26
29
 
@@ -40,10 +43,10 @@ module ActiveItem
40
43
  { next_cursor: next_cursor, has_more: has_more?, per_page: per_page }
41
44
  end
42
45
 
43
- def each(&block) = items.each(&block)
46
+ def each(&) = items.each(&)
44
47
  def length = items.length
45
- alias_method :size, :length
46
- alias_method :count, :length
48
+ alias size length
49
+ alias count length
47
50
  def empty? = items.empty?
48
51
  def to_a = items
49
52
  end
@@ -1,13 +1,15 @@
1
- # encoding: utf-8
1
+ # frozen_string_literal: true
2
2
 
3
3
  require_relative 'relation'
4
4
 
5
5
  module ActiveItem
6
+ # Class-level query interface providing find, where, batch operations,
7
+ # counting, existence checks, and automatic GSI index detection.
6
8
  module QueryHelpers
7
-
8
9
  def find(id)
9
10
  record = get({ primary_key.to_s => id })
10
11
  raise ActiveItem::RecordNotFound, "Couldn't find #{name} with '#{primary_key}'=#{id}" unless record
12
+
11
13
  instantiate(record)
12
14
  end
13
15
 
@@ -38,24 +40,23 @@ module ActiveItem
38
40
  items = response.responses[table_name] || []
39
41
  results.concat(items.map { |item| instantiate(item) })
40
42
 
41
- # Check for unprocessed keys and retry with exponential backoff
43
+ # Check for unprocessed keys and retry with exponential backoff + jitter
42
44
  unprocessed = response.unprocessed_keys
43
- if unprocessed&.any?
44
- retries += 1
45
- break if retries > max_retries
46
-
47
- sleep(0.05 * (2**retries)) # Exponential backoff: 100ms, 200ms, 400ms...
48
- request = unprocessed
49
- else
50
- break
51
- end
45
+ break unless unprocessed&.any?
46
+
47
+ retries += 1
48
+ break if retries > max_retries
49
+
50
+ sleep(0.05 * (2**retries) * (0.5 + (rand * 0.5)))
51
+ request = unprocessed
52
+
52
53
  end
53
54
  end
54
55
 
55
56
  results
56
57
  rescue Aws::DynamoDB::Errors::AccessDeniedException => e
57
58
  raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
58
- operation: 'BatchGetItem', original_error: e)
59
+ operation: 'BatchGetItem', original_error: e)
59
60
  end
60
61
 
61
62
  # Batch write multiple records using DynamoDB's BatchWriteItem
@@ -100,15 +101,14 @@ module ActiveItem
100
101
  response = dynamodb.batch_write_item(request_items: request)
101
102
 
102
103
  unprocessed = response.unprocessed_items
103
- if unprocessed&.any?
104
- retries += 1
105
- break if retries > max_retries
106
-
107
- sleep(0.05 * (2**retries))
108
- request = unprocessed
109
- else
110
- break
111
- end
104
+ break unless unprocessed&.any?
105
+
106
+ retries += 1
107
+ break if retries > max_retries
108
+
109
+ sleep(0.05 * (2**retries) * (0.5 + (rand * 0.5)))
110
+ request = unprocessed
111
+
112
112
  end
113
113
  end
114
114
 
@@ -116,7 +116,7 @@ module ActiveItem
116
116
  records
117
117
  rescue Aws::DynamoDB::Errors::AccessDeniedException => e
118
118
  raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
119
- operation: 'BatchWriteItem', original_error: e)
119
+ operation: 'BatchWriteItem', original_error: e)
120
120
  end
121
121
 
122
122
  def find_by(**conditions)
@@ -228,10 +228,6 @@ module ActiveItem
228
228
  all.first
229
229
  end
230
230
 
231
- def last
232
- all.last
233
- end
234
-
235
231
  # Count records with optional conditions or block
236
232
  #
237
233
  # @example Count all records
@@ -243,10 +239,10 @@ module ActiveItem
243
239
  # @example Count with block (Rails-like, loads all records)
244
240
  # Customer.count { |c| c.email.include?('@gmail.com') } # => 15
245
241
  #
246
- def count(**conditions, &block)
242
+ def count(**conditions, &)
247
243
  if block_given?
248
244
  # Block provided - load all records and count with Ruby
249
- all.count(&block)
245
+ all.count(&)
250
246
  elsif conditions.empty?
251
247
  # No conditions, no block - use efficient DynamoDB COUNT
252
248
  response = dynamodb.scan(
@@ -278,22 +274,16 @@ module ActiveItem
278
274
  # @return [Boolean] true if a record exists matching the conditions
279
275
  def exists?(id_or_conditions = nil, **conditions)
280
276
  # Handle single ID parameter: Customer.exists?('cust-123')
281
- if id_or_conditions.is_a?(String) && conditions.empty?
282
- return !!get({ primary_key.to_s => id_or_conditions })
283
- end
277
+ return !!get({ primary_key.to_s => id_or_conditions }) if id_or_conditions.is_a?(String) && conditions.empty?
284
278
 
285
279
  # Merge positional hash with keyword arguments if both provided
286
- if id_or_conditions.is_a?(Hash)
287
- conditions = id_or_conditions.merge(conditions)
288
- end
280
+ conditions = id_or_conditions.merge(conditions) if id_or_conditions.is_a?(Hash)
289
281
 
290
282
  # If checking by primary key only, use the efficient get operation
291
- if conditions.keys.size == 1 && conditions.key?(primary_key.to_sym)
292
- return !!get({ primary_key.to_s => conditions[primary_key.to_sym] })
293
- end
283
+ return !!get({ primary_key.to_s => conditions[primary_key.to_sym] }) if conditions.keys.size == 1 && conditions.key?(primary_key.to_sym)
294
284
 
295
285
  # For other conditions, use where with limit 1 and count
296
- where(**conditions).limit(1).count > 0
286
+ where(**conditions).limit(1).any?
297
287
  end
298
288
 
299
289
  def delete_all
@@ -340,16 +330,10 @@ module ActiveItem
340
330
 
341
331
  # First, try to find an index with the converted partition key
342
332
  indexes.each do |index_name, config|
343
- if config[:partition_key].to_s == dynamo_partition_key
344
- return index_name
345
- end
346
- end
333
+ return index_name if config[:partition_key].to_s == dynamo_partition_key
347
334
 
348
- # Also try the original Ruby key (for legacy indexes defined with snake_case)
349
- indexes.each do |index_name, config|
350
- if config[:partition_key].to_s == ruby_partition_key
351
- return index_name
352
- end
335
+ # Also try the original Ruby key (for legacy indexes defined with snake_case)
336
+ return index_name if config[:partition_key].to_s == ruby_partition_key
353
337
  end
354
338
 
355
339
  # If not found, check if this is an association-based attribute
@@ -360,9 +344,7 @@ module ActiveItem
360
344
  dynamo_resolved_key = to_dynamo_key(resolved_key)
361
345
  indexes.each do |index_name, config|
362
346
  pk = config[:partition_key].to_s
363
- if pk == dynamo_resolved_key || pk == resolved_key
364
- return index_name
365
- end
347
+ return index_name if pk == dynamo_resolved_key || pk == resolved_key
366
348
  end
367
349
  end
368
350
 
@@ -388,7 +370,7 @@ module ActiveItem
388
370
  foreign_key = association_config[:foreign_key]
389
371
 
390
372
  # If the foreign key is different from the attribute name, return it
391
- foreign_key.to_s != attr_name ? foreign_key.to_s : nil
373
+ foreign_key.to_s == attr_name ? nil : foreign_key.to_s
392
374
  end
393
375
 
394
376
  # Build a condition expression for a single attribute
@@ -416,15 +398,11 @@ module ActiveItem
416
398
  dynamo_attr = to_dynamo_key(attr_str)
417
399
 
418
400
  # Handle nil - use attribute_not_exists (DynamoDB doesn't support = NULL)
419
- if val.nil?
420
- return ["attribute_not_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}]
421
- end
401
+ return ["attribute_not_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}] if val.nil?
422
402
 
423
403
  # Handle case-insensitive search with ilike option
424
404
  # Uses DynamoDB's `contains` function with downcased value
425
- if ilike && val.is_a?(String)
426
- return build_ilike_condition(dynamo_attr, val, idx)
427
- end
405
+ return build_ilike_condition(dynamo_attr, val, idx) if ilike && val.is_a?(String)
428
406
 
429
407
  # Handle ActiveItem model objects - extract primary key value
430
408
  # This allows queries like: Container.where(parent_container: some_container)
@@ -437,24 +415,16 @@ module ActiveItem
437
415
  end
438
416
 
439
417
  # Handle nested hash syntax: { address: { zip_code: '12345' } }
440
- if val.is_a?(Hash)
441
- return build_nested_hash_conditions(dynamo_attr, val, idx)
442
- end
418
+ return build_nested_hash_conditions(dynamo_attr, val, idx) if val.is_a?(Hash)
443
419
 
444
420
  # Handle dot notation: 'address.zip_code'
445
- if attr_str.include?('.')
446
- return build_dot_notation_condition(attr_str, val, idx)
447
- end
421
+ return build_dot_notation_condition(attr_str, val, idx) if attr_str.include?('.')
448
422
 
449
423
  # Handle Range values (BETWEEN, >=, <=)
450
- if val.is_a?(Range)
451
- return build_range_condition(dynamo_attr, val, idx)
452
- end
424
+ return build_range_condition(dynamo_attr, val, idx) if val.is_a?(Range)
453
425
 
454
426
  # Handle array values (IN clause)
455
- if val.is_a?(Array)
456
- return build_in_condition(dynamo_attr, val, idx)
457
- end
427
+ return build_in_condition(dynamo_attr, val, idx) if val.is_a?(Array)
458
428
 
459
429
  # Simple equality condition (use placeholder for reserved words)
460
430
  build_simple_condition(dynamo_attr, val, idx)
@@ -468,7 +438,7 @@ module ActiveItem
468
438
  # @param val [String] Search value (will be downcased)
469
439
  # @param idx [Integer] Index for unique placeholder names
470
440
  # @return [Array<String, Hash, Hash>] [expression, attribute_names, attribute_values]
471
- def build_ilike_condition(attr, val, idx)
441
+ def build_ilike_condition(attr, _val, idx)
472
442
  # For case-insensitive search, we can't rely on DynamoDB's case-sensitive contains()
473
443
  # Instead, we'll return a condition that matches more broadly and filter in Ruby
474
444
  # We use attribute_exists to ensure the field exists, then filter everything in Ruby
@@ -494,15 +464,12 @@ module ActiveItem
494
464
  if nested_val.is_a?(Hash)
495
465
  # Recursively handle deeper nesting
496
466
  expr, names, values = build_nested_hash_conditions(full_path, nested_val, idx)
497
- expressions << expr
498
- all_names.merge!(names)
499
- all_values.merge!(values)
500
467
  else
501
468
  expr, names, values = build_dot_notation_condition(full_path, nested_val, idx)
502
- expressions << expr
503
- all_names.merge!(names)
504
- all_values.merge!(values)
505
469
  end
470
+ expressions << expr
471
+ all_names.merge!(names)
472
+ all_values.merge!(values)
506
473
  end
507
474
 
508
475
  [expressions.join(' AND '), all_names, all_values]
@@ -632,6 +599,5 @@ module ActiveItem
632
599
  }
633
600
  end
634
601
  end
635
-
636
602
  end
637
603
  end