activeitem 0.0.20 → 0.0.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 652850a906bc798042da7b5c3ea1396aeef9775057ad1199030354cff2b24b73
4
- data.tar.gz: 55001585d52f922c371cd6ca6cc61beef3f6f5ea98e4ca268d0bfe1e6ebf8d5a
3
+ metadata.gz: a7d5990fc9c0532dc9a65f803bd9886c1769dc2b4c3071fd4748c079fa7559b3
4
+ data.tar.gz: 1c480246c6a898502eacedda1b456e003f54910a3f8562893cc5c070c06f168e
5
5
  SHA512:
6
- metadata.gz: 64fb13d70a43f1f6a5668c3e93b6e1f24ceec853ea6f7d9e974a7fd5b53c7a2f8b66e9d582d6f12d10498a892a10895712092b1bbcac4929c6cad86fc164d770
7
- data.tar.gz: e6495e6752c6b8ffc0b8ef98451141ad8816fe069e4e33a40c7b65f45b4bf02981390bfab503149fce243e354d9842e1ce02bfb9f75f270af40dc5593d51ab5a
6
+ metadata.gz: db67b991fe6c8c9ed9ce18a362dc15dfd9f1b46c63ed20a49e8b1f82bf0bb2dfe3738b1e2f7d7b29a31396e61d40ee3e6d528393b01a93a92bf81c2e9d9f234b
7
+ data.tar.gz: dc5a3c76df184f02bf6fa4f0cd040e9a98e42729326f35243b198fc2d7c8ae4e4a86429bdc457be7f7e9198892ba93aaddf5a4d5650806afcba6cb528d6d54e7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.22
4
+
5
+ ### Changed
6
+
7
+ - **Scoped `find` on associations** — `Relation#find(id)` now enforces association scope, matching Rails behavior. When called on an association (e.g., `@project.epics.find(id)`), it validates that the found record belongs to the association before returning it. If the record exists but belongs to a different parent, `ActiveItem::RecordNotFound` is raised. This prevents accidentally accessing records outside the association scope.
8
+
9
+ ```ruby
10
+ # Before: would return the epic even if it belonged to a different project
11
+ @project.epics.find(epic_id)
12
+
13
+ # After: raises RecordNotFound if epic.project_id != @project.id
14
+ @project.epics.find(epic_id)
15
+ ```
16
+
17
+ ## 0.0.21
18
+
19
+ ### Added
20
+
21
+ - **GSI key type validation** — ActiveItem now validates that Global Secondary Index (GSI) key attributes have valid DynamoDB types at write time. Valid types are: String (non-empty), Numeric (Integer, Float, BigDecimal), and Binary (StringIO). Invalid types (Array, Hash, Boolean, Symbol, Date, Time, empty strings) now raise `ActiveItem::InvalidGsiKeyTypeError` immediately, with a helpful error message identifying the attribute and index. This catches common bugs like passing `Date.today` instead of `date.to_s`, or accidentally setting an array where a string was expected.
22
+
23
+ ```ruby
24
+ # Raises InvalidGsiKeyTypeError: Item#status has invalid type Array for GSI 'StatusIndex'
25
+ item = Item.new(status: ['a', 'b', 'c'])
26
+ item.save
27
+
28
+ # Raises InvalidGsiKeyTypeError for empty strings
29
+ item = Item.new(status: '')
30
+ item.save
31
+ ```
32
+
3
33
  ## 0.0.19
4
34
 
5
35
  ### Added
@@ -752,6 +752,8 @@ module ActiveItem
752
752
  item[dynamo_key] = collection.map(&:to_embedded_hash)
753
753
  end
754
754
 
755
+ validate_gsi_key_types!(item)
756
+
755
757
  item
756
758
  end
757
759
 
@@ -776,6 +778,9 @@ module ActiveItem
776
778
  embedded_changed = embedded_associations_changed?
777
779
  return if changes.empty? && !embedded_changed
778
780
 
781
+ # Validate GSI key types for changed attributes before building the update
782
+ validate_gsi_key_types_for_changes!
783
+
779
784
  run_embedded_callbacks(:update) if embedded_changed
780
785
  run_embedded_callbacks(:save) if embedded_changed
781
786
 
@@ -854,5 +859,102 @@ module ActiveItem
854
859
  raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
855
860
  operation: 'DeleteItem', original_error: e)
856
861
  end
862
+
863
+ # Validates that all GSI key attributes have valid types for DynamoDB.
864
+ # GSI keys can only be String, Number (Integer/Float/BigDecimal), or Binary (StringIO).
865
+ #
866
+ # @param item [Hash] The DynamoDB item being built
867
+ # @raise [InvalidGsiKeyTypeError] if any GSI key has an invalid type
868
+ def validate_gsi_key_types!(item)
869
+ return unless self.class.respond_to?(:indexes)
870
+
871
+ indexes = self.class.indexes
872
+ return if indexes.nil? || indexes.empty?
873
+
874
+ indexes.each do |index_name, config|
875
+ # Check partition key
876
+ partition_key = config[:partition_key]&.to_s
877
+ if partition_key && item.key?(partition_key)
878
+ value = item[partition_key]
879
+ validate_gsi_key_value!(partition_key, value, index_name) unless value.nil?
880
+ end
881
+
882
+ # Check sort key if present
883
+ sort_key = config[:sort_key]&.to_s
884
+ if sort_key && item.key?(sort_key)
885
+ value = item[sort_key]
886
+ validate_gsi_key_value!(sort_key, value, index_name) unless value.nil?
887
+ end
888
+ end
889
+ end
890
+
891
+ # Validates GSI key types for changed attributes during updates.
892
+ # Only validates the attributes that are being changed, not the entire item.
893
+ #
894
+ # @raise [InvalidGsiKeyTypeError] if any changed GSI key has an invalid type
895
+ def validate_gsi_key_types_for_changes!
896
+ return unless self.class.respond_to?(:indexes)
897
+
898
+ indexes = self.class.indexes
899
+ return if indexes.nil? || indexes.empty?
900
+
901
+ # Build a hash of changed dynamo keys and their new values
902
+ changed_dynamo_keys = {}
903
+ changes.each do |field, (_old_val, new_val)|
904
+ next if new_val.nil? # nil values are being removed, no type validation needed
905
+
906
+ dynamo_key = self.class.to_dynamo_key(field)
907
+ changed_dynamo_keys[dynamo_key] = new_val
908
+ end
909
+
910
+ return if changed_dynamo_keys.empty?
911
+
912
+ indexes.each do |index_name, config|
913
+ # Check partition key if it's being changed
914
+ partition_key = config[:partition_key]&.to_s
915
+ validate_gsi_key_value!(partition_key, changed_dynamo_keys[partition_key], index_name) if partition_key && changed_dynamo_keys.key?(partition_key)
916
+
917
+ # Check sort key if it's being changed
918
+ sort_key = config[:sort_key]&.to_s
919
+ validate_gsi_key_value!(sort_key, changed_dynamo_keys[sort_key], index_name) if sort_key && changed_dynamo_keys.key?(sort_key)
920
+ end
921
+ end
922
+
923
+ # Validates that a single GSI key value has a valid type.
924
+ # DynamoDB GSI keys support: String, Number (Integer/Float/BigDecimal), Binary (StringIO).
925
+ # Empty strings are not allowed for GSI keys.
926
+ #
927
+ # @param attribute [String] The attribute name
928
+ # @param value [Object] The attribute value
929
+ # @param index_name [String] The GSI name (for error messages)
930
+ # @raise [InvalidGsiKeyTypeError] if the value has an invalid type
931
+ def validate_gsi_key_value!(attribute, value, index_name)
932
+ return if valid_gsi_key_type?(value)
933
+
934
+ raise InvalidGsiKeyTypeError.new(
935
+ model_name: self.class.name,
936
+ attribute: attribute,
937
+ index_name: index_name,
938
+ value: value
939
+ )
940
+ end
941
+
942
+ # Checks if a value is a valid type for a DynamoDB GSI key.
943
+ # Valid types: String (non-empty), Numeric (Integer, Float, BigDecimal), Binary (StringIO).
944
+ # Empty strings are explicitly rejected as DynamoDB does not allow them for key attributes.
945
+ #
946
+ # @param value [Object] The value to check
947
+ # @return [Boolean] true if the value is a valid GSI key type
948
+ def valid_gsi_key_type?(value)
949
+ case value
950
+ when String
951
+ !value.empty? # Empty strings are not allowed for GSI keys
952
+ when Integer, Float, BigDecimal, StringIO
953
+ # Numeric (Integer, Float, BigDecimal) and Binary (StringIO) are valid GSI key types
954
+ true
955
+ else
956
+ false
957
+ end
958
+ end
857
959
  end
858
960
  end
@@ -82,4 +82,20 @@ module ActiveItem
82
82
  super(msg)
83
83
  end
84
84
  end
85
+
86
+ # Raised when a GSI key attribute has an invalid type.
87
+ # DynamoDB GSI keys must be String, Number (Integer/Float/BigDecimal), or Binary (StringIO).
88
+ class InvalidGsiKeyTypeError < StandardError
89
+ attr_reader :model_name, :attribute, :index_name, :value, :value_type
90
+
91
+ def initialize(model_name:, attribute:, index_name:, value:)
92
+ @model_name = model_name
93
+ @attribute = attribute
94
+ @index_name = index_name
95
+ @value = value
96
+ @value_type = value.class.name
97
+ super("#{model_name}##{attribute} has invalid type #{@value_type} for GSI '#{index_name}'. " \
98
+ 'GSI keys must be String, Numeric (Integer/Float/BigDecimal), or Binary (StringIO).')
99
+ end
100
+ end
85
101
  end
@@ -294,18 +294,26 @@ module ActiveItem
294
294
 
295
295
  # Find by id within the current scope, or find by block (like Enumerable#find)
296
296
  #
297
+ # When called on an association (e.g., @project.epics.find(id)), validates that
298
+ # the found record belongs to the association's scope. This matches Rails behavior
299
+ # where association.find(id) only returns records belonging to that association.
300
+ #
297
301
  # @overload find(id)
298
302
  # Find a record by ID within the current scope
299
303
  # @param id [String] The ID to find
300
- # @return [Object, nil] The found record or nil
304
+ # @return [Object] The found record
305
+ # @raise [ActiveItem::RecordNotFound] If record not found or not in scope
301
306
  #
302
307
  # @overload find(&block)
303
308
  # Find the first record matching the block condition (like Enumerable#find/detect)
304
309
  # @yield [record] Evaluates the block for each record
305
310
  # @return [Object, nil] The first record where block returns true, or nil
306
311
  #
307
- # @example Find by ID
308
- # User.where(status: 'active').find('user-123')
312
+ # @example Find by ID (scoped to association)
313
+ # @project.epics.find('epic-123') # Only finds if epic belongs to @project
314
+ #
315
+ # @example Find by ID (unscoped)
316
+ # Epic.where(status: 'active').find('epic-123') # Finds any active epic
309
317
  #
310
318
  # @example Find by block
311
319
  # User.where(status: 'active').find { |u| u.email.include?('@example.com') }
@@ -317,13 +325,20 @@ module ActiveItem
317
325
  elsif id
318
326
  # Use direct GetItem instead of scanning — O(1) vs O(n)
319
327
  record = resolved_model.find(id)
328
+
329
+ # When called on an association (has conditions), validate scope
330
+ # This ensures @project.epics.find(id) only returns epics belonging to @project
331
+ if conditions.any? && !conditions[:_empty]
332
+ foreign_key, expected_value = conditions.first
333
+ actual_value = record.send(foreign_key)
334
+ raise ActiveItem::RecordNotFound, "Couldn't find #{resolved_model.name} with id=#{id}" unless actual_value == expected_value
335
+ end
336
+
320
337
  preload_associations_for_records([record]) if includes_associations.any?
321
338
  record
322
339
  else
323
340
  raise ArgumentError, 'find requires either an ID or a block'
324
341
  end
325
- rescue ActiveItem::RecordNotFound
326
- nil
327
342
  end
328
343
 
329
344
  # Find by conditions within current scope
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.20'
4
+ VERSION = '0.0.22'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeitem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.20
4
+ version: 0.0.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-05 00:00:00.000000000 Z
12
+ date: 2026-09-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel