familia 2.0.0.pre24 → 2.0.0.pre25
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.rst +37 -0
- data/CLAUDE.md +1 -1
- data/Gemfile.lock +1 -1
- data/docs/guides/feature-relationships-indexing.md +104 -9
- data/docs/guides/feature-relationships-methods.md +37 -5
- data/docs/overview.md +9 -0
- data/lib/familia/base.rb +0 -2
- data/lib/familia/data_type/serialization.rb +8 -9
- data/lib/familia/data_type/settings.rb +0 -8
- data/lib/familia/data_type/types/json_stringkey.rb +155 -0
- data/lib/familia/data_type.rb +5 -4
- data/lib/familia/features/relationships/indexing/multi_index_generators.rb +281 -15
- data/lib/familia/features/relationships/indexing.rb +57 -27
- data/lib/familia/features/safe_dump.rb +0 -3
- data/lib/familia/horreum/persistence.rb +4 -1
- data/lib/familia/horreum/settings.rb +2 -10
- data/lib/familia/horreum.rb +1 -2
- data/lib/familia/version.rb +1 -1
- data/try/features/relationships/class_level_multi_index_auto_try.rb +318 -0
- data/try/features/relationships/class_level_multi_index_rebuild_try.rb +393 -0
- data/try/features/relationships/class_level_multi_index_try.rb +349 -0
- data/try/integration/familia_extended_try.rb +1 -1
- data/try/integration/scenarios_try.rb +4 -3
- data/try/unit/data_types/json_stringkey_try.rb +431 -0
- data/try/unit/horreum/settings_try.rb +0 -11
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99afa47f80c0cbf07cf0c1c88dbf2fc6d9f720641d9404efbb181c90dd83631b
|
|
4
|
+
data.tar.gz: 91ea00ff6aa997daa9781b75723e90bd77534bdf7d32f159f305d50e33e6a62e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2e0bdba2c70ee453af1cf7601930ce44d434a1219eca2ae03786c40d8cb5c02ad689e6e1850d31df6e1a9525fbcd8a10aa95b0202bd68b9873fcb4084e2e10f
|
|
7
|
+
data.tar.gz: 47d6c6c5c8e51184124532754caced75d0e3c6f5ceace675c9ed2164f61ffdcbcb77370c0759ed70cf70ea3edc7290247e69f6d5dfe4a6a6dbd94831879c6054
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,43 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
|
|
|
7
7
|
|
|
8
8
|
<!--scriv-insert-here-->
|
|
9
9
|
|
|
10
|
+
.. _changelog-2.0.0.pre25:
|
|
11
|
+
|
|
12
|
+
2.0.0.pre25 — 2026-01-08
|
|
13
|
+
========================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- Class-level multi-value indexing with ``multi_index :field, :index_name`` (``within: :class`` is now the default). Creates class methods like ``Model.find_all_by_field(value)`` and ``Model.sample_from_field(value, count)`` for grouping objects by field values at the class level.
|
|
19
|
+
|
|
20
|
+
- New ``JsonStringKey`` DataType for type-preserving string storage. Unlike
|
|
21
|
+
``StringKey`` which uses raw strings (for INCR/DECR support), ``JsonStringKey``
|
|
22
|
+
uses JSON serialization to preserve Ruby types (Integer, Float, Boolean, Hash,
|
|
23
|
+
Array) across the Redis storage boundary. Registered as ``:json_string`` and
|
|
24
|
+
``:json_stringkey``, enabling DSL methods like ``json_string :metadata`` and
|
|
25
|
+
``class_json_string :last_synced_at``.
|
|
26
|
+
|
|
27
|
+
Changed
|
|
28
|
+
-------
|
|
29
|
+
|
|
30
|
+
- ``multi_index`` now defaults to ``within: :class`` instead of requiring a scope class. Existing instance-scoped indexes (``within: SomeClass``) continue to work unchanged.
|
|
31
|
+
|
|
32
|
+
Removed
|
|
33
|
+
-------
|
|
34
|
+
|
|
35
|
+
- **BREAKING**: Removed ``dump_method`` and ``load_method`` configuration options
|
|
36
|
+
from ``Familia::Base``, ``Familia::Horreum``, and ``Familia::DataType``. JSON
|
|
37
|
+
serialization via ``to_json``/``from_json`` is now hard-coded for consistency
|
|
38
|
+
and type safety. Custom serialization methods are no longer supported.
|
|
39
|
+
|
|
40
|
+
AI Assistance
|
|
41
|
+
-------------
|
|
42
|
+
|
|
43
|
+
- Claude Opus 4.5 assisted with design, implementation, and testing of serialization consistency, the JsonStringKey feature, and multi_index :class mode.
|
|
44
|
+
- Gemini 3 Flash assisted with editing and trimming this section.
|
|
45
|
+
|
|
46
|
+
|
|
10
47
|
.. _changelog-2.0.0.pre24:
|
|
11
48
|
|
|
12
49
|
2.0.0.pre24 — 2026-01-07
|
data/CLAUDE.md
CHANGED
|
@@ -75,7 +75,7 @@ Add changelog fragment with each user-facing or documented change (optional but
|
|
|
75
75
|
|
|
76
76
|
2. **`Familia::DataType`** - Base class for Valkey data type wrappers
|
|
77
77
|
- Located in `lib/familia/data_type.rb`
|
|
78
|
-
- Provides String, List, UnsortedSet, SortedSet, HashKey implementations
|
|
78
|
+
- Provides String, JsonStringKey, List, UnsortedSet, SortedSet, HashKey implementations
|
|
79
79
|
- Each type has its own class in `lib/familia/data_type/types/`
|
|
80
80
|
|
|
81
81
|
3. **`Familia::Base`** - Common module for both Horreum and DataType
|
data/Gemfile.lock
CHANGED
|
@@ -16,7 +16,8 @@ Indexing creates fast lookups for finding objects by field values:
|
|
|
16
16
|
|------|-------|----------|-----------|
|
|
17
17
|
| `unique_index` | Class | Global unique fields | Redis HashKey |
|
|
18
18
|
| `unique_index` | Instance | Parent-scoped unique | Redis HashKey |
|
|
19
|
-
| `multi_index` |
|
|
19
|
+
| `multi_index` | Class (default) | Global non-unique groupings | Redis Set per value |
|
|
20
|
+
| `multi_index` | Instance | Parent-scoped groupings | Redis Set per value |
|
|
20
21
|
|
|
21
22
|
## Class-Level Unique Indexing
|
|
22
23
|
|
|
@@ -96,9 +97,80 @@ company2.find_by_badge_number('12345') # => emp2
|
|
|
96
97
|
| `remove_from_company_badge_index(company)` | Remove from index |
|
|
97
98
|
| `in_company_badge_index?(company)` | Check if indexed |
|
|
98
99
|
|
|
99
|
-
## Multi-Value Indexing
|
|
100
|
+
## Class-Level Multi-Value Indexing
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
Class-level multi-value indexes group objects by field values at the class level. This is the default behavior when no `within:` parameter is specified.
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class Customer < Familia::Horreum
|
|
106
|
+
feature :relationships
|
|
107
|
+
field :role
|
|
108
|
+
|
|
109
|
+
# Class-level multi_index (within: :class is the default)
|
|
110
|
+
multi_index :role, :role_index
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Create customers with various roles
|
|
114
|
+
alice = Customer.create(custid: 'cust_001', role: 'admin')
|
|
115
|
+
bob = Customer.create(custid: 'cust_002', role: 'user')
|
|
116
|
+
charlie = Customer.create(custid: 'cust_003', role: 'admin')
|
|
117
|
+
|
|
118
|
+
# Manually add to index (or use auto-indexing via save hooks)
|
|
119
|
+
alice.add_to_class_role_index
|
|
120
|
+
bob.add_to_class_role_index
|
|
121
|
+
charlie.add_to_class_role_index
|
|
122
|
+
|
|
123
|
+
# Query all customers with a specific role
|
|
124
|
+
admins = Customer.find_all_by_role('admin') # => [alice, charlie]
|
|
125
|
+
users = Customer.find_all_by_role('user') # => [bob]
|
|
126
|
+
|
|
127
|
+
# Random sampling
|
|
128
|
+
sample = Customer.sample_from_role('admin', 1) # => [random admin]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Redis Key Pattern
|
|
132
|
+
|
|
133
|
+
Class-level multi-indexes use the pattern: `{classname}:{index_name}:{field_value}`
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
Customer.role_index_for('admin').dbkey # => "customer:role_index:admin"
|
|
137
|
+
Customer.role_index_for('user').dbkey # => "customer:role_index:user"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Generated Class Methods
|
|
141
|
+
|
|
142
|
+
| Method | Description |
|
|
143
|
+
|--------|-------------|
|
|
144
|
+
| `Customer.role_index_for(value)` | Factory returning `Familia::UnsortedSet` for the field value |
|
|
145
|
+
| `Customer.find_all_by_role(value)` | Find all objects with that field value |
|
|
146
|
+
| `Customer.sample_from_role(value, count)` | Random sample of objects |
|
|
147
|
+
| `Customer.rebuild_role_index` | Rebuild the entire index from source data |
|
|
148
|
+
|
|
149
|
+
### Generated Instance Methods
|
|
150
|
+
|
|
151
|
+
| Method | Description |
|
|
152
|
+
|--------|-------------|
|
|
153
|
+
| `customer.add_to_class_role_index` | Add this object to its field value's index |
|
|
154
|
+
| `customer.remove_from_class_role_index` | Remove this object from its field value's index |
|
|
155
|
+
| `customer.update_in_class_role_index(old_value)` | Move object from old index to new index |
|
|
156
|
+
|
|
157
|
+
### Update Operations
|
|
158
|
+
|
|
159
|
+
When a field value changes, use the update method to atomically move the object between indexes:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
old_role = customer.role
|
|
163
|
+
customer.role = 'superadmin'
|
|
164
|
+
customer.update_in_class_role_index(old_role)
|
|
165
|
+
|
|
166
|
+
# Customer is now in 'superadmin' index, removed from old 'admin' index
|
|
167
|
+
Customer.find_all_by_role('superadmin') # => includes customer
|
|
168
|
+
Customer.find_all_by_role('admin') # => no longer includes customer
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Instance-Scoped Multi-Value Indexing
|
|
172
|
+
|
|
173
|
+
For indexes scoped to a parent object, use `within:` to specify the scope class. This allows the same field values across different parent contexts.
|
|
102
174
|
|
|
103
175
|
```ruby
|
|
104
176
|
class Employee < Familia::Horreum
|
|
@@ -125,13 +197,22 @@ sales_team = company.find_all_by_department('sales') # => [emp3]
|
|
|
125
197
|
sample = company.sample_from_department('engineering', 1) # => [random engineer]
|
|
126
198
|
```
|
|
127
199
|
|
|
128
|
-
### Generated Methods
|
|
200
|
+
### Generated Methods (Instance-Scoped)
|
|
201
|
+
|
|
202
|
+
**On scope class (Company):**
|
|
203
|
+
| Method | Description |
|
|
204
|
+
|--------|-------------|
|
|
205
|
+
| `company.dept_index_for(value)` | Factory returning UnsortedSet for value |
|
|
206
|
+
| `company.find_all_by_department(dept)` | Find all in department |
|
|
207
|
+
| `company.sample_from_department(dept, count)` | Random sample |
|
|
208
|
+
| `company.rebuild_dept_index` | Rebuild index from participation |
|
|
129
209
|
|
|
130
|
-
**On
|
|
210
|
+
**On indexed class (Employee):**
|
|
131
211
|
| Method | Description |
|
|
132
212
|
|--------|-------------|
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
213
|
+
| `employee.add_to_company_dept_index(company)` | Add to company's index |
|
|
214
|
+
| `employee.remove_from_company_dept_index(company)` | Remove from index |
|
|
215
|
+
| `employee.update_in_company_dept_index(company, old_dept)` | Move between indexes |
|
|
135
216
|
|
|
136
217
|
## Advanced Patterns
|
|
137
218
|
|
|
@@ -189,18 +270,30 @@ todays_events = user.find_all_by_daily_partition(today)
|
|
|
189
270
|
|
|
190
271
|
### Class vs Instance Scoping
|
|
191
272
|
|
|
192
|
-
**Class-level (`unique_index :email, :email_lookup`):**
|
|
273
|
+
**Class-level unique (`unique_index :email, :email_lookup`):**
|
|
193
274
|
- Automatic indexing on save/destroy
|
|
194
275
|
- System-wide uniqueness
|
|
195
276
|
- No parent context needed
|
|
196
277
|
- Examples: emails, usernames, API keys
|
|
197
278
|
|
|
279
|
+
**Class-level multi (`multi_index :role, :role_index`):**
|
|
280
|
+
- Default behavior (no `within:` needed)
|
|
281
|
+
- Groups all objects by field value at class level
|
|
282
|
+
- Manual indexing via instance methods
|
|
283
|
+
- Examples: roles, categories, statuses
|
|
284
|
+
|
|
198
285
|
**Instance-scoped (`unique_index :badge, :badge_index, within: Company`):**
|
|
199
286
|
- Manual indexing required
|
|
200
287
|
- Unique within parent only
|
|
201
288
|
- Requires parent context
|
|
202
289
|
- Examples: employee IDs, project names per team
|
|
203
290
|
|
|
291
|
+
**Instance-scoped multi (`multi_index :dept, :dept_index, within: Company`):**
|
|
292
|
+
- Groups objects by field value within parent scope
|
|
293
|
+
- Same field value allowed across different parents
|
|
294
|
+
- Manual indexing with parent context
|
|
295
|
+
- Examples: departments per company, tags per project
|
|
296
|
+
|
|
204
297
|
### Unique vs Multi Indexing
|
|
205
298
|
|
|
206
299
|
**Unique index (`unique_index`):**
|
|
@@ -212,6 +305,7 @@ todays_events = user.find_all_by_daily_partition(today)
|
|
|
212
305
|
- 1:many field-to-objects mapping
|
|
213
306
|
- Returns array of objects
|
|
214
307
|
- Allows duplicate values
|
|
308
|
+
- Default: class-level scope (use `within:` for instance scope)
|
|
215
309
|
|
|
216
310
|
## Rebuilding Indexes
|
|
217
311
|
|
|
@@ -277,8 +371,9 @@ end
|
|
|
277
371
|
| Type | Pattern | Example |
|
|
278
372
|
|------|---------|---------|
|
|
279
373
|
| Class unique | `{class}:{index_name}` | `user:email_lookup` |
|
|
374
|
+
| Class multi | `{class}:{index_name}:{value}` | `customer:role_index:admin` |
|
|
280
375
|
| Instance unique | `{scope}:{id}:{index_name}` | `company:123:badge_index` |
|
|
281
|
-
|
|
|
376
|
+
| Instance multi | `{scope}:{id}:{index_name}:{value}` | `company:123:dept_index:engineering` |
|
|
282
377
|
|
|
283
378
|
## Troubleshooting
|
|
284
379
|
|
|
@@ -134,7 +134,30 @@ end
|
|
|
134
134
|
| `employee.remove_from_company_badge_index(company)` | Remove from index |
|
|
135
135
|
| `employee.in_company_badge_index?(company)` | Check if indexed |
|
|
136
136
|
|
|
137
|
-
### Multi-Value Index
|
|
137
|
+
### Class-Level Multi-Value Index
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class Customer < Familia::Horreum
|
|
141
|
+
multi_index :role, :role_index # within: :class is the default
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Class methods on indexed class (Customer):**
|
|
146
|
+
| Method | Description |
|
|
147
|
+
|--------|-------------|
|
|
148
|
+
| `Customer.role_index_for(value)` | Factory returning UnsortedSet for value |
|
|
149
|
+
| `Customer.find_all_by_role(role)` | Find all with that role |
|
|
150
|
+
| `Customer.sample_from_role(role, count)` | Random sample |
|
|
151
|
+
| `Customer.rebuild_role_index` | Rebuild index from instances |
|
|
152
|
+
|
|
153
|
+
**Instance methods on indexed class (Customer):**
|
|
154
|
+
| Method | Description |
|
|
155
|
+
|--------|-------------|
|
|
156
|
+
| `customer.add_to_class_role_index` | Add to index |
|
|
157
|
+
| `customer.remove_from_class_role_index` | Remove from index |
|
|
158
|
+
| `customer.update_in_class_role_index(old_value)` | Move between indexes |
|
|
159
|
+
|
|
160
|
+
### Instance-Scoped Multi-Value Index
|
|
138
161
|
|
|
139
162
|
```ruby
|
|
140
163
|
class Employee < Familia::Horreum
|
|
@@ -145,14 +168,17 @@ end
|
|
|
145
168
|
**Methods on scope class (Company):**
|
|
146
169
|
| Method | Description |
|
|
147
170
|
|--------|-------------|
|
|
171
|
+
| `company.dept_index_for(value)` | Factory returning UnsortedSet for value |
|
|
148
172
|
| `company.find_all_by_department(dept)` | Find all in department |
|
|
149
173
|
| `company.sample_from_department(dept, count)` | Random sample |
|
|
174
|
+
| `company.rebuild_dept_index` | Rebuild index from participation |
|
|
150
175
|
|
|
151
176
|
**Methods on indexed class (Employee):**
|
|
152
177
|
| Method | Description |
|
|
153
178
|
|--------|-------------|
|
|
154
179
|
| `employee.add_to_company_dept_index(company)` | Add to index |
|
|
155
180
|
| `employee.remove_from_company_dept_index(company)` | Remove from index |
|
|
181
|
+
| `employee.update_in_company_dept_index(company, old_dept)` | Move between indexes |
|
|
156
182
|
|
|
157
183
|
## Method Naming Patterns
|
|
158
184
|
|
|
@@ -165,8 +191,9 @@ end
|
|
|
165
191
|
### Indexing
|
|
166
192
|
|
|
167
193
|
- **Class unique**: `find_by_{field}`, `index_{field}_for`, `unindex_{field}_for`
|
|
194
|
+
- **Class multi**: `{Class}.find_all_by_{field}`, `{Class}.sample_from_{field}`, `{item}.add_to_class_{index}`
|
|
168
195
|
- **Scoped unique**: `{scope}.find_by_{field}`, `{item}.add_to_{scope}_{index}`
|
|
169
|
-
- **
|
|
196
|
+
- **Scoped multi**: `{scope}.find_all_by_{field}`, `{scope}.sample_from_{field}`, `{item}.add_to_{scope}_{index}`
|
|
170
197
|
|
|
171
198
|
## Common Usage Examples
|
|
172
199
|
|
|
@@ -206,15 +233,20 @@ customer.domains.range(0, 9) # First 10
|
|
|
206
233
|
### Working with Indexes
|
|
207
234
|
|
|
208
235
|
```ruby
|
|
209
|
-
# Automatic class-level indexing
|
|
236
|
+
# Automatic class-level unique indexing
|
|
210
237
|
user = User.create(email: 'alice@example.com')
|
|
211
238
|
User.find_by_email('alice@example.com') # => user
|
|
212
239
|
|
|
213
|
-
#
|
|
240
|
+
# Class-level multi-value indexing
|
|
241
|
+
customer.add_to_class_role_index
|
|
242
|
+
Customer.find_all_by_role('admin') # => [customer, ...]
|
|
243
|
+
Customer.sample_from_role('admin', 2) # => random sample
|
|
244
|
+
|
|
245
|
+
# Manual scoped unique indexing
|
|
214
246
|
employee.add_to_company_badge_index(company)
|
|
215
247
|
company.find_by_badge_number('12345') # => employee
|
|
216
248
|
|
|
217
|
-
#
|
|
249
|
+
# Scoped multi-value indexing
|
|
218
250
|
employee.add_to_company_dept_index(company)
|
|
219
251
|
engineers = company.find_all_by_department('engineering')
|
|
220
252
|
```
|
data/docs/overview.md
CHANGED
|
@@ -76,6 +76,10 @@ class Product < Familia::Horreum
|
|
|
76
76
|
string :view_count, default: '0'
|
|
77
77
|
# Usage: view_count.increment (atomic increment)
|
|
78
78
|
|
|
79
|
+
# JSON string fields (type-preserving storage)
|
|
80
|
+
json_string :last_synced_at, default: 0.0
|
|
81
|
+
# Usage: last_synced_at stores Float, retrieves Float (not String)
|
|
82
|
+
|
|
79
83
|
# Lists (ordered, allows duplicates)
|
|
80
84
|
list :categories
|
|
81
85
|
# Usage: categories.push('fruit'), categories.pop
|
|
@@ -110,6 +114,10 @@ class Product < Familia::Horreum
|
|
|
110
114
|
stringkey :description # Creates StringKey instance
|
|
111
115
|
listkey :history # Creates ListKey instance
|
|
112
116
|
|
|
117
|
+
# JSON string (type-preserving alternative to StringKey)
|
|
118
|
+
json_string :metadata # Creates JsonStringKey instance
|
|
119
|
+
json_stringkey :config # Creates JsonStringKey instance
|
|
120
|
+
|
|
113
121
|
# Both work identically - choose based on preference
|
|
114
122
|
set :tags # UnsortedSet (unchanged)
|
|
115
123
|
sorted_set :ratings # SortedSet (unchanged)
|
|
@@ -119,6 +127,7 @@ end
|
|
|
119
127
|
# Access patterns are identical
|
|
120
128
|
product.view_count.class # => Familia::StringKey
|
|
121
129
|
product.description.class # => Familia::StringKey
|
|
130
|
+
product.metadata.class # => Familia::JsonStringKey
|
|
122
131
|
product.categories.class # => Familia::ListKey
|
|
123
132
|
product.history.class # => Familia::ListKey
|
|
124
133
|
```
|
data/lib/familia/base.rb
CHANGED
|
@@ -29,7 +29,6 @@ module Familia
|
|
|
29
29
|
#
|
|
30
30
|
module ClassMethods
|
|
31
31
|
attr_reader :features_available, :feature_definitions
|
|
32
|
-
attr_accessor :dump_method, :load_method
|
|
33
32
|
|
|
34
33
|
def add_feature(klass, feature_name, depends_on: [], field_group: nil)
|
|
35
34
|
@features_available ||= {}
|
|
@@ -111,7 +110,6 @@ module Familia
|
|
|
111
110
|
# Module-level methods for Familia::Base itself
|
|
112
111
|
class << self
|
|
113
112
|
attr_reader :features_available, :feature_definitions
|
|
114
|
-
attr_accessor :dump_method, :load_method
|
|
115
113
|
|
|
116
114
|
def add_feature(klass, feature_name, depends_on: [], field_group: nil)
|
|
117
115
|
@features_available ||= {}
|
|
@@ -70,11 +70,10 @@ module Familia
|
|
|
70
70
|
# @param values [Array<String>] The values to deserialize.
|
|
71
71
|
# @return [Array<Object, nil>] Deserialized objects, including nil values.
|
|
72
72
|
#
|
|
73
|
-
# @raise [Familia::Problem] If the specified class doesn't respond to
|
|
74
|
-
# load method.
|
|
73
|
+
# @raise [Familia::Problem] If the specified class doesn't respond to from_json.
|
|
75
74
|
#
|
|
76
75
|
# @note This method attempts to deserialize each value using the specified
|
|
77
|
-
# class's
|
|
76
|
+
# class's from_json method. If deserialization fails for a value, it's
|
|
78
77
|
# replaced with nil.
|
|
79
78
|
#
|
|
80
79
|
def deserialize_values_with_nil(*values)
|
|
@@ -83,20 +82,20 @@ module Familia
|
|
|
83
82
|
|
|
84
83
|
# If a class option is specified, use class-based deserialization
|
|
85
84
|
if @opts[:class]
|
|
86
|
-
unless @opts[:class].respond_to?(
|
|
87
|
-
raise Familia::Problem, "No such method: #{@opts[:class]}
|
|
85
|
+
unless @opts[:class].respond_to?(:from_json)
|
|
86
|
+
raise Familia::Problem, "No such method: #{@opts[:class]}.from_json"
|
|
88
87
|
end
|
|
89
88
|
|
|
90
89
|
values.collect! do |obj|
|
|
91
90
|
next if obj.nil?
|
|
92
91
|
|
|
93
|
-
val = @opts[:class].
|
|
94
|
-
Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}
|
|
92
|
+
val = @opts[:class].from_json(obj)
|
|
93
|
+
Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}.from_json" if val.nil?
|
|
95
94
|
|
|
96
95
|
val
|
|
97
96
|
rescue StandardError => e
|
|
98
|
-
Familia.info
|
|
99
|
-
Familia.info "Parse error for #{dbkey} (
|
|
97
|
+
Familia.info obj
|
|
98
|
+
Familia.info "Parse error for #{dbkey} (from_json): #{e.message}"
|
|
100
99
|
Familia.info e.backtrace
|
|
101
100
|
nil
|
|
102
101
|
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# lib/familia/data_type/types/json_stringkey.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
module Familia
|
|
6
|
+
# JsonStringKey - A string DataType that uses JSON serialization for type preservation.
|
|
7
|
+
#
|
|
8
|
+
# Unlike StringKey which uses raw string serialization (to support Redis operations
|
|
9
|
+
# like INCR/DECR/APPEND), JsonStringKey uses the base DataType's JSON serialization
|
|
10
|
+
# to preserve Ruby types across the Redis storage boundary.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# class MyIndex < Familia::Horreum
|
|
14
|
+
# class_json_string :last_synced_at, default: 0.0
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# MyIndex.last_synced_at = Time.now.to_f # Stored as JSON number
|
|
18
|
+
# MyIndex.last_synced_at #=> 1704067200.123 (Float preserved)
|
|
19
|
+
#
|
|
20
|
+
# @example Type preservation
|
|
21
|
+
# json_str.value = 42 # Stored in Redis as: 42 (JSON number)
|
|
22
|
+
# json_str.value = true # Stored in Redis as: true (JSON boolean)
|
|
23
|
+
# json_str.value = [1, 2, 3] # Stored in Redis as: [1,2,3] (JSON array)
|
|
24
|
+
#
|
|
25
|
+
# @note This class intentionally does NOT include increment/decrement or other
|
|
26
|
+
# raw string operations that are incompatible with JSON serialization.
|
|
27
|
+
#
|
|
28
|
+
# @note Performance: Each call to value, to_s, to_i, to_f makes a Redis
|
|
29
|
+
# roundtrip. If you need the value multiple times and don't expect it to
|
|
30
|
+
# change, store it in a local variable:
|
|
31
|
+
#
|
|
32
|
+
# # Inefficient (3 Redis calls):
|
|
33
|
+
# puts json_str.to_s
|
|
34
|
+
# puts json_str.to_i
|
|
35
|
+
# puts json_str.to_f
|
|
36
|
+
#
|
|
37
|
+
# # Efficient (1 Redis call):
|
|
38
|
+
# val = json_str.value
|
|
39
|
+
# puts val.to_s
|
|
40
|
+
# puts val.to_i
|
|
41
|
+
# puts val.to_f
|
|
42
|
+
#
|
|
43
|
+
class JsonStringKey < DataType
|
|
44
|
+
# Initialization hook (required by DataType contract)
|
|
45
|
+
def init; end
|
|
46
|
+
|
|
47
|
+
# Returns the number of characters in the string representation of the value.
|
|
48
|
+
#
|
|
49
|
+
# @return [Integer] number of characters
|
|
50
|
+
#
|
|
51
|
+
def char_count
|
|
52
|
+
to_s&.size || 0
|
|
53
|
+
end
|
|
54
|
+
alias size char_count
|
|
55
|
+
alias length char_count
|
|
56
|
+
|
|
57
|
+
# Returns the current value stored at the key.
|
|
58
|
+
#
|
|
59
|
+
# If a default option was provided during initialization, the default
|
|
60
|
+
# is set via SETNX (set if not exists) before retrieval.
|
|
61
|
+
#
|
|
62
|
+
# @return [Object] the deserialized value, or the default if not set
|
|
63
|
+
#
|
|
64
|
+
def value
|
|
65
|
+
echo :value, Familia.pretty_stack(limit: 1) if Familia.debug
|
|
66
|
+
if @opts.key?(:default)
|
|
67
|
+
was_set = dbclient.setnx(dbkey, serialize_value(@opts[:default]))
|
|
68
|
+
update_expiration if was_set
|
|
69
|
+
end
|
|
70
|
+
deserialize_value dbclient.get(dbkey)
|
|
71
|
+
end
|
|
72
|
+
alias content value
|
|
73
|
+
alias get value
|
|
74
|
+
|
|
75
|
+
# Sets the value at the key.
|
|
76
|
+
#
|
|
77
|
+
# The value is JSON-serialized before storage, preserving its Ruby type.
|
|
78
|
+
#
|
|
79
|
+
# @param val [Object] the value to store
|
|
80
|
+
# @return [String] "OK" on success
|
|
81
|
+
#
|
|
82
|
+
def value=(val)
|
|
83
|
+
ret = dbclient.set(dbkey, serialize_value(val))
|
|
84
|
+
update_expiration
|
|
85
|
+
ret
|
|
86
|
+
end
|
|
87
|
+
alias replace value=
|
|
88
|
+
alias set value=
|
|
89
|
+
|
|
90
|
+
# Sets the value only if the key does not already exist.
|
|
91
|
+
#
|
|
92
|
+
# @param val [Object] the value to store
|
|
93
|
+
# @return [Boolean] true if the key was set, false if it already existed
|
|
94
|
+
#
|
|
95
|
+
def setnx(val)
|
|
96
|
+
ret = dbclient.setnx(dbkey, serialize_value(val))
|
|
97
|
+
update_expiration if ret
|
|
98
|
+
ret
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Deletes the key from the database.
|
|
102
|
+
#
|
|
103
|
+
# @return [Boolean] true if the key was deleted, false if it didn't exist
|
|
104
|
+
#
|
|
105
|
+
def del
|
|
106
|
+
ret = dbclient.del dbkey
|
|
107
|
+
ret.positive?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Checks if the value is nil (key does not exist or has no value).
|
|
111
|
+
#
|
|
112
|
+
# @return [Boolean] true if the value is nil
|
|
113
|
+
#
|
|
114
|
+
def empty?
|
|
115
|
+
value.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Returns the string representation of the deserialized value.
|
|
119
|
+
#
|
|
120
|
+
# @return [String, nil] the deserialized value converted to string, or nil
|
|
121
|
+
#
|
|
122
|
+
def to_s
|
|
123
|
+
val = deserialize_value(dbclient.get(dbkey))
|
|
124
|
+
return nil if val.nil?
|
|
125
|
+
|
|
126
|
+
val.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the integer representation of the deserialized value.
|
|
130
|
+
#
|
|
131
|
+
# @return [Integer, nil] the deserialized value converted to integer, or nil
|
|
132
|
+
#
|
|
133
|
+
def to_i
|
|
134
|
+
val = deserialize_value(dbclient.get(dbkey))
|
|
135
|
+
return nil if val.nil?
|
|
136
|
+
|
|
137
|
+
val.to_i
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Returns the float representation of the deserialized value.
|
|
141
|
+
#
|
|
142
|
+
# @return [Float, nil] the deserialized value converted to float, or nil
|
|
143
|
+
#
|
|
144
|
+
def to_f
|
|
145
|
+
val = deserialize_value(dbclient.get(dbkey))
|
|
146
|
+
return nil if val.nil?
|
|
147
|
+
|
|
148
|
+
val.to_f
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Familia::DataType.register self, :json_string
|
|
152
|
+
Familia::DataType.register self, :json_stringkey
|
|
153
|
+
Familia::DataType.register self, :jsonkey
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/familia/data_type.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Familia
|
|
|
14
14
|
# DataType - Base class for Database data type wrappers
|
|
15
15
|
#
|
|
16
16
|
# This class provides common functionality for various Database data types
|
|
17
|
-
# such as String, List, UnsortedSet, SortedSet, and HashKey.
|
|
17
|
+
# such as String, JsonStringKey, List, UnsortedSet, SortedSet, and HashKey.
|
|
18
18
|
#
|
|
19
19
|
# @abstract Subclass and implement Database data type specific methods
|
|
20
20
|
class DataType
|
|
@@ -41,9 +41,9 @@ module Familia
|
|
|
41
41
|
#
|
|
42
42
|
# Options:
|
|
43
43
|
#
|
|
44
|
-
# :class => A class that responds to
|
|
45
|
-
#
|
|
46
|
-
#
|
|
44
|
+
# :class => A class that responds to from_json. This will be used
|
|
45
|
+
# when loading data from the database to unmarshal the class.
|
|
46
|
+
# JSON serialization is used for all data storage.
|
|
47
47
|
#
|
|
48
48
|
# :parent => The Familia object that this datatype object belongs
|
|
49
49
|
# to. This can be a class that includes Familia or an instance.
|
|
@@ -88,4 +88,5 @@ module Familia
|
|
|
88
88
|
require_relative 'data_type/types/sorted_set'
|
|
89
89
|
require_relative 'data_type/types/hashkey'
|
|
90
90
|
require_relative 'data_type/types/stringkey'
|
|
91
|
+
require_relative 'data_type/types/json_stringkey'
|
|
91
92
|
end
|