familia 2.0.0.pre21 → 2.0.0.pre22
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/.talismanrc +5 -1
- data/CHANGELOG.rst +43 -0
- data/Gemfile.lock +1 -1
- data/lib/familia/connection/operation_core.rb +1 -2
- data/lib/familia/connection/pipelined_core.rb +1 -3
- data/lib/familia/connection/transaction_core.rb +1 -2
- data/lib/familia/data_type/serialization.rb +76 -51
- data/lib/familia/data_type/types/sorted_set.rb +5 -10
- data/lib/familia/data_type/types/stringkey.rb +22 -0
- data/lib/familia/features/external_identifier.rb +29 -0
- data/lib/familia/features/object_identifier.rb +47 -0
- data/lib/familia/features/relationships/indexing/rebuild_strategies.rb +15 -15
- data/lib/familia/features/relationships/indexing/unique_index_generators.rb +8 -0
- data/lib/familia/horreum/database_commands.rb +6 -1
- data/lib/familia/horreum/management.rb +141 -10
- data/lib/familia/horreum/persistence.rb +3 -0
- data/lib/familia/identifier_extractor.rb +1 -1
- data/lib/familia/version.rb +1 -1
- data/lib/multi_result.rb +59 -31
- data/try/features/count_any_edge_cases_try.rb +486 -0
- data/try/features/count_any_methods_try.rb +197 -0
- data/try/features/external_identifier/external_identifier_try.rb +134 -0
- data/try/features/object_identifier/object_identifier_try.rb +138 -0
- data/try/features/relationships/indexing_rebuild_try.rb +6 -0
- data/try/integration/data_types/datatype_pipelines_try.rb +5 -3
- data/try/integration/data_types/datatype_transactions_try.rb +13 -7
- data/try/integration/models/customer_try.rb +3 -3
- data/try/unit/data_types/boolean_try.rb +35 -22
- data/try/unit/data_types/hash_try.rb +2 -2
- data/try/unit/data_types/serialization_try.rb +386 -0
- data/try/unit/horreum/destroy_related_fields_cleanup_try.rb +2 -1
- metadata +4 -7
- data/changelog.d/20251105_flexible_external_identifier_format.rst +0 -66
- data/changelog.d/20251107_112554_delano_179_participation_asymmetry.rst +0 -44
- data/changelog.d/20251107_213121_delano_fix_thread_safety_races_011CUumCP492Twxm4NLt2FvL.rst +0 -20
- data/changelog.d/20251107_fix_participates_in_symbol_resolution.rst +0 -91
- data/changelog.d/20251107_optimized_redis_exists_checks.rst +0 -94
- data/changelog.d/20251108_frozen_string_literal_pragma.rst +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf37d5963fa9ae33323b70ad4eea48cfa45e91aa59ea46ecce2ac85644a9d0e2
|
|
4
|
+
data.tar.gz: 8880f7e2914b12db9a0cdcf2f741968c7c43731ef15833a921be72df19de70cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31b585405895213ee9857a66306ee2e7b60ce2f26568915571bc8c992e1169ff8589856c40253e2b8ef8081fd3805b0f32f0a769d85108a11528786621ac4440
|
|
7
|
+
data.tar.gz: 715766983ba9c44603bc8a94c240dc716130cc70cc182fd84dec1cc5b17333de32e52b1677610d62b6aba047aadada2cdf65b61867df1d3bffab08f94d8c4067
|
data/.talismanrc
CHANGED
|
@@ -5,5 +5,9 @@
|
|
|
5
5
|
# https://thoughtworks.github.io/talisman/docs/configuring-talisman/severity-threshold/
|
|
6
6
|
#
|
|
7
7
|
threshold: medium
|
|
8
|
-
fileignoreconfig:
|
|
8
|
+
fileignoreconfig:
|
|
9
|
+
- filename: try/features/external_identifier/external_identifier_try.rb
|
|
10
|
+
checksum: dcc0dd135cd8c569b096c922e6260e446d25b65b7b97b8a4cb4ccaac59325b38
|
|
11
|
+
- filename: try/features/object_identifier/object_identifier_try.rb
|
|
12
|
+
checksum: 10c94f802b2fa3a39fa33bf7dc34dac2ecaa2dd453ff97a19313f96a32fadeff
|
|
9
13
|
version: ""
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,49 @@ 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.pre22:
|
|
11
|
+
|
|
12
|
+
2.0.0.pre22 — 2025-12-03
|
|
13
|
+
========================
|
|
14
|
+
|
|
15
|
+
- **ExternalIdentifier Format Flexibility**: The `external_identifier` feature now supports customizable format templates via the `format` option (e.g., `format: 'cust_%{id}'` or `format: 'api-%{id}'`). Default format remains `'ext_%{id}'`. Provides complete flexibility for various ID formatting needs including different prefixes, separators, URL paths, or no prefix at all.
|
|
16
|
+
|
|
17
|
+
- **Participation Relationships with Symbol/String Target Classes**: Fixed four bugs that occurred when calling `participates_in` with Symbol/String target class instead of Class object. Issues included NoMethodError during relationship definition (private method call), failures in `current_participations` (undefined `familia_name`), errors in `target_class_config_name` (undefined `config_name`), and confusing error messages for load order issues. All now properly resolve using `Familia.resolve_class` API with clear error messages for common issues.
|
|
18
|
+
|
|
19
|
+
- **Pipelined Bulk Loading Methods**: New `load_multi` and `load_multi_by_keys` methods enable efficient bulk object loading using Redis pipelining, reducing network round trips from N×2 commands to a single batch (up to 2× performance improvement). Methods maintain nil-return contract for missing objects and preserve input order.
|
|
20
|
+
|
|
21
|
+
- **Optional EXISTS Check Optimization**: The `find_by_dbkey` and `find_by_identifier` methods now accept `check_exists:` parameter (default: `true`) to optionally skip EXISTS check, reducing Redis commands from 2 to 1 per object. Maintains backwards compatibility and same nil-return behavior.
|
|
22
|
+
|
|
23
|
+
- **Parameter Consistency**: The `suffix` parameter in `find_by_identifier` is now a keyword parameter (was optional positional) for consistency with `check_exists`, following Ruby conventions.
|
|
24
|
+
|
|
25
|
+
Added
|
|
26
|
+
-----
|
|
27
|
+
|
|
28
|
+
- Bidirectional reverse collection methods for ``participates_in`` with ``_instances`` suffix (e.g., ``user.project_team_instances``, ``user.project_team_ids``). Supports union behavior for multiple collections and custom naming via ``as:`` parameter. Closes #179.
|
|
29
|
+
|
|
30
|
+
Changed
|
|
31
|
+
-------
|
|
32
|
+
|
|
33
|
+
- All Ruby files now include consistent headers with ``frozen_string_literal: true`` pragma for improved performance and memory efficiency. Headers follow the format: filename comment, blank comment line, frozen string literal pragma. Executable scripts properly place shebang first.
|
|
34
|
+
|
|
35
|
+
- Standardized DataType serialization to use JSON encoding for type preservation, matching Horreum field behavior. All primitive values (Integer, Boolean, String, Float, Hash, Array, nil) are now consistently serialized through JSON, ensuring types are preserved across the Redis storage boundary. Familia object references continue to use identifier extraction. Issue #190.
|
|
36
|
+
|
|
37
|
+
Fixed
|
|
38
|
+
-----
|
|
39
|
+
|
|
40
|
+
- Fixed critical race condition in mutex initialization for connection chain lazy loading. The mutex itself was being lazily initialized with ``||=``, which is not atomic and could result in multiple threads creating different mutex instances, defeating synchronization. Changed to eager initialization via ``Connection.included`` hook. (`lib/familia/horreum/connection.rb`)
|
|
41
|
+
|
|
42
|
+
- Fixed critical race condition in mutex initialization for logger lazy loading. Similar to connection chain issue, the logger mutex was lazily initialized with ``||=``. Changed to eager initialization at module definition time. (`lib/familia/logging.rb`)
|
|
43
|
+
|
|
44
|
+
- Fixed logger assignment atomicity issue where ``Familia.logger=`` set ``DatabaseLogger.logger`` outside the mutex synchronization block, potentially causing ``Familia.logger`` and ``DatabaseLogger.logger`` to be temporarily out of sync during concurrent access. Moved ``DatabaseLogger.logger`` assignment inside the synchronization block. (`lib/familia/logging.rb`)
|
|
45
|
+
|
|
46
|
+
- Added explicit return statement to ``Familia.logger`` method for robustness against future refactoring. (`lib/familia/logging.rb`)
|
|
47
|
+
|
|
48
|
+
AI Assistance
|
|
49
|
+
-------------
|
|
50
|
+
|
|
51
|
+
- Claude Code (Opus 4, Sonnet 4.5): Implementation of bidirectional participation relationships, external identifier format flexibility, bulk loading optimization with pipelining, race condition fixes in mutex initialization, frozen string literal pragma automation (308 files), and DataType serialization standardization. Comprehensive test coverage and documentation throughout.
|
|
52
|
+
|
|
10
53
|
.. _changelog-2.0.0.pre21:
|
|
11
54
|
|
|
12
55
|
2.0.0.pre21 — 2025-10-21
|
data/Gemfile.lock
CHANGED
|
@@ -87,8 +87,7 @@ module Familia
|
|
|
87
87
|
|
|
88
88
|
# Return MultiResult format for consistency
|
|
89
89
|
results = proxy.collected_results
|
|
90
|
-
|
|
91
|
-
MultiResult.new(summary_boolean, results)
|
|
90
|
+
MultiResult.new(results)
|
|
92
91
|
end
|
|
93
92
|
end
|
|
94
93
|
end
|
|
@@ -80,9 +80,7 @@ module Familia
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# Return same MultiResult format as other methods
|
|
83
|
-
|
|
84
|
-
summary_boolean = command_return_values.none? { |ret| ret.is_a?(Exception) }
|
|
85
|
-
MultiResult.new(summary_boolean, command_return_values)
|
|
83
|
+
MultiResult.new(command_return_values)
|
|
86
84
|
end
|
|
87
85
|
end
|
|
88
86
|
end
|
|
@@ -158,8 +158,7 @@ module Familia
|
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
# Return same MultiResult format as other methods
|
|
161
|
-
|
|
162
|
-
MultiResult.new(summary_boolean, command_return_values)
|
|
161
|
+
MultiResult.new(command_return_values)
|
|
163
162
|
end
|
|
164
163
|
end
|
|
165
164
|
end
|
|
@@ -8,46 +8,45 @@ module Familia
|
|
|
8
8
|
# Serializes a value for storage in the database.
|
|
9
9
|
#
|
|
10
10
|
# @param val [Object] The value to be serialized.
|
|
11
|
-
# @
|
|
12
|
-
# serialization (default: true).
|
|
13
|
-
# @return [String, nil] The serialized representation of the value, or nil
|
|
14
|
-
# if serialization fails.
|
|
11
|
+
# @return [String] The JSON-serialized representation of the value.
|
|
15
12
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# Serialization priority:
|
|
14
|
+
# 1. Familia objects (Base instances or classes) → extract identifier
|
|
15
|
+
# 2. All other values → JSON serialize for type preservation
|
|
19
16
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
17
|
+
# This unifies behavior with Horreum fields (Issue #190), ensuring
|
|
18
|
+
# consistent type preservation across DataType and Horreum.
|
|
22
19
|
#
|
|
23
|
-
# @example
|
|
24
|
-
# serialize_value(
|
|
25
|
-
# serialize_value("hello") #=> "hello"
|
|
20
|
+
# @example Familia object reference
|
|
21
|
+
# serialize_value(customer_obj) #=> "customer_123" (identifier)
|
|
26
22
|
#
|
|
27
|
-
# @
|
|
28
|
-
#
|
|
23
|
+
# @example Primitive values (JSON encoded)
|
|
24
|
+
# serialize_value(42) #=> "42"
|
|
25
|
+
# serialize_value("hello") #=> '"hello"'
|
|
26
|
+
# serialize_value(true) #=> "true"
|
|
27
|
+
# serialize_value(nil) #=> "null"
|
|
28
|
+
# serialize_value([1, 2, 3]) #=> "[1,2,3]"
|
|
29
29
|
#
|
|
30
|
-
def serialize_value(val
|
|
31
|
-
prepared = nil
|
|
32
|
-
|
|
30
|
+
def serialize_value(val)
|
|
33
31
|
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}|#{opts[:class]}>" if Familia.debug?
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Priority 1: Handle Familia object references - extract identifier
|
|
34
|
+
# This preserves the existing behavior for storing object references
|
|
35
|
+
if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
|
|
36
|
+
prepared = val.is_a?(Class) ? val.name : val.identifier
|
|
37
|
+
Familia.debug " Familia object: #{val.class} => #{prepared}"
|
|
38
|
+
return prepared
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
end
|
|
41
|
+
# Priority 2: Everything else gets JSON serialized for type preservation
|
|
42
|
+
# This unifies behavior with Horreum fields (Issue #190)
|
|
43
|
+
prepared = Familia::JsonSerializer.dump(val)
|
|
44
|
+
Familia.debug " JSON serialized: #{val.class} => #{prepared}"
|
|
45
45
|
|
|
46
46
|
if Familia.debug?
|
|
47
|
-
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}
|
|
47
|
+
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}> => #{prepared}<#{prepared.class}>"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
Familia.warn "[#{self.class}#serialize_value] nil returned for #{opts[:class]}##{name}" if prepared.nil?
|
|
51
50
|
prepared
|
|
52
51
|
end
|
|
53
52
|
|
|
@@ -81,27 +80,41 @@ module Familia
|
|
|
81
80
|
def deserialize_values_with_nil(*values)
|
|
82
81
|
Familia.debug "deserialize_values: (#{@opts}) #{values}"
|
|
83
82
|
return [] if values.empty?
|
|
84
|
-
return values.flatten unless @opts[:class]
|
|
85
83
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
# If a class option is specified, use class-based deserialization
|
|
85
|
+
if @opts[:class]
|
|
86
|
+
unless @opts[:class].respond_to?(load_method)
|
|
87
|
+
raise Familia::Problem, "No such method: #{@opts[:class]}##{load_method}"
|
|
88
|
+
end
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
values.collect! do |obj|
|
|
91
|
+
next if obj.nil?
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
val = @opts[:class].send load_method, obj
|
|
94
|
+
Familia.debug "[#{self.class}#deserialize_values] nil returned for #{@opts[:class]}##{name}" if val.nil?
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
val
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Familia.info val
|
|
99
|
+
Familia.info "Parse error for #{dbkey} (#{load_method}): #{e.message}"
|
|
100
|
+
Familia.info e.backtrace
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return values
|
|
102
105
|
end
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
# No class option: JSON deserialize each value for type preservation (Issue #190)
|
|
108
|
+
values.flatten.collect do |obj|
|
|
109
|
+
next if obj.nil?
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
Familia::JsonSerializer.parse(obj)
|
|
113
|
+
rescue Familia::SerializerError
|
|
114
|
+
# Fallback for legacy data stored without JSON encoding
|
|
115
|
+
obj
|
|
116
|
+
end
|
|
117
|
+
end
|
|
105
118
|
end
|
|
106
119
|
|
|
107
120
|
# Deserializes a single value from the database.
|
|
@@ -110,13 +123,15 @@ module Familia
|
|
|
110
123
|
# @return [Object, nil] The deserialized object, the default value if
|
|
111
124
|
# val is nil, or nil if deserialization fails.
|
|
112
125
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
126
|
+
# Deserialization priority:
|
|
127
|
+
# 1. Redis::Future objects → return as-is (transaction handling)
|
|
128
|
+
# 2. nil values → return default option value
|
|
129
|
+
# 3. Class option specified → use class-based deserialization
|
|
130
|
+
# 4. No class option → JSON parse for type preservation
|
|
115
131
|
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
# for serialization since everything becomes a string in Valkey.
|
|
132
|
+
# This unifies behavior with Horreum fields (Issue #190), ensuring
|
|
133
|
+
# consistent type preservation. Legacy data stored without JSON
|
|
134
|
+
# encoding is returned as-is.
|
|
120
135
|
#
|
|
121
136
|
def deserialize_value(val)
|
|
122
137
|
# Handle Redis::Future objects during transactions first
|
|
@@ -124,10 +139,20 @@ module Familia
|
|
|
124
139
|
|
|
125
140
|
return @opts[:default] if val.nil?
|
|
126
141
|
|
|
127
|
-
|
|
142
|
+
# If a class option is specified, use the existing class-based deserialization
|
|
143
|
+
if @opts[:class]
|
|
144
|
+
ret = deserialize_values val
|
|
145
|
+
return ret&.first # return the object or nil
|
|
146
|
+
end
|
|
128
147
|
|
|
129
|
-
|
|
130
|
-
|
|
148
|
+
# No class option: JSON deserialize for type preservation (Issue #190)
|
|
149
|
+
# This unifies behavior with Horreum fields
|
|
150
|
+
begin
|
|
151
|
+
Familia::JsonSerializer.parse(val)
|
|
152
|
+
rescue Familia::SerializerError
|
|
153
|
+
# Fallback for legacy data stored without JSON encoding
|
|
154
|
+
val
|
|
155
|
+
end
|
|
131
156
|
end
|
|
132
157
|
end
|
|
133
158
|
end
|
|
@@ -129,7 +129,7 @@ module Familia
|
|
|
129
129
|
alias add_element add
|
|
130
130
|
|
|
131
131
|
def score(val)
|
|
132
|
-
ret = dbclient.zscore dbkey, serialize_value(val
|
|
132
|
+
ret = dbclient.zscore dbkey, serialize_value(val)
|
|
133
133
|
ret&.to_f
|
|
134
134
|
end
|
|
135
135
|
alias [] score
|
|
@@ -142,13 +142,13 @@ module Familia
|
|
|
142
142
|
|
|
143
143
|
# rank of member +v+ when ordered lowest to highest (starts at 0)
|
|
144
144
|
def rank(v)
|
|
145
|
-
ret = dbclient.zrank dbkey, serialize_value(v
|
|
145
|
+
ret = dbclient.zrank dbkey, serialize_value(v)
|
|
146
146
|
ret&.to_i
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
# rank of member +v+ when ordered highest to lowest (starts at 0)
|
|
150
150
|
def revrank(v)
|
|
151
|
-
ret = dbclient.zrevrank dbkey, serialize_value(v
|
|
151
|
+
ret = dbclient.zrevrank dbkey, serialize_value(v)
|
|
152
152
|
ret&.to_i
|
|
153
153
|
end
|
|
154
154
|
|
|
@@ -269,7 +269,7 @@ module Familia
|
|
|
269
269
|
end
|
|
270
270
|
|
|
271
271
|
def increment(val, by = 1)
|
|
272
|
-
dbclient.zincrby(dbkey, by, val).to_i
|
|
272
|
+
dbclient.zincrby(dbkey, by, serialize_value(val)).to_i
|
|
273
273
|
end
|
|
274
274
|
alias incr increment
|
|
275
275
|
alias incrby increment
|
|
@@ -285,12 +285,7 @@ module Familia
|
|
|
285
285
|
# @return [Integer] The number of members that were removed (0 or 1)
|
|
286
286
|
def remove_element(value)
|
|
287
287
|
Familia.trace :REMOVE_ELEMENT, nil, "#{value}<#{value.class}>" if Familia.debug?
|
|
288
|
-
|
|
289
|
-
# that are in the sorted set. If it's a horreum object, the value is
|
|
290
|
-
# the identifier and not a serialized version of the object. So either
|
|
291
|
-
# the value exists in the sorted set or it doesn't -- we don't need to
|
|
292
|
-
# raise an error if it's not found.
|
|
293
|
-
dbclient.zrem dbkey, serialize_value(value, strict_values: false)
|
|
288
|
+
dbclient.zrem dbkey, serialize_value(value)
|
|
294
289
|
end
|
|
295
290
|
alias remove remove_element # deprecated
|
|
296
291
|
|
|
@@ -6,6 +6,28 @@ module Familia
|
|
|
6
6
|
class StringKey < DataType
|
|
7
7
|
def init; end
|
|
8
8
|
|
|
9
|
+
# StringKey uses raw string serialization (not JSON) because Redis string
|
|
10
|
+
# operations like INCR, DECR, APPEND operate on raw values.
|
|
11
|
+
# This overrides the base JSON serialization from DataType.
|
|
12
|
+
def serialize_value(val)
|
|
13
|
+
Familia.trace :TOREDIS, nil, "#{val}<#{val.class}>" if Familia.debug?
|
|
14
|
+
|
|
15
|
+
# Handle Familia object references - extract identifier
|
|
16
|
+
if val.is_a?(Familia::Base) || (val.is_a?(Class) && val.ancestors.include?(Familia::Base))
|
|
17
|
+
return val.is_a?(Class) ? val.name : val.identifier
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# StringKey uses raw string conversion for Redis compatibility
|
|
21
|
+
val.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# StringKey returns raw values (not JSON parsed)
|
|
25
|
+
def deserialize_value(val)
|
|
26
|
+
return val if val.is_a?(Redis::Future)
|
|
27
|
+
return @opts[:default] if val.nil?
|
|
28
|
+
val
|
|
29
|
+
end
|
|
30
|
+
|
|
9
31
|
# Returns the number of elements in the list
|
|
10
32
|
# @return [Integer] number of elements
|
|
11
33
|
def char_count
|
|
@@ -176,6 +176,35 @@ module Familia
|
|
|
176
176
|
# extid_lookup.remove_field(extid)
|
|
177
177
|
nil
|
|
178
178
|
end
|
|
179
|
+
|
|
180
|
+
# Check if a string matches the extid format for the Horreum class. The specific
|
|
181
|
+
# class is important b/c each one can have its own custom prefix, like `ext_`.
|
|
182
|
+
#
|
|
183
|
+
# The validator accepts a reasonable range of ID lengths (20-32 characters) to
|
|
184
|
+
# accommodate potential changes to the entropy or encoding while maintaining
|
|
185
|
+
# security. The current implementation generates exactly 25 base36 characters
|
|
186
|
+
# from 16 bytes (128 bits), but this flexibility allows for future adjustments
|
|
187
|
+
# without breaking validation.
|
|
188
|
+
#
|
|
189
|
+
# @param guess [String] The string to check
|
|
190
|
+
# @return [Boolean] true if the guess matches the extid format, false otherwise
|
|
191
|
+
def extid?(guess)
|
|
192
|
+
return false if guess.to_s.empty?
|
|
193
|
+
|
|
194
|
+
options = feature_options(:external_identifier)
|
|
195
|
+
format = options[:format] || 'ext_%{id}'
|
|
196
|
+
|
|
197
|
+
# Extract prefix and suffix from format
|
|
198
|
+
return false unless format.include?('%{id}')
|
|
199
|
+
prefix, suffix = format.split('%{id}', 2)
|
|
200
|
+
|
|
201
|
+
# Build regex pattern to match the extid format
|
|
202
|
+
# Accept 20-32 base36 characters to allow for entropy/encoding variations
|
|
203
|
+
# Current generation: 16 bytes -> base36 -> 25 chars (rjust with '0')
|
|
204
|
+
pattern = /\A#{Regexp.escape(prefix)}[0-9a-z]{20,32}#{Regexp.escape(suffix)}\z/i
|
|
205
|
+
|
|
206
|
+
!!(guess =~ pattern)
|
|
207
|
+
end
|
|
179
208
|
end
|
|
180
209
|
|
|
181
210
|
# Instance methods for external identifier management
|
|
@@ -279,6 +279,53 @@ module Familia
|
|
|
279
279
|
# objid_lookup.remove_field(objid)
|
|
280
280
|
nil
|
|
281
281
|
end
|
|
282
|
+
|
|
283
|
+
# Check if a string matches the objid format for the Horreum class. The specific
|
|
284
|
+
# class is important b/c each one can have its own type of objid generator.
|
|
285
|
+
#
|
|
286
|
+
# @param guess [String] The string to check
|
|
287
|
+
# @return [Boolean] true if the guess matches the objid format, false otherwise
|
|
288
|
+
def objid?(guess)
|
|
289
|
+
return false if guess.to_s.empty?
|
|
290
|
+
|
|
291
|
+
options = feature_options(:object_identifier)
|
|
292
|
+
generator = options[:generator] || DEFAULT_GENERATOR
|
|
293
|
+
|
|
294
|
+
case generator
|
|
295
|
+
when :uuid_v7, :uuid_v4
|
|
296
|
+
# UUID format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)
|
|
297
|
+
# Validate structure and that all characters are valid hex digits
|
|
298
|
+
guess_str = guess.to_s
|
|
299
|
+
return false unless guess_str.length == 36
|
|
300
|
+
return false unless guess_str[8] == '-' && guess_str[13] == '-' && guess_str[18] == '-' && guess_str[23] == '-'
|
|
301
|
+
|
|
302
|
+
# Extract segments and validate each is valid hex
|
|
303
|
+
segments = guess_str.split('-')
|
|
304
|
+
return false unless segments.length == 5
|
|
305
|
+
return false unless segments[0] =~ /\A[0-9a-fA-F]{8}\z/ # 8 hex chars
|
|
306
|
+
return false unless segments[1] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
|
|
307
|
+
return false unless segments[2] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars (includes version)
|
|
308
|
+
return false unless segments[3] =~ /\A[0-9a-fA-F]{4}\z/ # 4 hex chars
|
|
309
|
+
return false unless segments[4] =~ /\A[0-9a-fA-F]{12}\z/ # 12 hex chars
|
|
310
|
+
|
|
311
|
+
# Validate version character
|
|
312
|
+
version_char = guess_str[14]
|
|
313
|
+
if generator == :uuid_v7
|
|
314
|
+
version_char == '7'
|
|
315
|
+
else # generator == :uuid_v4
|
|
316
|
+
version_char == '4'
|
|
317
|
+
end
|
|
318
|
+
when :hex
|
|
319
|
+
# Hex format: pure hexadecimal without hyphens
|
|
320
|
+
!!(guess =~ /\A[0-9a-fA-F]+\z/)
|
|
321
|
+
when Proc
|
|
322
|
+
# Cannot determine format for custom Proc generators
|
|
323
|
+
Familia.warn "[objid?] Validation not supported for custom Proc generators on #{name}" if Familia.debug?
|
|
324
|
+
false
|
|
325
|
+
else
|
|
326
|
+
false
|
|
327
|
+
end
|
|
328
|
+
end
|
|
282
329
|
end
|
|
283
330
|
|
|
284
331
|
# Instance methods for object identifier management
|
|
@@ -76,7 +76,7 @@ module Familia
|
|
|
76
76
|
# batch_size: 100
|
|
77
77
|
# ) { |p| puts "#{p[:completed]}/#{p[:total]} (#{p[:rate]}/s)" }
|
|
78
78
|
#
|
|
79
|
-
def rebuild_via_instances(indexed_class, field, add_method, batch_size: 100, &progress)
|
|
79
|
+
def rebuild_via_instances(indexed_class, field, add_method, index_hashkey, batch_size: 100, &progress)
|
|
80
80
|
unless indexed_class.respond_to?(:instances)
|
|
81
81
|
raise ArgumentError, "#{indexed_class.name} does not have an instances collection"
|
|
82
82
|
end
|
|
@@ -104,8 +104,8 @@ module Familia
|
|
|
104
104
|
processed = 0
|
|
105
105
|
indexed_count = 0
|
|
106
106
|
|
|
107
|
-
# Process in batches - use
|
|
108
|
-
instances.
|
|
107
|
+
# Process in batches - use members to get deserialized identifiers
|
|
108
|
+
instances.members.each_slice(batch_size) do |identifiers|
|
|
109
109
|
# Bulk load objects, filtering out nils (deleted/missing objects)
|
|
110
110
|
objects = indexed_class.load_multi(identifiers).compact
|
|
111
111
|
|
|
@@ -117,8 +117,8 @@ module Familia
|
|
|
117
117
|
# Skip nil/empty field values gracefully
|
|
118
118
|
next unless value && !value.to_s.strip.empty?
|
|
119
119
|
|
|
120
|
-
# For class-level indexes, use HSET
|
|
121
|
-
tx.hset(temp_key, value.to_s, obj
|
|
120
|
+
# For class-level indexes, use HSET with serialized value for consistency
|
|
121
|
+
tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
|
|
122
122
|
batch_indexed += 1
|
|
123
123
|
end
|
|
124
124
|
end
|
|
@@ -177,7 +177,7 @@ module Familia
|
|
|
177
177
|
# batch_size: 100
|
|
178
178
|
# )
|
|
179
179
|
#
|
|
180
|
-
def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, batch_size: 100, &progress)
|
|
180
|
+
def rebuild_via_participation(scope_instance, indexed_class, field, add_method, collection, cardinality, index_hashkey, batch_size: 100, &progress)
|
|
181
181
|
total = collection.size
|
|
182
182
|
start_time = Familia.now
|
|
183
183
|
|
|
@@ -222,8 +222,8 @@ module Familia
|
|
|
222
222
|
processed = 0
|
|
223
223
|
indexed_count = 0
|
|
224
224
|
|
|
225
|
-
# Process in batches - use
|
|
226
|
-
collection.
|
|
225
|
+
# Process in batches - use members to get deserialized identifiers
|
|
226
|
+
collection.members.each_slice(batch_size) do |identifiers|
|
|
227
227
|
objects = indexed_class.load_multi(identifiers).compact
|
|
228
228
|
|
|
229
229
|
# Transaction per batch
|
|
@@ -233,9 +233,9 @@ module Familia
|
|
|
233
233
|
value = obj.send(field)
|
|
234
234
|
next unless value && !value.to_s.strip.empty?
|
|
235
235
|
|
|
236
|
-
# For unique index: HSET temp_key field_value
|
|
236
|
+
# For unique index: HSET temp_key field_value serialized_identifier
|
|
237
237
|
# For multi-index: SADD temp_key:field_value identifier
|
|
238
|
-
tx.hset(temp_key, value.to_s, obj
|
|
238
|
+
tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
|
|
239
239
|
batch_indexed += 1
|
|
240
240
|
end
|
|
241
241
|
end
|
|
@@ -295,7 +295,7 @@ module Familia
|
|
|
295
295
|
# batch_size: 100
|
|
296
296
|
# )
|
|
297
297
|
#
|
|
298
|
-
def rebuild_via_scan(indexed_class, field, add_method, scope_instance: nil, batch_size: 100, &progress)
|
|
298
|
+
def rebuild_via_scan(indexed_class, field, add_method, index_hashkey, scope_instance: nil, batch_size: 100, &progress)
|
|
299
299
|
start_time = Familia.now
|
|
300
300
|
|
|
301
301
|
# Build key pattern for SCAN
|
|
@@ -342,7 +342,7 @@ module Familia
|
|
|
342
342
|
|
|
343
343
|
# Process in batches
|
|
344
344
|
if batch.size >= batch_size
|
|
345
|
-
batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
|
|
345
|
+
batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
|
|
346
346
|
processed += batch.size
|
|
347
347
|
indexed_count += batch_indexed
|
|
348
348
|
|
|
@@ -362,7 +362,7 @@ module Familia
|
|
|
362
362
|
|
|
363
363
|
# Process remaining batch
|
|
364
364
|
unless batch.empty?
|
|
365
|
-
batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, scope_instance)
|
|
365
|
+
batch_indexed = RebuildStrategies.process_scan_batch(batch, indexed_class, field, temp_key, index_hashkey, scope_instance)
|
|
366
366
|
processed += batch.size
|
|
367
367
|
indexed_count += batch_indexed
|
|
368
368
|
end
|
|
@@ -385,7 +385,7 @@ module Familia
|
|
|
385
385
|
# @param scope_instance [Object, nil] Optional scope instance. If provided, only objects belonging to this scope will be indexed.
|
|
386
386
|
# @return [Integer] Number of objects indexed in this batch
|
|
387
387
|
#
|
|
388
|
-
def process_scan_batch(keys, indexed_class, field, temp_key, scope_instance)
|
|
388
|
+
def process_scan_batch(keys, indexed_class, field, temp_key, index_hashkey, scope_instance)
|
|
389
389
|
# Load objects by keys
|
|
390
390
|
objects = indexed_class.load_multi_by_keys(keys).compact
|
|
391
391
|
|
|
@@ -411,7 +411,7 @@ module Familia
|
|
|
411
411
|
value = obj.send(field)
|
|
412
412
|
next unless value && !value.to_s.strip.empty?
|
|
413
413
|
|
|
414
|
-
tx.hset(temp_key, value.to_s, obj
|
|
414
|
+
tx.hset(temp_key, value.to_s, index_hashkey.serialize_value(obj))
|
|
415
415
|
batch_indexed += 1
|
|
416
416
|
end
|
|
417
417
|
end
|
|
@@ -191,6 +191,7 @@ module Familia
|
|
|
191
191
|
index_config = indexed_class.indexing_relationships.find { |rel| rel.index_name == index_name }
|
|
192
192
|
|
|
193
193
|
# Strategy 2: Use participation-based rebuild
|
|
194
|
+
index_hashkey = send(index_name) # Get the index HashKey for serialization
|
|
194
195
|
Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_participation(
|
|
195
196
|
self, # scope_instance (e.g., company)
|
|
196
197
|
indexed_class, # e.g., Employee
|
|
@@ -198,15 +199,18 @@ module Familia
|
|
|
198
199
|
:"add_to_#{scope_class_config}_#{index_name}", # e.g., :add_to_company_badge_index
|
|
199
200
|
collection,
|
|
200
201
|
index_config.cardinality, # :unique or :multi
|
|
202
|
+
index_hashkey, # Pass index for serialization
|
|
201
203
|
batch_size: batch_size,
|
|
202
204
|
&progress_block
|
|
203
205
|
)
|
|
204
206
|
else
|
|
205
207
|
# Strategy 3: Fall back to SCAN with filtering
|
|
208
|
+
index_hashkey = send(index_name) # Get the index HashKey for serialization
|
|
206
209
|
Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
|
|
207
210
|
indexed_class,
|
|
208
211
|
field,
|
|
209
212
|
:"add_to_#{scope_class_config}_#{index_name}",
|
|
213
|
+
index_hashkey, # Pass index for serialization
|
|
210
214
|
scope_instance: self,
|
|
211
215
|
batch_size: batch_size,
|
|
212
216
|
&progress_block
|
|
@@ -373,19 +377,23 @@ module Familia
|
|
|
373
377
|
indexed_class.define_singleton_method(:"rebuild_#{index_name}") do |batch_size: 100, &progress_block|
|
|
374
378
|
if respond_to?(:instances)
|
|
375
379
|
# Strategy 1: Use instances collection (fastest)
|
|
380
|
+
index_hashkey = send(index_name) # Get the index HashKey for serialization
|
|
376
381
|
Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_instances(
|
|
377
382
|
self, # indexed_class (e.g., User)
|
|
378
383
|
field, # e.g., :email
|
|
379
384
|
:"add_to_class_#{index_name}", # e.g., :add_to_class_email_lookup
|
|
385
|
+
index_hashkey, # Pass index for serialization
|
|
380
386
|
batch_size: batch_size,
|
|
381
387
|
&progress_block
|
|
382
388
|
)
|
|
383
389
|
else
|
|
384
390
|
# Strategy 3: Fall back to SCAN
|
|
391
|
+
index_hashkey = send(index_name) # Get the index HashKey for serialization
|
|
385
392
|
Familia::Features::Relationships::Indexing::RebuildStrategies.rebuild_via_scan(
|
|
386
393
|
self,
|
|
387
394
|
field,
|
|
388
395
|
:"add_to_class_#{index_name}",
|
|
396
|
+
index_hashkey, # Pass index for serialization
|
|
389
397
|
batch_size: batch_size,
|
|
390
398
|
&progress_block
|
|
391
399
|
)
|
|
@@ -263,15 +263,20 @@ module Familia
|
|
|
263
263
|
#
|
|
264
264
|
# | Scenario | Use | Why |
|
|
265
265
|
# |----------|-----|-----|
|
|
266
|
-
# |
|
|
266
|
+
# | First-one-wins / idempotency | SET NX | Atomic claim, no read needed |
|
|
267
|
+
# | Distributed lock acquisition | SET NX EX | Claim with automatic expiry |
|
|
267
268
|
# | Read value, update conditionally | WATCH | Decision depends on current state |
|
|
268
269
|
# | Compare-and-swap operations | WATCH | Need optimistic locking |
|
|
269
270
|
# | Version-based updates | WATCH | Must detect concurrent changes |
|
|
271
|
+
# | Status transitions (pending→processing) | WATCH | Must verify current state |
|
|
270
272
|
# | Batch field updates | MULTI only | No conditional logic |
|
|
271
273
|
# | Increment + timestamp together | MULTI only | Concurrent increments OK |
|
|
272
274
|
# | Save object atomically | MULTI only | Just need atomicity |
|
|
273
275
|
# | Update indexes with save | MULTI only | No state checking needed |
|
|
274
276
|
#
|
|
277
|
+
# If you don't need to read before deciding, WATCH adds complexity
|
|
278
|
+
# without benefit. SET NX handles the "claim" pattern in one atomic shot.
|
|
279
|
+
#
|
|
275
280
|
# @param suffix_override [String, nil] Optional suffix override
|
|
276
281
|
# @return [String] 'OK' on success
|
|
277
282
|
def watch(...)
|