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.
@@ -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,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
- 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
757
768
 
758
769
  response = if effective_index && normalized_conditions.any?
759
- resolved_model.dynamodb.query(params)
760
- else
761
- resolved_model.dynamodb.scan(params)
762
- end
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
- operation: 'Count', original_error: e)
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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: "#pk = :pk_val",
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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 != attr_name ? foreign_key.to_s : nil
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: "#pk = :pk_val",
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 { |k, _|
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 { |k, _|
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] += " AND #sk = :sk_val"
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 = "#proj_pk"
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
- raise ArgumentError, "count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})" unless assoc_config[:type] == :has_many
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).index_by(&:id)
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
- # 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
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
- items = if index_name
1441
- all_items = []
1442
- exclusive_start_key = nil
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 << "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(', ')}"
@@ -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? ? true : options)
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
- errors.add(attribute, options[:message] || "is too short (minimum is #{options[:minimum]} characters)")
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] || "is not a number")
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] || "must be an integer")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.2'
5
5
  end