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/relation.rb
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'model_loader'
|
|
4
4
|
require_relative 'pagination'
|
|
@@ -10,9 +10,11 @@ module ActiveItem
|
|
|
10
10
|
include Enumerable
|
|
11
11
|
include ModelLoader
|
|
12
12
|
|
|
13
|
-
attr_reader :model, :conditions, :index_name, :limit_value, :not_conditions, :ilike, :ilike_exact, :class_name, :owner, :includes_associations,
|
|
13
|
+
attr_reader :model, :conditions, :index_name, :limit_value, :not_conditions, :ilike, :ilike_exact, :class_name, :owner, :includes_associations,
|
|
14
|
+
:order_direction, :select_attributes
|
|
14
15
|
|
|
15
|
-
def initialize(model, conditions: {}, index_name: nil, limit_value: nil, not_conditions: {}, ilike: false, ilike_exact: false, class_name: nil,
|
|
16
|
+
def initialize(model, conditions: {}, index_name: nil, limit_value: nil, not_conditions: {}, ilike: false, ilike_exact: false, class_name: nil,
|
|
17
|
+
owner: nil, preloaded_records: nil, includes_associations: [], order_direction: nil, select_attributes: nil)
|
|
16
18
|
@model = model
|
|
17
19
|
@class_name = class_name # For lazy loading
|
|
18
20
|
@owner = owner # The object that owns this association
|
|
@@ -20,13 +22,13 @@ module ActiveItem
|
|
|
20
22
|
@index_name = index_name
|
|
21
23
|
@limit_value = limit_value
|
|
22
24
|
@not_conditions = not_conditions.dup
|
|
23
|
-
@ilike = ilike
|
|
24
|
-
@ilike_exact = ilike_exact
|
|
25
|
+
@ilike = ilike # Use case-insensitive contains() for string conditions
|
|
26
|
+
@ilike_exact = ilike_exact # Require exact case-insensitive match (Ruby-side filter)
|
|
25
27
|
@loaded = preloaded_records ? true : false
|
|
26
28
|
@records = preloaded_records
|
|
27
29
|
@includes_associations = includes_associations
|
|
28
|
-
@order_direction = order_direction
|
|
29
|
-
@select_attributes = select_attributes
|
|
30
|
+
@order_direction = order_direction # :asc or :desc for DynamoDB ScanIndexForward
|
|
31
|
+
@select_attributes = select_attributes # Projection expression attributes
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
# Chainable includes - preload associations to avoid N+1 queries
|
|
@@ -49,8 +51,6 @@ module ActiveItem
|
|
|
49
51
|
case assoc
|
|
50
52
|
when Symbol
|
|
51
53
|
new_includes << assoc unless new_includes.include?(assoc)
|
|
52
|
-
when Hash
|
|
53
|
-
new_includes << assoc
|
|
54
54
|
else
|
|
55
55
|
new_includes << assoc
|
|
56
56
|
end
|
|
@@ -131,9 +131,9 @@ module ActiveItem
|
|
|
131
131
|
# @example Enumerable filtering (block)
|
|
132
132
|
# InventoryItem.where(customer_id: id).select { |i| i.active? }
|
|
133
133
|
#
|
|
134
|
-
def select(*attrs, &
|
|
134
|
+
def select(*attrs, &)
|
|
135
135
|
if block_given?
|
|
136
|
-
super(&
|
|
136
|
+
super(&)
|
|
137
137
|
else
|
|
138
138
|
spawn(select_attributes: attrs.map(&:to_sym))
|
|
139
139
|
end
|
|
@@ -155,7 +155,7 @@ module ActiveItem
|
|
|
155
155
|
# result = Model.where(status: 'active').page(params[:cursor], per_page: 25)
|
|
156
156
|
#
|
|
157
157
|
def page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE)
|
|
158
|
-
per_page =
|
|
158
|
+
per_page = per_page.to_i.clamp(1, Pagination::MAX_PER_PAGE)
|
|
159
159
|
|
|
160
160
|
items, next_cursor = execute_paginated_query(cursor, per_page)
|
|
161
161
|
if includes_associations.any?
|
|
@@ -181,9 +181,9 @@ module ActiveItem
|
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# Execute query and iterate over results
|
|
184
|
-
def each(&
|
|
184
|
+
def each(&)
|
|
185
185
|
load_records
|
|
186
|
-
@records.each(&
|
|
186
|
+
@records.each(&)
|
|
187
187
|
end
|
|
188
188
|
|
|
189
189
|
# Get first record
|
|
@@ -206,10 +206,10 @@ module ActiveItem
|
|
|
206
206
|
# @example With block (Rails-like)
|
|
207
207
|
# Pickup.all.count { |p| p.time_slot == "10-12" } # => 3
|
|
208
208
|
#
|
|
209
|
-
def count(&
|
|
209
|
+
def count(&)
|
|
210
210
|
if block_given? || ilike || ilike_exact
|
|
211
211
|
load_records
|
|
212
|
-
return block_given? ? @records.count(&
|
|
212
|
+
return block_given? ? @records.count(&) : @records.length
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
return @records.length if @loaded
|
|
@@ -222,7 +222,7 @@ module ActiveItem
|
|
|
222
222
|
load_records
|
|
223
223
|
@records.length
|
|
224
224
|
end
|
|
225
|
-
|
|
225
|
+
alias size length
|
|
226
226
|
|
|
227
227
|
# Check if any records exist
|
|
228
228
|
def any?
|
|
@@ -231,7 +231,7 @@ module ActiveItem
|
|
|
231
231
|
|
|
232
232
|
# Check if no records exist
|
|
233
233
|
def empty?
|
|
234
|
-
count
|
|
234
|
+
count.zero?
|
|
235
235
|
end
|
|
236
236
|
|
|
237
237
|
# Check if records exist matching optional conditions
|
|
@@ -248,7 +248,7 @@ module ActiveItem
|
|
|
248
248
|
load_records
|
|
249
249
|
@records
|
|
250
250
|
end
|
|
251
|
-
|
|
251
|
+
alias to_ary to_a
|
|
252
252
|
|
|
253
253
|
# Re-fetch full records from the main table via batch_find, or return
|
|
254
254
|
# already-loaded records if the initial query returned full items.
|
|
@@ -268,7 +268,7 @@ module ActiveItem
|
|
|
268
268
|
# Check if records already have full attributes (not just keys)
|
|
269
269
|
# A KEYS_ONLY GSI record will only have the primary key + sort key attributes
|
|
270
270
|
sample = records.first
|
|
271
|
-
attr_count = sample.class.attribute_names.count { |a| sample.instance_variable_get("@#{a}")
|
|
271
|
+
attr_count = sample.class.attribute_names.count { |a| !sample.instance_variable_get("@#{a}").nil? }
|
|
272
272
|
|
|
273
273
|
# If the record has more than just the key attributes, it's already fully hydrated
|
|
274
274
|
return records if attr_count > 2
|
|
@@ -305,10 +305,10 @@ module ActiveItem
|
|
|
305
305
|
# @example Find by block
|
|
306
306
|
# User.where(status: 'active').find { |u| u.email.include?('@example.com') }
|
|
307
307
|
#
|
|
308
|
-
def find(id = nil, &
|
|
308
|
+
def find(id = nil, &)
|
|
309
309
|
if block_given?
|
|
310
310
|
# Delegate to Enumerable#find when block is given (Rails behavior)
|
|
311
|
-
to_a.find(&
|
|
311
|
+
to_a.find(&)
|
|
312
312
|
elsif id
|
|
313
313
|
# Use direct GetItem instead of scanning — O(1) vs O(n)
|
|
314
314
|
record = resolved_model.find(id)
|
|
@@ -391,9 +391,7 @@ module ActiveItem
|
|
|
391
391
|
return { operation: :none, reason: 'empty relation' } if conditions[:_empty]
|
|
392
392
|
|
|
393
393
|
normalized_conditions = normalize_conditions(conditions)
|
|
394
|
-
effective_index = if normalized_conditions.any?
|
|
395
|
-
index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
|
|
396
|
-
end
|
|
394
|
+
effective_index = (index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) if normalized_conditions.any?)
|
|
397
395
|
|
|
398
396
|
if normalized_conditions.empty? && not_conditions.empty?
|
|
399
397
|
params = { table_name: resolved_model.table_name }
|
|
@@ -418,14 +416,14 @@ module ActiveItem
|
|
|
418
416
|
parts = []
|
|
419
417
|
parts << "conditions=#{conditions.inspect}" if conditions.any?
|
|
420
418
|
parts << "not_conditions=#{not_conditions.inspect}" if not_conditions.any?
|
|
421
|
-
parts <<
|
|
422
|
-
parts <<
|
|
419
|
+
parts << 'ilike=true' if ilike
|
|
420
|
+
parts << 'exact=true' if ilike_exact
|
|
423
421
|
"#<#{self.class.name} (not loaded) #{parts.join(' ')}>"
|
|
424
422
|
end
|
|
425
423
|
end
|
|
426
424
|
|
|
427
425
|
# Forward named scope calls to the model's scope registry
|
|
428
|
-
def method_missing(method_name, *args, &
|
|
426
|
+
def method_missing(method_name, *args, &)
|
|
429
427
|
if resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name)
|
|
430
428
|
instance_exec(&resolved_model._scopes[method_name])
|
|
431
429
|
else
|
|
@@ -465,7 +463,7 @@ module ActiveItem
|
|
|
465
463
|
return Object unless @class_name
|
|
466
464
|
|
|
467
465
|
# Lazy load the class when first needed
|
|
468
|
-
@
|
|
466
|
+
@resolved_model ||= safe_constantize_model(@class_name)
|
|
469
467
|
end
|
|
470
468
|
|
|
471
469
|
# Apply Ruby-side filter for case-insensitive matching
|
|
@@ -520,7 +518,7 @@ module ActiveItem
|
|
|
520
518
|
end
|
|
521
519
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
522
520
|
raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
|
|
523
|
-
|
|
521
|
+
operation: 'PaginatedQuery', original_error: e)
|
|
524
522
|
end
|
|
525
523
|
|
|
526
524
|
# Decode Base64 cursor to DynamoDB LastEvaluatedKey
|
|
@@ -529,7 +527,22 @@ module ActiveItem
|
|
|
529
527
|
|
|
530
528
|
require 'base64'
|
|
531
529
|
require 'json'
|
|
532
|
-
JSON.parse(Base64.urlsafe_decode64(cursor))
|
|
530
|
+
decoded = JSON.parse(Base64.urlsafe_decode64(cursor))
|
|
531
|
+
|
|
532
|
+
return nil unless decoded.is_a?(Hash)
|
|
533
|
+
return nil if decoded.empty?
|
|
534
|
+
|
|
535
|
+
# Only allow simple string/numeric values — reject nested objects, arrays, or expressions
|
|
536
|
+
allowed_key_pattern = /\A[a-zA-Z][a-zA-Z0-9_]*\z/
|
|
537
|
+
decoded.each do |key, value|
|
|
538
|
+
next if key.is_a?(String) && key.match?(allowed_key_pattern) &&
|
|
539
|
+
(value.is_a?(String) || value.is_a?(Numeric))
|
|
540
|
+
|
|
541
|
+
ActiveItem.logger.warn('Rejected pagination cursor: invalid key/value structure')
|
|
542
|
+
return nil
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
decoded
|
|
533
546
|
rescue ArgumentError, JSON::ParserError => e
|
|
534
547
|
ActiveItem.logger.warn("Invalid pagination cursor: #{e.message}")
|
|
535
548
|
nil
|
|
@@ -555,7 +568,7 @@ module ActiveItem
|
|
|
555
568
|
params = {
|
|
556
569
|
table_name: resolved_model.table_name,
|
|
557
570
|
index_name: idx_name,
|
|
558
|
-
key_condition_expression:
|
|
571
|
+
key_condition_expression: '#pk = :pk_val',
|
|
559
572
|
expression_attribute_names: { '#pk' => dynamo_partition_key },
|
|
560
573
|
expression_attribute_values: { ':pk_val' => partition_value },
|
|
561
574
|
limit: per_page
|
|
@@ -569,14 +582,14 @@ module ActiveItem
|
|
|
569
582
|
remaining_conditions = conditions.to_a[1..]
|
|
570
583
|
|
|
571
584
|
if sort_key && remaining_conditions.any?
|
|
572
|
-
sort_condition = remaining_conditions.find
|
|
585
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
573
586
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
574
|
-
|
|
587
|
+
end
|
|
575
588
|
if sort_condition
|
|
576
589
|
_, sort_value = sort_condition
|
|
577
|
-
remaining_conditions = remaining_conditions.reject
|
|
590
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
578
591
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
579
|
-
|
|
592
|
+
end
|
|
580
593
|
|
|
581
594
|
if sort_value.is_a?(Range)
|
|
582
595
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -584,7 +597,7 @@ module ActiveItem
|
|
|
584
597
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
585
598
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
586
599
|
else
|
|
587
|
-
params[:key_condition_expression] +=
|
|
600
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
588
601
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
589
602
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
590
603
|
end
|
|
@@ -712,25 +725,25 @@ module ActiveItem
|
|
|
712
725
|
normalized_conditions = normalize_conditions(conditions)
|
|
713
726
|
|
|
714
727
|
records = if normalized_conditions.empty? && not_conditions.empty?
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
+
# Direct scan - don't call model.all to avoid recursion
|
|
729
|
+
items = resolved_model.scan(limit: limit_value)
|
|
730
|
+
items.map { |item| resolved_model.instantiate(item) }
|
|
731
|
+
else
|
|
732
|
+
# Determine index to use (use normalized conditions)
|
|
733
|
+
effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
|
|
734
|
+
|
|
735
|
+
if effective_index && normalized_conditions.any?
|
|
736
|
+
query_with_index_normalized(effective_index, normalized_conditions)
|
|
737
|
+
else
|
|
738
|
+
scan_with_conditions_normalized(normalized_conditions)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
728
741
|
|
|
729
742
|
# Apply Ruby-side filter for case-insensitive matching
|
|
730
743
|
apply_ilike_filter(records)
|
|
731
744
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
732
745
|
raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
|
|
733
|
-
|
|
746
|
+
operation: 'Query/Scan', original_error: e)
|
|
734
747
|
end
|
|
735
748
|
|
|
736
749
|
# Execute a count-only query using DynamoDB SELECT: 'COUNT'
|
|
@@ -740,26 +753,24 @@ module ActiveItem
|
|
|
740
753
|
|
|
741
754
|
normalized_conditions = normalize_conditions(conditions)
|
|
742
755
|
|
|
743
|
-
effective_index = if normalized_conditions.any?
|
|
744
|
-
index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
|
|
745
|
-
end
|
|
756
|
+
effective_index = (index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) if normalized_conditions.any?)
|
|
746
757
|
|
|
747
758
|
total = 0
|
|
748
759
|
exclusive_start_key = nil
|
|
749
760
|
|
|
750
761
|
loop do
|
|
751
762
|
params = if effective_index && normalized_conditions.any?
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
763
|
+
build_count_query_params(effective_index, normalized_conditions)
|
|
764
|
+
else
|
|
765
|
+
build_count_scan_params(normalized_conditions)
|
|
766
|
+
end
|
|
756
767
|
params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
|
|
757
768
|
|
|
758
769
|
response = if effective_index && normalized_conditions.any?
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
770
|
+
resolved_model.dynamodb.query(params)
|
|
771
|
+
else
|
|
772
|
+
resolved_model.dynamodb.scan(params)
|
|
773
|
+
end
|
|
763
774
|
|
|
764
775
|
total += response.count
|
|
765
776
|
exclusive_start_key = response.last_evaluated_key
|
|
@@ -769,8 +780,9 @@ module ActiveItem
|
|
|
769
780
|
total
|
|
770
781
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
771
782
|
raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
|
|
772
|
-
|
|
783
|
+
operation: 'Count', original_error: e)
|
|
773
784
|
end
|
|
785
|
+
|
|
774
786
|
def build_count_query_params(idx_name, normalized_conditions)
|
|
775
787
|
ruby_partition_key = normalized_conditions.keys.first.to_s
|
|
776
788
|
partition_value = normalized_conditions.values.first
|
|
@@ -792,14 +804,14 @@ module ActiveItem
|
|
|
792
804
|
remaining_conditions = conditions.to_a[1..]
|
|
793
805
|
|
|
794
806
|
if sort_key && remaining_conditions.any?
|
|
795
|
-
sort_condition = remaining_conditions.find
|
|
807
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
796
808
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
797
|
-
|
|
809
|
+
end
|
|
798
810
|
if sort_condition
|
|
799
811
|
_, sort_value = sort_condition
|
|
800
|
-
remaining_conditions = remaining_conditions.reject
|
|
812
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
801
813
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
802
|
-
|
|
814
|
+
end
|
|
803
815
|
|
|
804
816
|
if sort_value.is_a?(Range)
|
|
805
817
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -807,7 +819,7 @@ module ActiveItem
|
|
|
807
819
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
808
820
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
809
821
|
else
|
|
810
|
-
params[:key_condition_expression] +=
|
|
822
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
811
823
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
812
824
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
813
825
|
end
|
|
@@ -877,7 +889,7 @@ module ActiveItem
|
|
|
877
889
|
params = {
|
|
878
890
|
table_name: resolved_model.table_name,
|
|
879
891
|
index_name: idx_name,
|
|
880
|
-
key_condition_expression:
|
|
892
|
+
key_condition_expression: '#pk = :pk_val',
|
|
881
893
|
expression_attribute_names: { '#pk' => dynamo_partition_key },
|
|
882
894
|
expression_attribute_values: { ':pk_val' => partition_value }
|
|
883
895
|
}
|
|
@@ -886,14 +898,14 @@ module ActiveItem
|
|
|
886
898
|
remaining_conditions = conditions.to_a[1..]
|
|
887
899
|
|
|
888
900
|
if sort_key && remaining_conditions.any?
|
|
889
|
-
sort_condition = remaining_conditions.find
|
|
901
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
890
902
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
891
|
-
|
|
903
|
+
end
|
|
892
904
|
if sort_condition
|
|
893
905
|
_, sort_value = sort_condition
|
|
894
|
-
remaining_conditions = remaining_conditions.reject
|
|
906
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
895
907
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
896
|
-
|
|
908
|
+
end
|
|
897
909
|
|
|
898
910
|
if sort_value.is_a?(Range)
|
|
899
911
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -901,7 +913,7 @@ module ActiveItem
|
|
|
901
913
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
902
914
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
903
915
|
else
|
|
904
|
-
params[:key_condition_expression] +=
|
|
916
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
905
917
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
906
918
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
907
919
|
end
|
|
@@ -1007,7 +1019,7 @@ module ActiveItem
|
|
|
1007
1019
|
foreign_key = association_config[:foreign_key]
|
|
1008
1020
|
|
|
1009
1021
|
# If the foreign key is different from the attribute name, return it
|
|
1010
|
-
foreign_key.to_s
|
|
1022
|
+
foreign_key.to_s == attr_name ? nil : foreign_key.to_s
|
|
1011
1023
|
end
|
|
1012
1024
|
|
|
1013
1025
|
def query_with_index_normalized(idx_name, normalized_conditions)
|
|
@@ -1020,18 +1032,14 @@ module ActiveItem
|
|
|
1020
1032
|
index_config = resolved_model.indexes[idx_name] || {}
|
|
1021
1033
|
dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
|
|
1022
1034
|
|
|
1023
|
-
if partition_value.is_a?(Array)
|
|
1024
|
-
raise ArgumentError, "Array values not supported for partition key queries. Use scan instead."
|
|
1025
|
-
end
|
|
1035
|
+
raise ArgumentError, 'Array values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Array)
|
|
1026
1036
|
|
|
1027
|
-
if partition_value.is_a?(Range)
|
|
1028
|
-
raise ArgumentError, "Range values not supported for partition key queries. Use scan instead."
|
|
1029
|
-
end
|
|
1037
|
+
raise ArgumentError, 'Range values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Range)
|
|
1030
1038
|
|
|
1031
1039
|
params = {
|
|
1032
1040
|
table_name: resolved_model.table_name,
|
|
1033
1041
|
index_name: idx_name,
|
|
1034
|
-
key_condition_expression:
|
|
1042
|
+
key_condition_expression: '#pk = :pk_val',
|
|
1035
1043
|
expression_attribute_names: { '#pk' => dynamo_partition_key },
|
|
1036
1044
|
expression_attribute_values: { ':pk_val' => partition_value }
|
|
1037
1045
|
}
|
|
@@ -1043,14 +1051,14 @@ module ActiveItem
|
|
|
1043
1051
|
|
|
1044
1052
|
if sort_key && remaining_conditions.any?
|
|
1045
1053
|
# Find the sort key condition by matching the DynamoDB key name
|
|
1046
|
-
sort_condition = remaining_conditions.find
|
|
1054
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
1047
1055
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
1048
|
-
|
|
1056
|
+
end
|
|
1049
1057
|
if sort_condition
|
|
1050
1058
|
_, sort_value = sort_condition
|
|
1051
|
-
remaining_conditions = remaining_conditions.reject
|
|
1059
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
1052
1060
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
1053
|
-
|
|
1061
|
+
end
|
|
1054
1062
|
|
|
1055
1063
|
if sort_value.is_a?(Range)
|
|
1056
1064
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -1058,7 +1066,7 @@ module ActiveItem
|
|
|
1058
1066
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
1059
1067
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
1060
1068
|
else
|
|
1061
|
-
params[:key_condition_expression] +=
|
|
1069
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
1062
1070
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
1063
1071
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
1064
1072
|
end
|
|
@@ -1163,7 +1171,7 @@ module ActiveItem
|
|
|
1163
1171
|
|
|
1164
1172
|
# Always include the raw primary key if it differs from 'id'
|
|
1165
1173
|
unless names.values.include?(pk)
|
|
1166
|
-
placeholder =
|
|
1174
|
+
placeholder = '#proj_pk'
|
|
1167
1175
|
names[placeholder] = pk
|
|
1168
1176
|
placeholders << placeholder
|
|
1169
1177
|
end
|
|
@@ -1197,9 +1205,7 @@ module ActiveItem
|
|
|
1197
1205
|
dynamo_attr = resolved_model.to_dynamo_key(attr_str)
|
|
1198
1206
|
|
|
1199
1207
|
# Handle nil - use attribute_exists (opposite of attribute_not_exists)
|
|
1200
|
-
if val.nil?
|
|
1201
|
-
return ["attribute_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}]
|
|
1202
|
-
end
|
|
1208
|
+
return ["attribute_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}] if val.nil?
|
|
1203
1209
|
|
|
1204
1210
|
# Handle array values (NOT IN)
|
|
1205
1211
|
if val.is_a?(Array)
|
|
@@ -1259,7 +1265,11 @@ module ActiveItem
|
|
|
1259
1265
|
|
|
1260
1266
|
case mode
|
|
1261
1267
|
when :count
|
|
1262
|
-
|
|
1268
|
+
unless assoc_config[:type] == :has_many
|
|
1269
|
+
raise ArgumentError,
|
|
1270
|
+
"count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})"
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1263
1273
|
preload_has_many_counts(records, assoc_name, assoc_config)
|
|
1264
1274
|
when :records
|
|
1265
1275
|
preload_symbol_association(records, assoc_name)
|
|
@@ -1278,7 +1288,7 @@ module ActiveItem
|
|
|
1278
1288
|
return if foreign_ids.empty?
|
|
1279
1289
|
|
|
1280
1290
|
# Batch load associated records
|
|
1281
|
-
associated_records = assoc_class.batch_find(foreign_ids).
|
|
1291
|
+
associated_records = assoc_class.batch_find(foreign_ids).to_h { |r| [r.id, r] }
|
|
1282
1292
|
|
|
1283
1293
|
# Cache associations on each record
|
|
1284
1294
|
records.each do |record|
|
|
@@ -1311,18 +1321,18 @@ module ActiveItem
|
|
|
1311
1321
|
end
|
|
1312
1322
|
|
|
1313
1323
|
counts_by_parent = if assoc_class == resolved_model && conditions.empty? && not_conditions.empty? && !@_paginated
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1324
|
+
# Self-referential with full table loaded — count in memory
|
|
1325
|
+
tally = Hash.new(0)
|
|
1326
|
+
records.each do |r|
|
|
1327
|
+
fk_val = r.send(foreign_key)
|
|
1328
|
+
tally[fk_val] += 1 if fk_val && parent_ids.include?(fk_val)
|
|
1329
|
+
end
|
|
1330
|
+
tally
|
|
1331
|
+
elsif index_name
|
|
1332
|
+
query_counts_via_index(assoc_class, index_name, dynamo_fk, parent_ids)
|
|
1333
|
+
else
|
|
1334
|
+
scan_and_count_foreign_keys(assoc_class, dynamo_fk, parent_ids)
|
|
1335
|
+
end
|
|
1326
1336
|
|
|
1327
1337
|
records.each do |record|
|
|
1328
1338
|
pk_value = record.send(local_key)
|
|
@@ -1437,9 +1447,9 @@ module ActiveItem
|
|
|
1437
1447
|
parent_ids.each_slice(max_concurrency) do |batch|
|
|
1438
1448
|
threads = batch.map do |pk_value|
|
|
1439
1449
|
Thread.new do
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1450
|
+
all_items = []
|
|
1451
|
+
exclusive_start_key = nil
|
|
1452
|
+
if index_name
|
|
1443
1453
|
loop do
|
|
1444
1454
|
params = {
|
|
1445
1455
|
table_name: assoc_class.table_name, index_name: index_name,
|
|
@@ -1453,10 +1463,7 @@ module ActiveItem
|
|
|
1453
1463
|
exclusive_start_key = response.last_evaluated_key
|
|
1454
1464
|
break unless exclusive_start_key
|
|
1455
1465
|
end
|
|
1456
|
-
all_items
|
|
1457
1466
|
else
|
|
1458
|
-
all_items = []
|
|
1459
|
-
exclusive_start_key = nil
|
|
1460
1467
|
loop do
|
|
1461
1468
|
params = {
|
|
1462
1469
|
table_name: assoc_class.table_name,
|
|
@@ -1470,8 +1477,8 @@ module ActiveItem
|
|
|
1470
1477
|
exclusive_start_key = response.last_evaluated_key
|
|
1471
1478
|
break unless exclusive_start_key
|
|
1472
1479
|
end
|
|
1473
|
-
all_items
|
|
1474
1480
|
end
|
|
1481
|
+
items = all_items
|
|
1475
1482
|
|
|
1476
1483
|
instantiated = items.map { |item| assoc_class.instantiate(item) }
|
|
1477
1484
|
mutex.synchronize { records_by_parent[pk_value] = instantiated }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ActiveItem
|
|
4
|
+
# Wraps DynamoDB TransactWriteItems, allowing multiple put, update, and
|
|
5
|
+
# delete operations to be committed atomically (up to 100 items).
|
|
4
6
|
class Transaction
|
|
5
7
|
MAX_ITEMS = 100
|
|
6
8
|
|
|
@@ -57,7 +59,7 @@ module ActiveItem
|
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
|
|
60
|
-
set_parts <<
|
|
62
|
+
set_parts << 'updatedAt = :ts'
|
|
61
63
|
attr_values[':ts'] = Time.now.utc.iso8601
|
|
62
64
|
|
|
63
65
|
update_expression = "SET #{set_parts.join(', ')}"
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'active_model'
|
|
4
4
|
|
|
5
5
|
module ActiveItem
|
|
6
|
+
# ActiveModel validator that checks attribute uniqueness by querying
|
|
7
|
+
# DynamoDB, with optional scope and custom condition support.
|
|
6
8
|
class UniquenessValidator < ActiveModel::EachValidator
|
|
7
9
|
def validate_each(record, attribute, value)
|
|
8
10
|
return if value.nil? || value.to_s.empty?
|
|
@@ -22,9 +24,11 @@ module ActiveItem
|
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
|
|
27
|
+
# Convenience validation macros (uniqueness, length, numericality, format)
|
|
28
|
+
# that extend ActiveModel::Validations for DynamoDB-backed models.
|
|
25
29
|
module Validations
|
|
26
30
|
def validates_uniqueness_of(*attributes, **options)
|
|
27
|
-
validates(*attributes, uniqueness: options.empty?
|
|
31
|
+
validates(*attributes, uniqueness: options.empty? || options)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def validates_length_of(*attributes, **options)
|
|
@@ -35,18 +39,12 @@ module ActiveItem
|
|
|
35
39
|
|
|
36
40
|
length = value.to_s.length
|
|
37
41
|
|
|
38
|
-
if options[:minimum] && length < options[:minimum]
|
|
39
|
-
|
|
40
|
-
end
|
|
41
|
-
if options[:maximum] && length > options[:maximum]
|
|
42
|
-
errors.add(attribute, options[:message] || "is too long (maximum is #{options[:maximum]} characters)")
|
|
43
|
-
end
|
|
42
|
+
errors.add(attribute, options[:message] || "is too short (minimum is #{options[:minimum]} characters)") if options[:minimum] && length < options[:minimum]
|
|
43
|
+
errors.add(attribute, options[:message] || "is too long (maximum is #{options[:maximum]} characters)") if options[:maximum] && length > options[:maximum]
|
|
44
44
|
if options[:in] && !options[:in].include?(length)
|
|
45
45
|
errors.add(attribute, options[:message] || "length must be between #{options[:in].min} and #{options[:in].max} characters")
|
|
46
46
|
end
|
|
47
|
-
if options[:is] && length != options[:is]
|
|
48
|
-
errors.add(attribute, options[:message] || "must be exactly #{options[:is]} characters")
|
|
49
|
-
end
|
|
47
|
+
errors.add(attribute, options[:message] || "must be exactly #{options[:is]} characters") if options[:is] && length != options[:is]
|
|
50
48
|
end
|
|
51
49
|
end
|
|
52
50
|
end
|
|
@@ -58,32 +56,26 @@ module ActiveItem
|
|
|
58
56
|
next if value.nil?
|
|
59
57
|
|
|
60
58
|
unless value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+(\.\d+)?\z/)
|
|
61
|
-
errors.add(attribute, options[:message] ||
|
|
59
|
+
errors.add(attribute, options[:message] || 'is not a number')
|
|
62
60
|
next
|
|
63
61
|
end
|
|
64
62
|
|
|
65
63
|
num_value = value.to_f
|
|
66
64
|
|
|
67
65
|
if options[:only_integer] && num_value != num_value.to_i
|
|
68
|
-
errors.add(attribute, options[:message] ||
|
|
66
|
+
errors.add(attribute, options[:message] || 'must be an integer')
|
|
69
67
|
next
|
|
70
68
|
end
|
|
71
69
|
|
|
72
|
-
if options[:greater_than] && num_value <= options[:greater_than]
|
|
73
|
-
errors.add(attribute, options[:message] || "must be greater than #{options[:greater_than]}")
|
|
74
|
-
end
|
|
70
|
+
errors.add(attribute, options[:message] || "must be greater than #{options[:greater_than]}") if options[:greater_than] && num_value <= options[:greater_than]
|
|
75
71
|
if options[:greater_than_or_equal_to] && num_value < options[:greater_than_or_equal_to]
|
|
76
72
|
errors.add(attribute, options[:message] || "must be greater than or equal to #{options[:greater_than_or_equal_to]}")
|
|
77
73
|
end
|
|
78
|
-
if options[:less_than] && num_value >= options[:less_than]
|
|
79
|
-
errors.add(attribute, options[:message] || "must be less than #{options[:less_than]}")
|
|
80
|
-
end
|
|
74
|
+
errors.add(attribute, options[:message] || "must be less than #{options[:less_than]}") if options[:less_than] && num_value >= options[:less_than]
|
|
81
75
|
if options[:less_than_or_equal_to] && num_value > options[:less_than_or_equal_to]
|
|
82
76
|
errors.add(attribute, options[:message] || "must be less than or equal to #{options[:less_than_or_equal_to]}")
|
|
83
77
|
end
|
|
84
|
-
if options[:equal_to] && num_value != options[:equal_to]
|
|
85
|
-
errors.add(attribute, options[:message] || "must be equal to #{options[:equal_to]}")
|
|
86
|
-
end
|
|
78
|
+
errors.add(attribute, options[:message] || "must be equal to #{options[:equal_to]}") if options[:equal_to] && num_value != options[:equal_to]
|
|
87
79
|
end
|
|
88
80
|
end
|
|
89
81
|
end
|
data/lib/active_item/version.rb
CHANGED