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.
@@ -1,4 +1,4 @@
1
- # encoding: utf-8
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, :order_direction, :select_attributes
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, owner: nil, preloaded_records: nil, includes_associations: [], order_direction: nil, select_attributes: 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 # Use case-insensitive contains() for string conditions
24
- @ilike_exact = ilike_exact # Require exact case-insensitive match (Ruby-side filter)
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 # :asc or :desc for DynamoDB ScanIndexForward
29
- @select_attributes = select_attributes # Projection expression 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, &block)
134
+ def select(*attrs, &)
135
135
  if block_given?
136
- super(&block)
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 = [[per_page.to_i, 1].max, Pagination::MAX_PER_PAGE].min
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(&block)
184
+ def each(&)
185
185
  load_records
186
- @records.each(&block)
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(&block)
209
+ def count(&)
210
210
  if block_given? || ilike || ilike_exact
211
211
  load_records
212
- return block_given? ? @records.count(&block) : @records.length
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
- alias_method :size, :length
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 == 0
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
- alias_method :to_ary, :to_a
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}") != nil }
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, &block)
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(&block)
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 << "ilike=true" if ilike
422
- parts << "exact=true" if ilike_exact
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, &block)
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
- @model ||= safe_constantize_model(@class_name)
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
- operation: 'PaginatedQuery', original_error: e)
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: "#pk = :pk_val",
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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
- # Direct scan - don't call model.all to avoid recursion
716
- items = resolved_model.scan(limit: limit_value)
717
- items.map { |item| resolved_model.instantiate(item) }
718
- else
719
- # Determine index to use (use normalized conditions)
720
- effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
721
-
722
- if effective_index && normalized_conditions.any?
723
- query_with_index_normalized(effective_index, normalized_conditions)
724
- else
725
- scan_with_conditions_normalized(normalized_conditions)
726
- end
727
- end
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
- operation: 'Query/Scan', original_error: e)
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
- build_count_query_params(effective_index, normalized_conditions)
753
- else
754
- build_count_scan_params(normalized_conditions)
755
- end
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
- resolved_model.dynamodb.query(params)
760
- else
761
- resolved_model.dynamodb.scan(params)
762
- end
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
- operation: 'Count', original_error: e)
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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: "#pk = :pk_val",
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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 != attr_name ? foreign_key.to_s : nil
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: "#pk = :pk_val",
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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 = "#proj_pk"
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
- raise ArgumentError, "count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})" unless assoc_config[:type] == :has_many
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).index_by(&:id)
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
- # Self-referential with full table loaded — count in memory
1315
- tally = Hash.new(0)
1316
- records.each do |r|
1317
- fk_val = r.send(foreign_key)
1318
- tally[fk_val] += 1 if fk_val && parent_ids.include?(fk_val)
1319
- end
1320
- tally
1321
- elsif index_name
1322
- query_counts_via_index(assoc_class, index_name, dynamo_fk, parent_ids)
1323
- else
1324
- scan_and_count_foreign_keys(assoc_class, dynamo_fk, parent_ids)
1325
- end
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
- items = if index_name
1441
- all_items = []
1442
- exclusive_start_key = nil
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 << "updatedAt = :ts"
62
+ set_parts << 'updatedAt = :ts'
61
63
  attr_values[':ts'] = Time.now.utc.iso8601
62
64
 
63
65
  update_expression = "SET #{set_parts.join(', ')}"