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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/active_item/base.rb +102 -0
- data/lib/active_item/errors.rb +16 -0
- data/lib/active_item/relation.rb +20 -5
- data/lib/active_item/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7d5990fc9c0532dc9a65f803bd9886c1769dc2b4c3071fd4748c079fa7559b3
|
|
4
|
+
data.tar.gz: 1c480246c6a898502eacedda1b456e003f54910a3f8562893cc5c070c06f168e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/active_item/base.rb
CHANGED
|
@@ -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
|
data/lib/active_item/errors.rb
CHANGED
|
@@ -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
|
data/lib/active_item/relation.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
data/lib/active_item/version.rb
CHANGED
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.
|
|
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-
|
|
12
|
+
date: 2026-09-07 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activemodel
|