activeitem 0.0.1 → 0.0.3
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 +36 -0
- data/LICENSE.txt +1 -1
- data/lib/active_item/associations.rb +21 -24
- data/lib/active_item/base.rb +88 -98
- 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 +122 -113
- data/lib/active_item/transaction.rb +3 -1
- data/lib/active_item/validations.rb +22 -70
- 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,37 +753,38 @@ 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
|
|
768
|
+
params[:limit] = limit_value if limit_value
|
|
757
769
|
|
|
758
770
|
response = if effective_index && normalized_conditions.any?
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
771
|
+
resolved_model.dynamodb.query(params)
|
|
772
|
+
else
|
|
773
|
+
resolved_model.dynamodb.scan(params)
|
|
774
|
+
end
|
|
763
775
|
|
|
764
776
|
total += response.count
|
|
765
777
|
exclusive_start_key = response.last_evaluated_key
|
|
766
778
|
break unless exclusive_start_key
|
|
779
|
+
break if limit_value && total >= limit_value
|
|
767
780
|
end
|
|
768
781
|
|
|
769
|
-
total
|
|
782
|
+
limit_value ? [total, limit_value].min : total
|
|
770
783
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
771
784
|
raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
|
|
772
|
-
|
|
785
|
+
operation: 'Count', original_error: e)
|
|
773
786
|
end
|
|
787
|
+
|
|
774
788
|
def build_count_query_params(idx_name, normalized_conditions)
|
|
775
789
|
ruby_partition_key = normalized_conditions.keys.first.to_s
|
|
776
790
|
partition_value = normalized_conditions.values.first
|
|
@@ -792,14 +806,14 @@ module ActiveItem
|
|
|
792
806
|
remaining_conditions = conditions.to_a[1..]
|
|
793
807
|
|
|
794
808
|
if sort_key && remaining_conditions.any?
|
|
795
|
-
sort_condition = remaining_conditions.find
|
|
809
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
796
810
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
797
|
-
|
|
811
|
+
end
|
|
798
812
|
if sort_condition
|
|
799
813
|
_, sort_value = sort_condition
|
|
800
|
-
remaining_conditions = remaining_conditions.reject
|
|
814
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
801
815
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
802
|
-
|
|
816
|
+
end
|
|
803
817
|
|
|
804
818
|
if sort_value.is_a?(Range)
|
|
805
819
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -807,7 +821,7 @@ module ActiveItem
|
|
|
807
821
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
808
822
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
809
823
|
else
|
|
810
|
-
params[:key_condition_expression] +=
|
|
824
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
811
825
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
812
826
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
813
827
|
end
|
|
@@ -877,7 +891,7 @@ module ActiveItem
|
|
|
877
891
|
params = {
|
|
878
892
|
table_name: resolved_model.table_name,
|
|
879
893
|
index_name: idx_name,
|
|
880
|
-
key_condition_expression:
|
|
894
|
+
key_condition_expression: '#pk = :pk_val',
|
|
881
895
|
expression_attribute_names: { '#pk' => dynamo_partition_key },
|
|
882
896
|
expression_attribute_values: { ':pk_val' => partition_value }
|
|
883
897
|
}
|
|
@@ -886,14 +900,14 @@ module ActiveItem
|
|
|
886
900
|
remaining_conditions = conditions.to_a[1..]
|
|
887
901
|
|
|
888
902
|
if sort_key && remaining_conditions.any?
|
|
889
|
-
sort_condition = remaining_conditions.find
|
|
903
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
890
904
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
891
|
-
|
|
905
|
+
end
|
|
892
906
|
if sort_condition
|
|
893
907
|
_, sort_value = sort_condition
|
|
894
|
-
remaining_conditions = remaining_conditions.reject
|
|
908
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
895
909
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
896
|
-
|
|
910
|
+
end
|
|
897
911
|
|
|
898
912
|
if sort_value.is_a?(Range)
|
|
899
913
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -901,7 +915,7 @@ module ActiveItem
|
|
|
901
915
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
902
916
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
903
917
|
else
|
|
904
|
-
params[:key_condition_expression] +=
|
|
918
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
905
919
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
906
920
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
907
921
|
end
|
|
@@ -1007,7 +1021,7 @@ module ActiveItem
|
|
|
1007
1021
|
foreign_key = association_config[:foreign_key]
|
|
1008
1022
|
|
|
1009
1023
|
# If the foreign key is different from the attribute name, return it
|
|
1010
|
-
foreign_key.to_s
|
|
1024
|
+
foreign_key.to_s == attr_name ? nil : foreign_key.to_s
|
|
1011
1025
|
end
|
|
1012
1026
|
|
|
1013
1027
|
def query_with_index_normalized(idx_name, normalized_conditions)
|
|
@@ -1020,18 +1034,14 @@ module ActiveItem
|
|
|
1020
1034
|
index_config = resolved_model.indexes[idx_name] || {}
|
|
1021
1035
|
dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
|
|
1022
1036
|
|
|
1023
|
-
if partition_value.is_a?(Array)
|
|
1024
|
-
raise ArgumentError, "Array values not supported for partition key queries. Use scan instead."
|
|
1025
|
-
end
|
|
1037
|
+
raise ArgumentError, 'Array values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Array)
|
|
1026
1038
|
|
|
1027
|
-
if partition_value.is_a?(Range)
|
|
1028
|
-
raise ArgumentError, "Range values not supported for partition key queries. Use scan instead."
|
|
1029
|
-
end
|
|
1039
|
+
raise ArgumentError, 'Range values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Range)
|
|
1030
1040
|
|
|
1031
1041
|
params = {
|
|
1032
1042
|
table_name: resolved_model.table_name,
|
|
1033
1043
|
index_name: idx_name,
|
|
1034
|
-
key_condition_expression:
|
|
1044
|
+
key_condition_expression: '#pk = :pk_val',
|
|
1035
1045
|
expression_attribute_names: { '#pk' => dynamo_partition_key },
|
|
1036
1046
|
expression_attribute_values: { ':pk_val' => partition_value }
|
|
1037
1047
|
}
|
|
@@ -1043,14 +1053,14 @@ module ActiveItem
|
|
|
1043
1053
|
|
|
1044
1054
|
if sort_key && remaining_conditions.any?
|
|
1045
1055
|
# Find the sort key condition by matching the DynamoDB key name
|
|
1046
|
-
sort_condition = remaining_conditions.find
|
|
1056
|
+
sort_condition = remaining_conditions.find do |k, _|
|
|
1047
1057
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
1048
|
-
|
|
1058
|
+
end
|
|
1049
1059
|
if sort_condition
|
|
1050
1060
|
_, sort_value = sort_condition
|
|
1051
|
-
remaining_conditions = remaining_conditions.reject
|
|
1061
|
+
remaining_conditions = remaining_conditions.reject do |k, _|
|
|
1052
1062
|
resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
|
|
1053
|
-
|
|
1063
|
+
end
|
|
1054
1064
|
|
|
1055
1065
|
if sort_value.is_a?(Range)
|
|
1056
1066
|
range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
|
|
@@ -1058,7 +1068,7 @@ module ActiveItem
|
|
|
1058
1068
|
params[:expression_attribute_names].merge!(range_condition[:names])
|
|
1059
1069
|
params[:expression_attribute_values].merge!(range_condition[:values])
|
|
1060
1070
|
else
|
|
1061
|
-
params[:key_condition_expression] +=
|
|
1071
|
+
params[:key_condition_expression] += ' AND #sk = :sk_val'
|
|
1062
1072
|
params[:expression_attribute_names]['#sk'] = sort_key
|
|
1063
1073
|
params[:expression_attribute_values][':sk_val'] = sort_value
|
|
1064
1074
|
end
|
|
@@ -1163,7 +1173,7 @@ module ActiveItem
|
|
|
1163
1173
|
|
|
1164
1174
|
# Always include the raw primary key if it differs from 'id'
|
|
1165
1175
|
unless names.values.include?(pk)
|
|
1166
|
-
placeholder =
|
|
1176
|
+
placeholder = '#proj_pk'
|
|
1167
1177
|
names[placeholder] = pk
|
|
1168
1178
|
placeholders << placeholder
|
|
1169
1179
|
end
|
|
@@ -1197,9 +1207,7 @@ module ActiveItem
|
|
|
1197
1207
|
dynamo_attr = resolved_model.to_dynamo_key(attr_str)
|
|
1198
1208
|
|
|
1199
1209
|
# 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
|
|
1210
|
+
return ["attribute_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}] if val.nil?
|
|
1203
1211
|
|
|
1204
1212
|
# Handle array values (NOT IN)
|
|
1205
1213
|
if val.is_a?(Array)
|
|
@@ -1259,7 +1267,11 @@ module ActiveItem
|
|
|
1259
1267
|
|
|
1260
1268
|
case mode
|
|
1261
1269
|
when :count
|
|
1262
|
-
|
|
1270
|
+
unless assoc_config[:type] == :has_many
|
|
1271
|
+
raise ArgumentError,
|
|
1272
|
+
"count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})"
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1263
1275
|
preload_has_many_counts(records, assoc_name, assoc_config)
|
|
1264
1276
|
when :records
|
|
1265
1277
|
preload_symbol_association(records, assoc_name)
|
|
@@ -1278,7 +1290,7 @@ module ActiveItem
|
|
|
1278
1290
|
return if foreign_ids.empty?
|
|
1279
1291
|
|
|
1280
1292
|
# Batch load associated records
|
|
1281
|
-
associated_records = assoc_class.batch_find(foreign_ids).
|
|
1293
|
+
associated_records = assoc_class.batch_find(foreign_ids).to_h { |r| [r.id, r] }
|
|
1282
1294
|
|
|
1283
1295
|
# Cache associations on each record
|
|
1284
1296
|
records.each do |record|
|
|
@@ -1311,18 +1323,18 @@ module ActiveItem
|
|
|
1311
1323
|
end
|
|
1312
1324
|
|
|
1313
1325
|
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
|
-
|
|
1326
|
+
# Self-referential with full table loaded — count in memory
|
|
1327
|
+
tally = Hash.new(0)
|
|
1328
|
+
records.each do |r|
|
|
1329
|
+
fk_val = r.send(foreign_key)
|
|
1330
|
+
tally[fk_val] += 1 if fk_val && parent_ids.include?(fk_val)
|
|
1331
|
+
end
|
|
1332
|
+
tally
|
|
1333
|
+
elsif index_name
|
|
1334
|
+
query_counts_via_index(assoc_class, index_name, dynamo_fk, parent_ids)
|
|
1335
|
+
else
|
|
1336
|
+
scan_and_count_foreign_keys(assoc_class, dynamo_fk, parent_ids)
|
|
1337
|
+
end
|
|
1326
1338
|
|
|
1327
1339
|
records.each do |record|
|
|
1328
1340
|
pk_value = record.send(local_key)
|
|
@@ -1437,9 +1449,9 @@ module ActiveItem
|
|
|
1437
1449
|
parent_ids.each_slice(max_concurrency) do |batch|
|
|
1438
1450
|
threads = batch.map do |pk_value|
|
|
1439
1451
|
Thread.new do
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1452
|
+
all_items = []
|
|
1453
|
+
exclusive_start_key = nil
|
|
1454
|
+
if index_name
|
|
1443
1455
|
loop do
|
|
1444
1456
|
params = {
|
|
1445
1457
|
table_name: assoc_class.table_name, index_name: index_name,
|
|
@@ -1453,10 +1465,7 @@ module ActiveItem
|
|
|
1453
1465
|
exclusive_start_key = response.last_evaluated_key
|
|
1454
1466
|
break unless exclusive_start_key
|
|
1455
1467
|
end
|
|
1456
|
-
all_items
|
|
1457
1468
|
else
|
|
1458
|
-
all_items = []
|
|
1459
|
-
exclusive_start_key = nil
|
|
1460
1469
|
loop do
|
|
1461
1470
|
params = {
|
|
1462
1471
|
table_name: assoc_class.table_name,
|
|
@@ -1470,8 +1479,8 @@ module ActiveItem
|
|
|
1470
1479
|
exclusive_start_key = response.last_evaluated_key
|
|
1471
1480
|
break unless exclusive_start_key
|
|
1472
1481
|
end
|
|
1473
|
-
all_items
|
|
1474
1482
|
end
|
|
1483
|
+
items = all_items
|
|
1475
1484
|
|
|
1476
1485
|
instantiated = items.map { |item| assoc_class.instantiate(item) }
|
|
1477
1486
|
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(', ')}"
|