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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +1 -1
- data/lib/active_item/associations.rb +13 -13
- data/lib/active_item/base.rb +84 -64
- data/lib/active_item/composed_of.rb +34 -23
- data/lib/active_item/configuration.rb +2 -0
- data/lib/active_item/database_helpers.rb +3 -3
- data/lib/active_item/errors.rb +4 -1
- data/lib/active_item/logging.rb +2 -0
- data/lib/active_item/model_loader.rb +4 -12
- data/lib/active_item/pagination.rb +8 -5
- data/lib/active_item/query_helpers.rb +44 -78
- data/lib/active_item/relation.rb +119 -112
- data/lib/active_item/transaction.rb +3 -1
- data/lib/active_item/validations.rb +13 -21
- data/lib/active_item/version.rb +1 -1
- data/lib/activeitem.rb +3 -0
- metadata +63 -17
data/lib/active_item/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/active_item/logging.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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| ([
|
|
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(&
|
|
46
|
+
def each(&) = items.each(&)
|
|
44
47
|
def length = items.length
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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, &
|
|
242
|
+
def count(**conditions, &)
|
|
247
243
|
if block_given?
|
|
248
244
|
# Block provided - load all records and count with Ruby
|
|
249
|
-
all.count(&
|
|
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).
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
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,
|
|
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
|