familia 2.0.0.pre16 → 2.0.0.pre17
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/.github/workflows/ci.yml +2 -2
- data/.github/workflows/{code-smellage.yml → code-smells.yml} +3 -63
- data/.gitignore +2 -0
- data/.rubocop.yml +6 -0
- data/CHANGELOG.rst +22 -0
- data/CLAUDE.md +38 -0
- data/Gemfile.lock +1 -1
- data/docs/archive/FAMILIA_TECHNICAL.md +1 -1
- data/docs/overview.md +2 -2
- data/docs/reference/api-technical.md +1 -1
- data/examples/encrypted_fields.rb +1 -1
- data/examples/safe_dump.rb +1 -1
- data/lib/familia/base.rb +6 -4
- data/lib/familia/data_type/class_methods.rb +63 -0
- data/lib/familia/data_type/connection.rb +83 -0
- data/lib/familia/data_type/settings.rb +96 -0
- data/lib/familia/data_type/types/hashkey.rb +2 -1
- data/lib/familia/data_type/types/sorted_set.rb +113 -10
- data/lib/familia/data_type/types/stringkey.rb +0 -4
- data/lib/familia/data_type.rb +6 -193
- data/lib/familia/features/encrypted_fields.rb +5 -2
- data/lib/familia/features/external_identifier.rb +49 -8
- data/lib/familia/features/object_identifier.rb +84 -12
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +6 -1
- data/lib/familia/features/relationships/indexing.rb +7 -1
- data/lib/familia/features/relationships/participation/participant_methods.rb +6 -2
- data/lib/familia/features/transient_fields.rb +7 -2
- data/lib/familia/features.rb +6 -1
- data/lib/familia/field_type.rb +0 -18
- data/lib/familia/horreum/{core/connection.rb → connection.rb} +21 -0
- data/lib/familia/horreum/{subclass/definition.rb → definition.rb} +109 -32
- data/lib/familia/horreum/{subclass/management.rb → management.rb} +1 -3
- data/lib/familia/horreum/{core/serialization.rb → persistence.rb} +72 -169
- data/lib/familia/horreum/{subclass/related_fields_management.rb → related_fields.rb} +22 -2
- data/lib/familia/horreum/serialization.rb +172 -0
- data/lib/familia/horreum.rb +29 -8
- data/lib/familia/version.rb +1 -1
- data/try/configuration/scenarios_try.rb +1 -1
- data/try/core/connection_try.rb +4 -4
- data/try/core/database_consistency_try.rb +1 -0
- data/try/core/errors_try.rb +3 -3
- data/try/core/familia_try.rb +1 -1
- data/try/core/isolated_dbclient_try.rb +2 -2
- data/try/core/tools_try.rb +2 -2
- data/try/data_types/sorted_set_zadd_options_try.rb +625 -0
- data/try/features/field_groups_try.rb +244 -0
- data/try/features/relationships/indexing_try.rb +10 -0
- data/try/features/transient_fields/refresh_reset_try.rb +2 -0
- data/try/helpers/test_helpers.rb +3 -4
- data/try/horreum/auto_indexing_on_save_try.rb +212 -0
- data/try/horreum/commands_try.rb +2 -0
- data/try/horreum/defensive_initialization_try.rb +86 -0
- data/try/horreum/destroy_related_fields_cleanup_try.rb +2 -0
- data/try/horreum/settings_try.rb +2 -0
- data/try/memory/memory_docker_ruby_dump.sh +1 -1
- data/try/models/customer_try.rb +5 -5
- data/try/valkey.conf +26 -0
- metadata +19 -11
- data/lib/familia/horreum/core.rb +0 -21
- /data/lib/familia/horreum/{core/database_commands.rb → database_commands.rb} +0 -0
- /data/lib/familia/horreum/{shared/settings.rb → settings.rb} +0 -0
- /data/lib/familia/horreum/{core/utils.rb → utils.rb} +0 -0
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# try/features/field_groups_try.rb
|
4
|
+
|
5
|
+
require_relative '../../lib/familia'
|
6
|
+
|
7
|
+
# Define test classes in setup section
|
8
|
+
class BasicUser < Familia::Horreum
|
9
|
+
field_group :personal_info do
|
10
|
+
field :name
|
11
|
+
field :email
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class MultiGroupUser < Familia::Horreum
|
16
|
+
field_group :personal do
|
17
|
+
field :name
|
18
|
+
field :email
|
19
|
+
end
|
20
|
+
|
21
|
+
field_group :metadata do
|
22
|
+
field :created_at
|
23
|
+
field :updated_at
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class EmptyGroupModel < Familia::Horreum
|
28
|
+
field_group :placeholder
|
29
|
+
end
|
30
|
+
|
31
|
+
class TransientModel < Familia::Horreum
|
32
|
+
feature :transient_fields
|
33
|
+
transient_field :api_key
|
34
|
+
transient_field :session_token
|
35
|
+
end
|
36
|
+
|
37
|
+
class EncryptedModel < Familia::Horreum
|
38
|
+
feature :encrypted_fields
|
39
|
+
encrypted_field :password
|
40
|
+
encrypted_field :credit_card
|
41
|
+
end
|
42
|
+
|
43
|
+
class MixedGroupsModel < Familia::Horreum
|
44
|
+
feature :transient_fields
|
45
|
+
transient_field :temp_data
|
46
|
+
|
47
|
+
field_group :custom do
|
48
|
+
field :custom_field
|
49
|
+
end
|
50
|
+
|
51
|
+
feature :encrypted_fields
|
52
|
+
encrypted_field :secret_key
|
53
|
+
end
|
54
|
+
|
55
|
+
class FieldsOutsideGroups < Familia::Horreum
|
56
|
+
field :standalone_field
|
57
|
+
|
58
|
+
field_group :grouped do
|
59
|
+
field :grouped_field
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
class NoSuchGroup < Familia::Horreum
|
64
|
+
field_group :existing do
|
65
|
+
field :name
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class ParentModel < Familia::Horreum
|
70
|
+
field_group :base_fields do
|
71
|
+
field :id
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class ChildModel < ParentModel
|
76
|
+
field_group :child_fields do
|
77
|
+
field :name
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Create instances for testing
|
82
|
+
@user = MultiGroupUser.new(name: 'Alice', email: 'alice@example.com', created_at: Time.now.to_i)
|
83
|
+
@user2 = BasicUser.new(name: 'Bob', email: 'bob@example.com')
|
84
|
+
|
85
|
+
## Manual field groups - basic access via hash
|
86
|
+
BasicUser.instance_variable_get(:@field_groups)[:personal_info]
|
87
|
+
#=> [:name, :email]
|
88
|
+
|
89
|
+
## Multiple groups - access personal group via hash
|
90
|
+
MultiGroupUser.instance_variable_get(:@field_groups)[:personal]
|
91
|
+
#=> [:name, :email]
|
92
|
+
|
93
|
+
## Multiple groups - access metadata group via hash
|
94
|
+
MultiGroupUser.instance_variable_get(:@field_groups)[:metadata]
|
95
|
+
#=> [:created_at, :updated_at]
|
96
|
+
|
97
|
+
## Multiple groups - list all field groups (returns hash)
|
98
|
+
MultiGroupUser.field_groups.keys.sort
|
99
|
+
#=> [:metadata, :personal]
|
100
|
+
|
101
|
+
## Field groups - fields defined inside groups are tracked
|
102
|
+
user = MultiGroupUser.new(name: 'Alice', email: 'alice@example.com', created_at: Time.now.to_i)
|
103
|
+
|
104
|
+
## Grouped fields - access name field
|
105
|
+
@user.name
|
106
|
+
#=> 'Alice'
|
107
|
+
|
108
|
+
## Grouped fields - access email field
|
109
|
+
@user.email
|
110
|
+
#=> 'alice@example.com'
|
111
|
+
|
112
|
+
## Empty group - access via hash
|
113
|
+
EmptyGroupModel.instance_variable_get(:@field_groups)[:placeholder]
|
114
|
+
#=> []
|
115
|
+
|
116
|
+
## Empty group - list field groups (returns hash)
|
117
|
+
EmptyGroupModel.field_groups
|
118
|
+
#=> {placeholder: []}
|
119
|
+
|
120
|
+
## Empty group - list field group keys
|
121
|
+
EmptyGroupModel.field_groups.keys
|
122
|
+
#=> [:placeholder]
|
123
|
+
|
124
|
+
## Transient feature - access via backward compatible method
|
125
|
+
TransientModel.transient_fields
|
126
|
+
#=> [:api_key, :session_token]
|
127
|
+
|
128
|
+
## Transient feature - access via field_groups hash
|
129
|
+
TransientModel.instance_variable_get(:@field_groups)[:transient_fields]
|
130
|
+
#=> [:api_key, :session_token]
|
131
|
+
|
132
|
+
## Transient feature - field_groups returns hash with content
|
133
|
+
TransientModel.field_groups
|
134
|
+
#=> {transient_fields: [:api_key, :session_token]}
|
135
|
+
|
136
|
+
## Transient feature - list field group keys
|
137
|
+
TransientModel.field_groups.keys
|
138
|
+
#=> [:transient_fields]
|
139
|
+
|
140
|
+
## Encrypted feature - access via backward compatible method
|
141
|
+
EncryptedModel.encrypted_fields
|
142
|
+
#=> [:password, :credit_card]
|
143
|
+
|
144
|
+
## Encrypted feature - access via field_groups hash
|
145
|
+
EncryptedModel.instance_variable_get(:@field_groups)[:encrypted_fields]
|
146
|
+
#=> [:password, :credit_card]
|
147
|
+
|
148
|
+
## Encrypted feature - field_groups returns hash with content
|
149
|
+
EncryptedModel.field_groups
|
150
|
+
#=> {encrypted_fields: [:password, :credit_card]}
|
151
|
+
|
152
|
+
## Encrypted feature - list field group keys
|
153
|
+
EncryptedModel.field_groups.keys
|
154
|
+
#=> [:encrypted_fields]
|
155
|
+
|
156
|
+
## Mixed groups - list all field group keys
|
157
|
+
MixedGroupsModel.field_groups.keys.sort
|
158
|
+
#=> [:custom, :encrypted_fields, :transient_fields]
|
159
|
+
|
160
|
+
## Mixed groups - access custom group via hash
|
161
|
+
MixedGroupsModel.instance_variable_get(:@field_groups)[:custom]
|
162
|
+
#=> [:custom_field]
|
163
|
+
|
164
|
+
## Mixed groups - access transient_fields via backward compatible method
|
165
|
+
MixedGroupsModel.transient_fields
|
166
|
+
#=> [:temp_data]
|
167
|
+
|
168
|
+
## Mixed groups - access encrypted_fields via backward compatible method
|
169
|
+
MixedGroupsModel.encrypted_fields
|
170
|
+
#=> [:secret_key]
|
171
|
+
|
172
|
+
## Error: nested field groups
|
173
|
+
class NestedGroupsModel < Familia::Horreum
|
174
|
+
field_group :outer do
|
175
|
+
field_group :inner do
|
176
|
+
field :bad
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
#=!> Familia::Problem
|
181
|
+
|
182
|
+
## Exception during field_group block resets @current_field_group
|
183
|
+
class ErrorDuringGroup < Familia::Horreum
|
184
|
+
begin
|
185
|
+
field_group :broken do
|
186
|
+
field :first_field
|
187
|
+
raise StandardError, "Simulated error"
|
188
|
+
field :unreachable_field
|
189
|
+
end
|
190
|
+
rescue StandardError
|
191
|
+
# Swallow the error for testing
|
192
|
+
end
|
193
|
+
|
194
|
+
# Field defined after the error should not be in :broken group
|
195
|
+
field :after_error
|
196
|
+
end
|
197
|
+
|
198
|
+
ErrorDuringGroup
|
199
|
+
#=> ErrorDuringGroup
|
200
|
+
|
201
|
+
## Exception handling - broken group has only first_field
|
202
|
+
ErrorDuringGroup.instance_variable_get(:@field_groups)[:broken]
|
203
|
+
#=> [:first_field]
|
204
|
+
|
205
|
+
## Exception handling - after_error field is not in broken group
|
206
|
+
ErrorDuringGroup.instance_variable_get(:@field_groups)[:broken].include?(:after_error)
|
207
|
+
#=> false
|
208
|
+
|
209
|
+
## Exception handling - after_error is in fields list
|
210
|
+
ErrorDuringGroup.fields.include?(:after_error)
|
211
|
+
#=> true
|
212
|
+
|
213
|
+
## Exception handling - current_field_group was reset to nil
|
214
|
+
ErrorDuringGroup.instance_variable_get(:@current_field_group)
|
215
|
+
#=> nil
|
216
|
+
|
217
|
+
|
218
|
+
## Fields outside - access grouped field group via hash
|
219
|
+
FieldsOutsideGroups.instance_variable_get(:@field_groups)[:grouped]
|
220
|
+
#=> [:grouped_field]
|
221
|
+
|
222
|
+
## Fields outside - all fields include both grouped and standalone
|
223
|
+
FieldsOutsideGroups.fields
|
224
|
+
#=> [:standalone_field, :grouped_field]
|
225
|
+
|
226
|
+
## Accessing non-existent field group returns nil
|
227
|
+
NoSuchGroup.instance_variable_get(:@field_groups)[:nonexistent]
|
228
|
+
#=> nil
|
229
|
+
|
230
|
+
## Inheritance - parent class has its own field groups
|
231
|
+
ParentModel.field_groups
|
232
|
+
#=> {base_fields: [:id]}
|
233
|
+
|
234
|
+
## Inheritance - child class has its own field groups
|
235
|
+
ChildModel.field_groups
|
236
|
+
#=> {child_fields: [:name]}
|
237
|
+
|
238
|
+
## Normal field access - get name value
|
239
|
+
@user2.name
|
240
|
+
#=> 'Bob'
|
241
|
+
|
242
|
+
## Normal field access - get email value
|
243
|
+
@user2.email
|
244
|
+
#=> 'bob@example.com'
|
@@ -152,6 +152,11 @@ found_users.map(&:user_id).sort
|
|
152
152
|
TestUser.find_all_by_email([]).length
|
153
153
|
#=> 0
|
154
154
|
|
155
|
+
## Single value (non-array) is accepted by find_all_by method
|
156
|
+
found_users = TestUser.find_all_by_email('bob@example.com')
|
157
|
+
found_users.map(&:user_id)
|
158
|
+
#=> ["user_002"]
|
159
|
+
|
155
160
|
## Update index entry with old value removal
|
156
161
|
old_email = @user1.email
|
157
162
|
@user1.email = 'alice.new@example.com'
|
@@ -236,6 +241,11 @@ found_emps = @company.find_all_by_badge_number(badges)
|
|
236
241
|
found_emps.map(&:emp_id).sort
|
237
242
|
#=> ["emp_001", "emp_002"]
|
238
243
|
|
244
|
+
## Single value (non-array) accepted for instance-scoped find_all_by
|
245
|
+
found_emps = @company.find_all_by_badge_number('BADGE002')
|
246
|
+
found_emps.map(&:emp_id)
|
247
|
+
#=> ["emp_002"]
|
248
|
+
|
239
249
|
## Update badge index entry
|
240
250
|
old_badge = @emp1.badge_number
|
241
251
|
@emp1.badge_number = 'BADGE001_NEW'
|
data/try/helpers/test_helpers.rb
CHANGED
@@ -10,6 +10,7 @@ require_relative '../../lib/familia'
|
|
10
10
|
|
11
11
|
Familia.enable_database_logging = true
|
12
12
|
Familia.enable_database_counter = true
|
13
|
+
Familia.uri = 'redis://127.0.0.1:2525'
|
13
14
|
|
14
15
|
class Bone < Familia::Horreum
|
15
16
|
using Familia::Refinements::TimeLiterals
|
@@ -44,12 +45,10 @@ class Customer < Familia::Horreum
|
|
44
45
|
|
45
46
|
using Familia::Refinements::TimeLiterals
|
46
47
|
|
47
|
-
logical_database
|
48
|
+
logical_database 3 # Use something other than the default DB
|
48
49
|
default_expiration 5.years
|
49
50
|
|
50
51
|
feature :safe_dump
|
51
|
-
# feature :expiration
|
52
|
-
# feature :api_version
|
53
52
|
|
54
53
|
# Use new SafeDump DSL instead of @safe_dump_fields
|
55
54
|
safe_dump_field :custid
|
@@ -108,7 +107,7 @@ end
|
|
108
107
|
class Session < Familia::Horreum
|
109
108
|
using Familia::Refinements::TimeLiterals
|
110
109
|
|
111
|
-
logical_database
|
110
|
+
logical_database 2 # a non-default database
|
112
111
|
default_expiration 180.minutes
|
113
112
|
|
114
113
|
identifier_field :sessid
|
@@ -0,0 +1,212 @@
|
|
1
|
+
# try/horreum/auto_indexing_on_save_try.rb
|
2
|
+
|
3
|
+
#
|
4
|
+
# Auto-indexing on save functionality tests
|
5
|
+
# Tests automatic index population when Familia::Horreum objects are saved
|
6
|
+
#
|
7
|
+
|
8
|
+
require_relative '../helpers/test_helpers'
|
9
|
+
|
10
|
+
# Test classes for auto-indexing functionality
|
11
|
+
class ::AutoIndexUser < Familia::Horreum
|
12
|
+
feature :relationships
|
13
|
+
|
14
|
+
identifier_field :user_id
|
15
|
+
field :user_id
|
16
|
+
field :email
|
17
|
+
field :username
|
18
|
+
field :department
|
19
|
+
|
20
|
+
# Class-level unique indexes (should auto-populate on save)
|
21
|
+
unique_index :email, :email_index
|
22
|
+
unique_index :username, :username_index
|
23
|
+
end
|
24
|
+
|
25
|
+
class ::AutoIndexCompany < Familia::Horreum
|
26
|
+
feature :relationships
|
27
|
+
|
28
|
+
identifier_field :company_id
|
29
|
+
field :company_id
|
30
|
+
field :name
|
31
|
+
end
|
32
|
+
|
33
|
+
class ::AutoIndexEmployee < Familia::Horreum
|
34
|
+
feature :relationships
|
35
|
+
|
36
|
+
identifier_field :emp_id
|
37
|
+
field :emp_id
|
38
|
+
field :badge_number
|
39
|
+
field :department
|
40
|
+
|
41
|
+
# Instance-scoped indexes (should NOT auto-populate - require parent context)
|
42
|
+
unique_index :badge_number, :badge_index, within: AutoIndexCompany
|
43
|
+
multi_index :department, :dept_index, within: AutoIndexCompany
|
44
|
+
end
|
45
|
+
|
46
|
+
# Setup
|
47
|
+
@user_id = "user_#{rand(1000000)}"
|
48
|
+
@user = AutoIndexUser.new(user_id: @user_id, email: 'test@example.com', username: 'testuser', department: 'engineering')
|
49
|
+
|
50
|
+
@company_id = "comp_#{rand(1000000)}"
|
51
|
+
@company = AutoIndexCompany.new(company_id: @company_id, name: 'Test Corp')
|
52
|
+
|
53
|
+
@emp_id = "emp_#{rand(1000000)}"
|
54
|
+
@employee = AutoIndexEmployee.new(emp_id: @emp_id, badge_number: 'BADGE123', department: 'sales')
|
55
|
+
|
56
|
+
# =============================================
|
57
|
+
# 1. Class-Level Unique Index Auto-Population
|
58
|
+
# =============================================
|
59
|
+
|
60
|
+
## Unique index is empty before save
|
61
|
+
AutoIndexUser.email_index.has_key?('test@example.com')
|
62
|
+
#=> false
|
63
|
+
|
64
|
+
## Save automatically populates unique index
|
65
|
+
@user.save
|
66
|
+
AutoIndexUser.email_index.has_key?('test@example.com')
|
67
|
+
#=> true
|
68
|
+
|
69
|
+
## Auto-populated index maps to correct identifier
|
70
|
+
AutoIndexUser.email_index.get('test@example.com')
|
71
|
+
#=> @user_id
|
72
|
+
|
73
|
+
## Finder method works after auto-indexing
|
74
|
+
found = AutoIndexUser.find_by_email('test@example.com')
|
75
|
+
found&.user_id
|
76
|
+
#=> @user_id
|
77
|
+
|
78
|
+
## Multiple unique indexes auto-populate on same save
|
79
|
+
AutoIndexUser.username_index.get('testuser')
|
80
|
+
#=> @user_id
|
81
|
+
|
82
|
+
## Subsequent saves maintain index (idempotent)
|
83
|
+
@user.save
|
84
|
+
AutoIndexUser.email_index.get('test@example.com')
|
85
|
+
#=> @user_id
|
86
|
+
|
87
|
+
## Changing indexed field and saving adds new entry (old entry remains unless manually removed)
|
88
|
+
# Note: Auto-indexing is idempotent addition only - updates require manual update_in_class_* calls
|
89
|
+
@user.email = 'newemail@example.com'
|
90
|
+
@user.save
|
91
|
+
# New email is indexed, but old email remains (expected behavior - use update_in_class_* for proper updates)
|
92
|
+
[AutoIndexUser.email_index.has_key?('test@example.com'), AutoIndexUser.email_index.get('newemail@example.com') == @user_id]
|
93
|
+
#=> [true, true]
|
94
|
+
|
95
|
+
# =============================================
|
96
|
+
# 2. Instance-Scoped Indexes (Manual Only)
|
97
|
+
# =============================================
|
98
|
+
|
99
|
+
## Instance-scoped indexes do NOT auto-populate on save
|
100
|
+
@employee.save
|
101
|
+
@company.badge_index.has_key?('BADGE123')
|
102
|
+
#=> false
|
103
|
+
|
104
|
+
## Instance-scoped indexes remain manual (require parent context)
|
105
|
+
@employee.add_to_auto_index_company_badge_index(@company)
|
106
|
+
@company.badge_index.has_key?('BADGE123')
|
107
|
+
#=> true
|
108
|
+
|
109
|
+
# =============================================
|
110
|
+
# 3. Edge Cases and Error Handling
|
111
|
+
# =============================================
|
112
|
+
|
113
|
+
## Nil field values handled gracefully
|
114
|
+
@user_nil_id = "user_nil_#{rand(1000000)}"
|
115
|
+
@user_nil = AutoIndexUser.new(user_id: @user_nil_id, email: nil, username: nil, department: nil)
|
116
|
+
@user_nil.save
|
117
|
+
AutoIndexUser.email_index.has_key?('')
|
118
|
+
#=> false
|
119
|
+
|
120
|
+
## Empty string field values handled gracefully
|
121
|
+
@user_empty_id = "user_empty_#{rand(1000000)}"
|
122
|
+
@user_empty = AutoIndexUser.new(user_id: @user_empty_id, email: '', username: '', department: '')
|
123
|
+
@user_empty.save
|
124
|
+
# Empty strings are indexed (they're valid string values, just empty)
|
125
|
+
AutoIndexUser.email_index.has_key?('')
|
126
|
+
#=> true
|
127
|
+
|
128
|
+
## Auto-indexing works with create method
|
129
|
+
@user2_id = "user_#{rand(1000000)}"
|
130
|
+
@user2 = AutoIndexUser.create(user_id: @user2_id, email: 'create@example.com', username: 'createuser', department: 'marketing')
|
131
|
+
AutoIndexUser.find_by_email('create@example.com')&.user_id
|
132
|
+
#=> @user2_id
|
133
|
+
|
134
|
+
## Auto-indexing idempotent with multiple saves
|
135
|
+
@user2.save
|
136
|
+
@user2.save
|
137
|
+
@user2.save
|
138
|
+
AutoIndexUser.email_index.get('create@example.com')
|
139
|
+
#=> @user2_id
|
140
|
+
|
141
|
+
## Field update followed by save adds new entry (use update_in_class_* for proper updates)
|
142
|
+
old_email = @user2.email
|
143
|
+
@user2.email = 'updated@example.com'
|
144
|
+
@user2.save
|
145
|
+
# Both old and new emails are indexed (auto-indexing doesn't remove old values)
|
146
|
+
# For proper updates that remove old values, use: @user2.update_in_class_email_index(old_email)
|
147
|
+
[AutoIndexUser.email_index.has_key?(old_email), AutoIndexUser.email_index.get('updated@example.com') == @user2_id]
|
148
|
+
#=> [true, true]
|
149
|
+
|
150
|
+
# =============================================
|
151
|
+
# 4. Integration with Other Features
|
152
|
+
# =============================================
|
153
|
+
|
154
|
+
## Auto-indexing works with transient fields
|
155
|
+
class ::AutoIndexWithTransient < Familia::Horreum
|
156
|
+
feature :transient_fields
|
157
|
+
feature :relationships
|
158
|
+
|
159
|
+
identifier_field :id
|
160
|
+
field :id
|
161
|
+
field :email
|
162
|
+
transient_field :temp_value
|
163
|
+
|
164
|
+
unique_index :email, :email_index
|
165
|
+
end
|
166
|
+
|
167
|
+
@transient_id = "trans_#{rand(1000000)}"
|
168
|
+
@transient_obj = AutoIndexWithTransient.new(id: @transient_id, email: 'transient@example.com', temp_value: 'ignored')
|
169
|
+
@transient_obj.save
|
170
|
+
AutoIndexWithTransient.find_by_email('transient@example.com')&.id
|
171
|
+
#=> @transient_id
|
172
|
+
|
173
|
+
## Auto-indexing works regardless of other features
|
174
|
+
# Just verify that the feature system doesn't interfere
|
175
|
+
@transient_obj.class.respond_to?(:indexing_relationships)
|
176
|
+
#=> true
|
177
|
+
|
178
|
+
# =============================================
|
179
|
+
# 5. Performance and Behavior Verification
|
180
|
+
# =============================================
|
181
|
+
|
182
|
+
## Auto-indexing has negligible overhead (no existence checks)
|
183
|
+
# This test verifies the design: we use idempotent commands (HSET, SADD)
|
184
|
+
# rather than checking if the index exists before updating
|
185
|
+
@user4_id = "user_#{rand(1000000)}"
|
186
|
+
@user4 = AutoIndexUser.new(user_id: @user4_id, email: 'perf@example.com', username: 'perfuser', department: 'ops')
|
187
|
+
|
188
|
+
# Save multiple times - all should succeed with same result
|
189
|
+
@user4.save
|
190
|
+
@user4.save
|
191
|
+
@user4.save
|
192
|
+
|
193
|
+
AutoIndexUser.email_index.get('perf@example.com')
|
194
|
+
#=> @user4_id
|
195
|
+
|
196
|
+
## Auto-indexing only processes class-level indexes
|
197
|
+
# Verify no errors when instance-scoped indexes present
|
198
|
+
@employee2_id = "emp_#{rand(1000000)}"
|
199
|
+
@employee2 = AutoIndexEmployee.new(emp_id: @employee2_id, badge_number: 'BADGE456', department: 'engineering')
|
200
|
+
@employee2.save # Should not error, just skip instance-scoped indexes
|
201
|
+
@employee2.emp_id
|
202
|
+
#=> @employee2_id
|
203
|
+
|
204
|
+
# Teardown - clean up test objects
|
205
|
+
[@user, @user2, @user4, @user_nil, @user_empty, @company, @employee, @employee2, @transient_obj].each do |obj|
|
206
|
+
obj.destroy! if obj.respond_to?(:destroy!) && obj.respond_to?(:exists?) && obj.exists?
|
207
|
+
end
|
208
|
+
|
209
|
+
# Clean up class-level indexes
|
210
|
+
[AutoIndexUser.email_index, AutoIndexUser.username_index].each do |index|
|
211
|
+
index.delete! if index.respond_to?(:delete!) && index.respond_to?(:exists?) && index.exists?
|
212
|
+
end
|
data/try/horreum/commands_try.rb
CHANGED
@@ -0,0 +1,86 @@
|
|
1
|
+
# try/horreum/defensive_initialization_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
|
5
|
+
# Test defensive initialization behavior
|
6
|
+
class User < Familia::Horreum
|
7
|
+
field :email
|
8
|
+
list :sessions
|
9
|
+
zset :metrics
|
10
|
+
|
11
|
+
def initialize(email = nil)
|
12
|
+
# This is the common mistake - overriding initialize without calling super
|
13
|
+
@email = email
|
14
|
+
# Missing: super() or initialize_relatives
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class SafeUser < Familia::Horreum
|
19
|
+
field :email
|
20
|
+
list :sessions
|
21
|
+
zset :metrics
|
22
|
+
|
23
|
+
def init
|
24
|
+
# This is the correct way - using the init hook
|
25
|
+
# Fields are already set by initialize, no need to override
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Setup instances for testing
|
30
|
+
@user = User.new("test@example.com")
|
31
|
+
@safe_user = SafeUser.new
|
32
|
+
@safe_user.email = "safe@example.com"
|
33
|
+
|
34
|
+
## Test that accessing relationships after bad initialize triggers lazy initialization
|
35
|
+
@user.email
|
36
|
+
#=> "test@example.com"
|
37
|
+
|
38
|
+
## Test that sessions works with lazy initialization
|
39
|
+
@user.sessions.class
|
40
|
+
#=> Familia::ListKey
|
41
|
+
|
42
|
+
## Test that metrics also works with lazy initialization
|
43
|
+
@user.metrics.class
|
44
|
+
#=> Familia::SortedSet
|
45
|
+
|
46
|
+
## Test that safe user works normally
|
47
|
+
@safe_user.email
|
48
|
+
#=> "safe@example.com"
|
49
|
+
|
50
|
+
## Test that safe user sessions work
|
51
|
+
@safe_user.sessions.class
|
52
|
+
#=> Familia::ListKey
|
53
|
+
|
54
|
+
## Test that relatives_initialized flag prevents double initialization
|
55
|
+
@user.singleton_class.instance_variable_get(:@relatives_initialized)
|
56
|
+
#=> true
|
57
|
+
|
58
|
+
## Test that manual initialize_relatives call is no-op
|
59
|
+
@user.initialize_relatives
|
60
|
+
@user.sessions.class
|
61
|
+
#=> Familia::ListKey
|
62
|
+
|
63
|
+
## Test that the original problem is now fixed - bad override still works
|
64
|
+
class BadUser < Familia::Horreum
|
65
|
+
field :email
|
66
|
+
list :sessions
|
67
|
+
|
68
|
+
def initialize(email)
|
69
|
+
# Bad: overriding initialize without calling super
|
70
|
+
@email = email
|
71
|
+
# Missing: super() or initialize_relatives
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
@bad_user = BadUser.new("bad@example.com")
|
76
|
+
@bad_user.email
|
77
|
+
#=> "bad@example.com"
|
78
|
+
|
79
|
+
## Test that relationships work despite bad initialize (lazy initialization kicks in)
|
80
|
+
@bad_user.sessions.class
|
81
|
+
#=> Familia::ListKey
|
82
|
+
|
83
|
+
## Test that the bad user can actually use the relationships
|
84
|
+
@bad_user.sessions.add("session_123")
|
85
|
+
@bad_user.sessions.size > 0
|
86
|
+
#=> true
|
data/try/horreum/settings_try.rb
CHANGED
@@ -59,7 +59,7 @@ docker exec $CONTAINER_ID bash -c '
|
|
59
59
|
# $
|
60
60
|
# $ docker run --rm -d -p 3000:3000 \
|
61
61
|
# -e SECRET=$SECRET \
|
62
|
-
# -e REDIS_URL=redis://host.docker.internal:
|
62
|
+
# -e REDIS_URL=redis://host.docker.internal:2525/0 \
|
63
63
|
# ghcr.io/onetimesecret/devtimesecret-lite:latest
|
64
64
|
#
|
65
65
|
# abcd1234
|
data/try/models/customer_try.rb
CHANGED
@@ -103,15 +103,15 @@ exists = Customer.exists?('test@example.com')
|
|
103
103
|
|
104
104
|
## Customer.logical_database returns the correct database number
|
105
105
|
Customer.logical_database
|
106
|
-
#=>
|
106
|
+
#=> 3
|
107
107
|
|
108
108
|
## Customer.logical_database returns the correct database number
|
109
109
|
@customer.logical_database
|
110
|
-
#=>
|
110
|
+
#=> 3
|
111
111
|
|
112
112
|
## @customer.dbclient.connection returns the correct database URI
|
113
113
|
@customer.dbclient.connection
|
114
|
-
#=> {:host=>"127.0.0.1", :port=>
|
114
|
+
#=> {:host=>"127.0.0.1", :port=>2525, :db=>3, :id=>"redis://127.0.0.1:2525/3", :location=>"127.0.0.1:2525"}
|
115
115
|
|
116
116
|
## @customer.dbclient.uri returns the correct database URI
|
117
117
|
@customer.secrets_created.logical_database
|
@@ -119,7 +119,7 @@ Customer.logical_database
|
|
119
119
|
|
120
120
|
## @customer.dbclient.uri returns the correct database URI
|
121
121
|
@customer.secrets_created.dbclient.connection
|
122
|
-
#=> {:host=>"127.0.0.1", :port=>
|
122
|
+
#=> {:host=>"127.0.0.1", :port=>2525, :db=>3, :id=>"redis://127.0.0.1:2525/3", :location=>"127.0.0.1:2525"}
|
123
123
|
|
124
124
|
## Customer.url is nil by default
|
125
125
|
Customer.uri
|
@@ -131,7 +131,7 @@ Customer.instances.logical_database
|
|
131
131
|
|
132
132
|
## Customer.logical_database returns the correct database number
|
133
133
|
Customer.instances.uri.to_s
|
134
|
-
#=> 'redis://127.0.0.1/
|
134
|
+
#=> 'redis://127.0.0.1/3'
|
135
135
|
|
136
136
|
# Teardown
|
137
137
|
Customer.instances.delete!
|
data/try/valkey.conf
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# try/test-valkey.conf
|
2
|
+
|
3
|
+
# Familia - Tryouts Valkey Config
|
4
|
+
# 2025-10-01
|
5
|
+
#
|
6
|
+
# Usage:
|
7
|
+
#
|
8
|
+
# $ valkey-server try/valkey.conf
|
9
|
+
#
|
10
|
+
|
11
|
+
dir ./data
|
12
|
+
|
13
|
+
enable-debug-command yes
|
14
|
+
|
15
|
+
#requirepass CHANGEME
|
16
|
+
|
17
|
+
bind 127.0.0.1
|
18
|
+
port 2525
|
19
|
+
databases 10
|
20
|
+
|
21
|
+
timeout 4
|
22
|
+
daemonize no
|
23
|
+
loglevel notice
|
24
|
+
|
25
|
+
# Disable RDB persistence for tests DB
|
26
|
+
save ""
|