familia 1.0.0.pre.rc1 → 1.0.0.pre.rc2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcefc7fe4a1a64cc584d629612f752ecb1eca753ab39245217f017a03b71075e
4
- data.tar.gz: 8a2d0313578fe9acf743537efaee95e8167eabab98474a2a821222696641b567
3
+ metadata.gz: b4f3c02cad65603669b086ec26f31512004e939da510fec97fcc3803f132e5f5
4
+ data.tar.gz: cdd710ea75ff3c6ce3ccb88caef97226ee9fb549435fce605433621a8583f06d
5
5
  SHA512:
6
- metadata.gz: 3f5e8755442f3766515caf445e78888fa26a1b66910bd04228d2fee24988ffa1b380ea5aeedde02a5c545e74d58fee5e273801e7c3f5cc18e873093f383da42a
7
- data.tar.gz: 39d8c525f39c123982a150dc62e1cfd302b602086068ea7e694061bc889ab040e1765af5177e23fb9db5b408e2f4ec6f0b5d4ddf165d6ead6dce929a927a61d1
6
+ metadata.gz: '058e49695798298238523d571a4d21b47234874b47af018fdb085d22a430fa8db2ca725cb3103e0c8eee309b000eea7598cc4e9aed00373191d393af8d4ab27d'
7
+ data.tar.gz: 9ff11c6c341d540e69ddb9d0518b67c9463b2248899b6f74a94a92b609140ed064e303e3559a455847727d5f7581aa41fccce5fdaac5effb5d74a30202e75721
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- familia (1.0.0.pre.rc1)
4
+ familia (1.0.0.pre.rc2)
5
5
  redis (>= 4.8.1, < 6.0)
6
6
  uri-redis (~> 1.3)
7
7
 
data/README.md CHANGED
@@ -1,31 +1,84 @@
1
- # Familia - 1.0.0-rc1
1
+ # Familia - 1.0.0-rc2 (August 2024)
2
2
 
3
3
  **Organize and store ruby objects in Redis. A Ruby ORM for Redis.**
4
4
 
5
+ Familia provides a powerful and flexible way to interact with Redis using Ruby objects. It's designed to make working with Redis as natural as working with Ruby classes.
6
+
5
7
  ## Installation
6
8
 
7
9
  Get it in one of the following ways:
8
10
 
9
- * In your Gemfile: `gem 'familia', '>= 1.0.0-rc1'`
11
+ * In your Gemfile: `gem 'familia', '>= 1.0.0-rc2'`
10
12
  * Install it by hand: `gem install familia`
11
13
  * Or for development: `git clone git@github.com:delano/familia.git`
12
14
 
13
15
  ## Basic Example
14
16
 
15
17
  ```ruby
16
- class Flower < Familia::Horreum
17
- identifier :generate_id
18
- field :token
19
- field :name
20
- list :owners
21
- set :tags
22
- zset :metrics
23
- hashkey :props
24
- string :counter
25
- end
18
+ class Flower < Familia::Horreum
19
+ identifier :generate_id
20
+ field :token
21
+ field :name
22
+ list :owners
23
+ set :tags
24
+ zset :metrics
25
+ hashkey :props
26
+ string :counter
27
+ end
28
+ ```
29
+
30
+ ## What Familia::Horreum Can Do
31
+
32
+ Familia::Horreum provides a powerful abstraction layer over Redis, allowing you to:
33
+
34
+ 1. **Define Redis-backed Ruby Classes**: As shown in the example, you can easily define classes that map to Redis structures.
35
+
36
+ 2. **Use Various Redis Data Types**: Familia supports multiple Redis data types:
37
+ - `field`: For simple key-value pairs
38
+ - `list`: For Redis lists
39
+ - `set`: For Redis sets
40
+ - `zset`: For Redis sorted sets
41
+ - `hashkey`: For Redis hashes
42
+ - `string`: For Redis strings
43
+
44
+ 3. **Custom Identifiers**: Use the `identifier` method to specify how objects are uniquely identified in Redis.
45
+
46
+ 4. **Automatic Serialization**: Familia handles the serialization and deserialization of your objects to and from Redis.
47
+
48
+ 5. **Redis Commands as Ruby Methods**: Interact with Redis using familiar Ruby syntax instead of raw Redis commands.
49
+
50
+ 6. **TTL Support**: Set expiration times for your objects in Redis.
51
+
52
+ 7. **Flexible Configuration**: Configure Redis connection details, serialization methods, and more.
53
+
54
+ ## Advanced Features
55
+
56
+ - **API Versioning**: Familia supports API versioning to help manage changes in your data model over time.
57
+ - **Custom Serialization**: You can specify custom serialization methods for your objects.
58
+ - **Redis URI Support**: Easily connect to Redis using URI strings.
59
+ - **Debugging Tools**: Built-in debugging capabilities to help troubleshoot Redis interactions.
60
+
61
+ ## Usage Example
62
+
63
+ ```ruby
64
+ # Create a new Flower
65
+ rose = Flower.new
66
+ rose.name = "Red Rose"
67
+ rose.tags << "romantic" << "red"
68
+ rose.owners.push("Alice", "Bob")
69
+ rose.save
70
+
71
+ # Retrieve a Flower
72
+ retrieved_rose = Flower.get(rose.identifier)
73
+ puts retrieved_rose.name # => "Red Rose"
74
+ puts retrieved_rose.tags.members # => ["romantic", "red"]
26
75
  ```
27
76
 
28
77
  ## More Information
29
78
 
30
79
  * [Github](https://github.com/delano/familia)
31
80
  * [Rubygems](https://rubygems.org/gems/familia)
81
+
82
+ ## Contributing
83
+
84
+ Contributions are welcome! Please feel free to submit a Pull Request.
data/VERSION.yml CHANGED
@@ -2,4 +2,4 @@
2
2
  :MAJOR: 1
3
3
  :MINOR: 0
4
4
  :PATCH: 0
5
- :PRE: rc1
5
+ :PRE: rc2
@@ -12,13 +12,15 @@ module Familia::Features
12
12
  # a symbol, the method with the same name will be called on the object to
13
13
  # retrieve the value. If the field is a hash, the key is the field name and
14
14
  # the value is a lambda that will be called with the object as an argument.
15
- # the hash syntax allows you to:
15
+ # The hash syntax allows you to:
16
16
  # * define a field name that is different from the method name
17
17
  # * define a field that requires some computation on-the-fly
18
18
  # * define a field that is not a method on the object
19
19
  #
20
20
  # Example:
21
21
  #
22
+ # feature :safe_dump
23
+ #
22
24
  # @safe_dump_fields = [
23
25
  # :objid,
24
26
  # :updated,
@@ -26,11 +28,26 @@ module Familia::Features
26
28
  # { :active => ->(obj) { obj.active? } }
27
29
  # ]
28
30
  #
29
- # Internally, all fields are normalized to the hash syntax and store in
31
+ # Internally, all fields are normalized to the hash syntax and stored in
30
32
  # @safe_dump_field_map. `SafeDump.safe_dump_fields` returns only the list
31
33
  # of symbols in the order they were defined. From the example above, it would
32
34
  # return `[:objid, :updated, :created, :active]`.
33
35
  #
36
+ # Standalone Usage:
37
+ #
38
+ # You can also use SafeDump by including it in your model and defining the
39
+ # safe dump fields using the class instance variable `@safe_dump_fields`.
40
+ #
41
+ # Example:
42
+ #
43
+ # class MyModel
44
+ # include Familia::Features::SafeDump
45
+ #
46
+ # @safe_dump_fields = [
47
+ # :id, :name, { active: ->(obj) { obj.active? } }
48
+ # ]
49
+ # end
50
+ #
34
51
  module SafeDump
35
52
  @dump_method = :to_json
36
53
  @load_method = :from_json
@@ -148,6 +165,8 @@ end
148
165
 
149
166
  __END__
150
167
 
168
+ # Some leftovers related to dump_method and load_method
169
+
151
170
  if value_to_distunguish.is_a?(Familia::Horreum)
152
171
  Familia.trace :DISTINGUISHER, redis, "horreum", caller(1..1) if Familia.debug?
153
172
  value_to_distunguish.identifier
@@ -8,15 +8,14 @@ module Familia
8
8
  # These are set up as nil initially and populated later
9
9
  @redis = nil
10
10
  @identifier = nil
11
- @fields = nil # []
12
11
  @ttl = nil
13
12
  @db = nil
14
13
  @uri = nil
15
14
  @suffix = nil
16
15
  @prefix = nil
16
+ @fields = nil # []
17
17
  @class_redis_types = nil # {}
18
18
  @redis_types = nil # {}
19
- @defined_fields = nil # {}
20
19
  @dump_method = nil
21
20
  @load_method = nil
22
21
 
@@ -54,6 +53,19 @@ module Familia
54
53
  def field(name)
55
54
  fields << name
56
55
  attr_accessor name
56
+
57
+ # Every field gets a fast writer method for immediately persisting
58
+ fast_writer! name
59
+ end
60
+
61
+ # @return The return value from redis client for hset command
62
+ def fast_writer!(name)
63
+ define_method :"#{name}!" do |value|
64
+ prepared = to_redis(value)
65
+ Familia.ld "[.fast_writer!] #{name} val: #{value.class} prepared: #{prepared.class}"
66
+ send :"#{name}=", value # use the existing accessor
67
+ hset name, prepared # persist to Redis without delay
68
+ end
57
69
  end
58
70
 
59
71
  # Returns the list of field names defined for the class in the order
@@ -81,11 +93,6 @@ module Familia
81
93
  @redis_types
82
94
  end
83
95
 
84
- def defined_fields
85
- @defined_fields ||= {}
86
- @defined_fields
87
- end
88
-
89
96
  def ttl(v = nil)
90
97
  @ttl = v unless v.nil?
91
98
  @ttl || parent&.ttl
@@ -145,6 +152,34 @@ module Familia
145
152
  redis.mget(*ids)
146
153
  end
147
154
 
155
+ # Retrieves and instantiates an object from Redis using the full object
156
+ # key.
157
+ #
158
+ # @param objkey [String] The full Redis key for the object.
159
+ # @return [Object, nil] An instance of the class if the key exists, nil
160
+ # otherwise.
161
+ # @raise [ArgumentError] If the provided key is empty.
162
+ #
163
+ # This method performs a two-step process to safely retrieve and
164
+ # instantiate objects:
165
+ #
166
+ # 1. It first checks if the key exists in Redis. This is crucial because:
167
+ # - It provides a definitive answer about the object's existence.
168
+ # - It prevents ambiguity that could arise from `hgetall` returning an
169
+ # empty hash for non-existent keys, which could lead to the creation
170
+ # of "empty" objects.
171
+ #
172
+ # 2. If the key exists, it retrieves the object's data and instantiates
173
+ # it.
174
+ #
175
+ # This approach ensures that we only attempt to instantiate objects that
176
+ # actually exist in Redis, improving reliability and simplifying
177
+ # debugging.
178
+ #
179
+ # @example
180
+ # User.from_key("user:123") # Returns a User instance if it exists,
181
+ # nil otherwise
182
+ #
148
183
  def from_key(objkey)
149
184
  raise ArgumentError, 'Empty key' if objkey.to_s.empty?
150
185
 
@@ -153,27 +188,46 @@ module Familia
153
188
  does_exist = redis.exists(objkey).positive?
154
189
 
155
190
  Familia.ld "[.from_key] #{self} from key #{objkey} (exists: #{does_exist})"
156
- Familia.trace :LOAD, redis, objkey, caller if Familia.debug?
191
+ Familia.trace :FROM_KEY, redis, objkey, caller if Familia.debug?
157
192
 
158
- # This is reason for calling exists first. We want to definitively and without any
159
- # ambiguity know if the object exists in Redis. If it doesn't, we return nil. If
160
- # it does, we proceed to load the object. Otherwise, hgetall will return an empty
161
- # hash, which will be passed to the constructor, which will then be annoying to
162
- # debug.
193
+ # This is the reason for calling exists first. We want to definitively
194
+ # and without any ambiguity know if the object exists in Redis. If it
195
+ # doesn't, we return nil. If it does, we proceed to load the object.
196
+ # Otherwise, hgetall will return an empty hash, which will be passed to
197
+ # the constructor, which will then be annoying to debug.
163
198
  return unless does_exist
164
199
 
165
200
  obj = redis.hgetall(objkey) # horreum objects are persisted as redis hashes
166
- Familia.trace :HGETALL, redis, "#{objkey}: #{obj.inspect}", caller if Familia.debug?
201
+ Familia.trace :FROM_KEY2, redis, "#{objkey}: #{obj.inspect}", caller if Familia.debug?
167
202
 
168
203
  new(**obj)
169
204
  end
170
205
 
206
+ # Retrieves and instantiates an object from Redis using its identifier.
207
+ #
208
+ # @param identifier [String, Integer] The unique identifier for the
209
+ # object.
210
+ # @param suffix [Symbol] The suffix to use in the Redis key (default:
211
+ # :object).
212
+ # @return [Object, nil] An instance of the class if found, nil otherwise.
213
+ #
214
+ # This method constructs the full Redis key using the provided identifier
215
+ # and suffix, then delegates to `from_key` for the actual retrieval and
216
+ # instantiation.
217
+ #
218
+ # It's a higher-level method that abstracts away the key construction,
219
+ # making it easier to retrieve objects when you only have their
220
+ # identifier.
221
+ #
222
+ # @example
223
+ # User.from_redis(123) # Equivalent to User.from_key("user:123:object")
224
+ #
171
225
  def from_redis(identifier, suffix = :object)
172
226
  return nil if identifier.to_s.empty?
173
227
 
174
228
  objkey = rediskey(identifier, suffix)
175
229
  Familia.ld "[.from_redis] #{self} from key #{objkey})"
176
- Familia.trace :FROMREDIS, Familia.redis(uri), objkey, caller(1..1).first if Familia.debug?
230
+ Familia.trace :FROM_REDIS, Familia.redis(uri), objkey, caller(1..1).first if Familia.debug?
177
231
  from_key objkey
178
232
  end
179
233
 
@@ -183,10 +237,7 @@ module Familia
183
237
  objkey = rediskey identifier, suffix
184
238
 
185
239
  ret = redis.exists objkey
186
- if Familia.debug?
187
- Familia.trace :EXISTS, redis, "#{objkey} #{ret.inspect}",
188
- caller
189
- end
240
+ Familia.trace :EXISTS, redis, "#{objkey} #{ret.inspect}", caller if Familia.debug?
190
241
  ret.positive?
191
242
  end
192
243
 
@@ -236,5 +287,6 @@ module Familia
236
287
  @load_method || :from_json # Familia.load_method
237
288
  end
238
289
  end
290
+ # End of ClassMethods module
239
291
  end
240
292
  end
@@ -36,13 +36,60 @@ module Familia
36
36
  redis.hdel rediskey, field
37
37
  end
38
38
 
39
- def redistype(suffix = nil)
39
+ def redistype
40
40
  redis.type rediskey(suffix)
41
41
  end
42
42
 
43
- def hmset(suffix = nil)
44
- suffix ||= self.class.suffix
45
- redis.hmset rediskey(suffix), to_h
43
+ # Parity with RedisType#rename
44
+ def rename(newkey)
45
+ redis.rename rediskey, newkey
46
+ end
47
+
48
+ # For parity with RedisType#hgetall
49
+ def hgetall
50
+ Familia.trace :HGETALL, redis, redisuri, caller(1..1) if Familia.debug?
51
+ redis.hgetall rediskey(suffix)
52
+ end
53
+ alias all hgetall
54
+
55
+ def hget(field)
56
+ redis.hget rediskey(suffix), field
57
+ end
58
+
59
+ # @return The number of fields that were added to the hash. If the
60
+ # field already exists, this will return 0.
61
+ def hset(field, value)
62
+ Familia.trace :HSET, redis, redisuri, caller(1..1) if Familia.debug?
63
+ redis.hset rediskey, field, value
64
+ end
65
+
66
+ def hmset
67
+ redis.hmset rediskey(suffix), self.to_h
68
+ end
69
+
70
+ def hkeys
71
+ Familia.trace :HKEYS, redis, 'redisuri', caller(1..1) if Familia.debug?
72
+ redis.hkeys rediskey(suffix)
73
+ end
74
+
75
+ def hvals
76
+ redis.hvals rediskey(suffix)
77
+ end
78
+
79
+ def hincrby(field, increment)
80
+ redis.hincrby rediskey(suffix), field, increment
81
+ end
82
+
83
+ def hincrbyfloat(field, increment)
84
+ redis.hincrbyfloat rediskey(suffix), field, increment
85
+ end
86
+
87
+ def hlen
88
+ redis.hlen rediskey(suffix)
89
+ end
90
+
91
+ def hstrlen(field)
92
+ redis.hstrlen rediskey(suffix), field
46
93
  end
47
94
 
48
95
  def delete!
@@ -10,6 +10,12 @@ module Familia
10
10
 
11
11
  # Methods that call load and dump (InstanceMethods)
12
12
  #
13
+ # Note on refresh methods:
14
+ # In this class, refresh! is the primary method that performs the Redis
15
+ # query and state update. The non-bang refresh method is provided as a
16
+ # convenience for method chaining, but still performs the same destructive
17
+ # update as refresh!. This deviates from common Ruby conventions to better
18
+ # fit the specific needs of this system.
13
19
  module Serialization
14
20
  #include Familia::RedisType::Serialization
15
21
 
@@ -38,8 +44,9 @@ module Familia
38
44
  Familia.trace :SAVE, redis, redisuri, caller(1..1) if Familia.debug?
39
45
 
40
46
  # Update timestamp fields
47
+ self.key ||= self.identifier
41
48
  self.updated = Familia.now.to_i
42
- self.created = Familia.now.to_i unless self.created
49
+ self.created ||= Familia.now.to_i
43
50
 
44
51
  # Thr return value of commit_fields is an array of strings: ["OK"].
45
52
  ret = commit_fields # e.g. ["OK"]
@@ -64,12 +71,39 @@ module Familia
64
71
  delete!
65
72
  end
66
73
 
74
+ # Refreshes the object's state by querying Redis and overwriting the
75
+ # current field values. This method performs a destructive update on the
76
+ # object, regardless of unsaved changes.
77
+ #
78
+ # @note This is a destructive operation that will overwrite any unsaved
79
+ # changes.
80
+ # @return The list of field names that were updated.
81
+ def refresh!
82
+ Familia.trace :REFRESH, redis, redisuri, caller(1..1) if Familia.debug?
83
+ fields = hgetall
84
+ Familia.ld "[refresh!] #{self.class} #{rediskey} #{fields.keys}"
85
+ optimistic_refresh(**fields)
86
+ end
87
+
88
+ # Refreshes the object's state and returns self to allow method chaining.
89
+ # This method calls refresh! internally, performing the actual Redis
90
+ # query and state update.
91
+ #
92
+ # @note While this method allows chaining, it still performs a
93
+ # destructive update like refresh!.
94
+ # @return [self] Returns the object itself after refreshing, allowing
95
+ # method chaining.
96
+ def refresh
97
+ refresh!
98
+ self
99
+ end
100
+
67
101
  def to_h
68
102
  # Use self.class.fields to efficiently generate a hash
69
103
  # of all the fields for this object
70
104
  self.class.fields.inject({}) do |hsh, field|
71
105
  val = send(field)
72
- prepared = val.to_s
106
+ prepared = to_redis(val)
73
107
  Familia.ld " [to_h] field: #{field} val: #{val.class} prepared: #{prepared.class}"
74
108
  hsh[field] = prepared
75
109
  hsh
@@ -79,13 +113,14 @@ module Familia
79
113
  def to_a
80
114
  self.class.fields.map do |field|
81
115
  val = send(field)
82
- Familia.ld " [to_a] field: #{field} val: #{val}"
83
- to_redis(val)
116
+ prepared = to_redis(val)
117
+ Familia.ld " [to_a] field: #{field} val: #{val.class} prepared: #{prepared.class}"
118
+ prepared
84
119
  end
85
120
  end
86
121
 
87
- # The to_redis method in Familia::Redistype and Familia::Horreum serve similar purposes
88
- # but have some key differences in their implementation:
122
+ # The to_redis method in Familia::Redistype and Familia::Horreum serve
123
+ # similar purposes but have some key differences in their implementation:
89
124
  #
90
125
  # Similarities:
91
126
  # - Both methods aim to serialize various data types for Redis storage
@@ -97,16 +132,19 @@ module Familia
97
132
  # - Familia::Horreum had more explicit type checking and conversion
98
133
  # - Familia::Redistype includes more extensive debug tracing
99
134
  #
100
- # The centralized Familia.distinguisher method accommodates both approaches by:
101
- # 1. Handling a wide range of data types, including those from both implementations
135
+ # The centralized Familia.distinguisher method accommodates both approaches
136
+ # by:
137
+ # 1. Handling a wide range of data types, including those from both
138
+ # implementations
102
139
  # 2. Providing a 'strict_values' option for flexible type handling
103
140
  # 3. Supporting custom serialization through a dump_method
104
141
  # 4. Including debug tracing similar to Familia::Redistype
105
142
  #
106
- # By using Familia.distinguisher, we achieve more consistent behavior across
107
- # different parts of the library while maintaining the flexibility to handle
108
- # various data types and custom serialization needs. This centralization
109
- # also makes it easier to extend or modify serialization behavior in the future.
143
+ # By using Familia.distinguisher, we achieve more consistent behavior
144
+ # across different parts of the library while maintaining the flexibility
145
+ # to handle various data types and custom serialization needs. This
146
+ # centralization also makes it easier to extend or modify serialization
147
+ # behavior in the future.
110
148
  #
111
149
  def to_redis(val)
112
150
  prepared = Familia.distinguisher(val, false)
@@ -115,7 +153,10 @@ module Familia
115
153
  prepared = val.send(dump_method)
116
154
  end
117
155
 
118
- Familia.ld "[#{self.class}#to_redis] nil returned for #{self.class}##{name}" if prepared.nil?
156
+ if prepared.nil?
157
+ Familia.ld "[#{self.class}#to_redis] nil returned for #{self.class}##{name}"
158
+ end
159
+
119
160
  prepared
120
161
  end
121
162
 
@@ -127,6 +168,7 @@ module Familia
127
168
  expire ttl.to_i
128
169
  end
129
170
  end
171
+ # End of Serialization module
130
172
 
131
173
  include Serialization # these become Horreum instance methods
132
174
  end
@@ -134,21 +176,18 @@ end
134
176
 
135
177
  __END__
136
178
 
137
-
138
-
139
- # From RedisHash
140
- def save
141
- hsh = { :key => identifier }
142
- ret = commit_fields hsh
143
- ret == "OK"
144
- end
145
-
146
- def update_fields hsh={}
147
- check_identifier!
148
- hsh[:updated] = OT.now.to_i
149
- hsh[:created] = OT.now.to_i unless has_key?(:created)
150
- ret = update hsh # update is defined in HashKey
151
- ## NOTE: caching here like this only works if hsh has all keys
152
- #self.cache.replace hsh
153
- ret
179
+ # Consider adding a retry mechanism for the refresh operation
180
+ # if it fails to fetch the expected data:
181
+ def refresh_with_retry(max_attempts = 3)
182
+ attempts = 0
183
+ base = 2
184
+ while attempts < max_attempts
185
+ refresh!
186
+ return if name == "Jane Doe" # Or whatever condition indicates a successful refresh
187
+ attempts += 1
188
+
189
+ sleep_time = 0.1 * (base ** attempts)
190
+ sleep(sleep_time) # Exponential backoff
191
+ end
192
+ raise "Failed to refresh after #{max_attempts} attempts"
154
193
  end
@@ -27,9 +27,10 @@ module Familia
27
27
  # Whether this is a Horreum or RedisType object, the value is taken
28
28
  # from the `identifier` method).
29
29
  #
30
- def rediskey(suffix = self.suffix, ignored = nil)
30
+ def rediskey(suffix = nil, ignored = nil)
31
31
  Familia.ld "[#rediskey] #{identifier} for #{self.class}"
32
32
  raise Familia::NoIdentifier, "No identifier for #{self.class}" if identifier.to_s.empty?
33
+ suffix ||= self.suffix # use the instance method to get the default suffix
33
34
  self.class.rediskey identifier, suffix
34
35
  end
35
36
 
@@ -94,6 +94,15 @@ module Familia
94
94
  # end
95
95
  end
96
96
 
97
+ # Automatically add a 'key' field if it's not already defined
98
+ # This ensures that every object has a unique identifier
99
+ unless self.class.fields.include?(:key)
100
+ # Define the 'key' field for this class
101
+ # This approach allows flexibility in how identifiers are generated
102
+ # while ensuring each object has a consistent way to be referenced
103
+ self.class.field :key # , default: -> { identifier }
104
+ end
105
+
97
106
  # Implementing classes can define an init method to do any
98
107
  # additional initialization. Notice that this is called
99
108
  # after the fields are set.
@@ -145,23 +154,64 @@ module Familia
145
154
  end
146
155
  end
147
156
 
157
+ # Initializes the object with positional arguments.
158
+ # Maps each argument to a corresponding field in the order they are defined.
159
+ #
160
+ # @param args [Array] List of values to be assigned to fields
161
+ # @return [Array<Symbol>] List of field names that were successfully updated
162
+ # (i.e., had non-nil values assigned)
163
+ # @private
148
164
  def initialize_with_positional_args(*args)
149
- self.class.fields.zip(args).each do |field, value|
150
- send(:"#{field}=", value) if value
165
+ Familia.trace :INITIALIZE_ARGS, redis, args, caller(1..1) if Familia.debug?
166
+ self.class.fields.zip(args).filter_map do |field, value|
167
+ if value
168
+ send(:"#{field}=", value)
169
+ field.to_sym
170
+ end
151
171
  end
152
172
  end
153
173
  private :initialize_with_positional_args
154
174
 
155
- def initialize_with_keyword_args(**kwargs)
156
- self.class.fields.each do |field|
175
+ # Initializes the object with keyword arguments.
176
+ # Assigns values to fields based on the provided hash of field names and values.
177
+ # Handles both symbol and string keys to accommodate different sources of data.
178
+ #
179
+ # @param fields [Hash] Hash of field names (as symbols or strings) and their values
180
+ # @return [Array<Symbol>] List of field names that were successfully updated
181
+ # (i.e., had non-nil values assigned)
182
+ # @private
183
+ def initialize_with_keyword_args(**fields)
184
+ Familia.trace :INITIALIZE_KWARGS, redis, fields.keys, caller(1..1) if Familia.debug?
185
+ self.class.fields.filter_map do |field|
157
186
  # Redis will give us field names as strings back, but internally
158
- # we use symbols. So we do both.
159
- value = kwargs[field.to_sym] || kwargs[field.to_s]
160
- send(:"#{field}=", value) if value
187
+ # we use symbols. So we check for both.
188
+ value = fields[field.to_sym] || fields[field.to_s]
189
+ if value
190
+ send(:"#{field}=", value)
191
+ field.to_sym
192
+ end
161
193
  end
162
194
  end
163
195
  private :initialize_with_keyword_args
164
196
 
197
+ # A thin wrapper around the private initialize method that accepts a field
198
+ # hash and refreshes the existing object.
199
+ #
200
+ # This method is part of horreum.rb rather than serialization.rb because it
201
+ # operates solely on the provided values and doesn't query Redis or other
202
+ # external sources. That's why it's called "optimistic" refresh: it assumes
203
+ # the provided values are correct and updates the object accordingly.
204
+ #
205
+ # @see #refresh!
206
+ #
207
+ # @param fields [Hash] A hash of field names and their new values to update
208
+ # the object with.
209
+ # @return [Array] The list of field names that were updated.
210
+ def optimistic_refresh(**fields)
211
+ Familia.ld "[optimistic_refresh] #{self.class} #{rediskey} #{fields.keys}"
212
+ initialize_with_keyword_args(**fields)
213
+ end
214
+
165
215
  # Determines the unique identifier for the instance
166
216
  # This method is used to generate Redis keys for the object
167
217
  def identifier
@@ -183,7 +233,7 @@ module Familia
183
233
  end
184
234
 
185
235
  # If the unique_id is nil, raise an error
186
- raise Problem, "Identifier is nil for #{self}" if unique_id.nil?
236
+ raise Problem, "Identifier is nil for #{self.class}" if unique_id.nil?
187
237
  raise Problem, 'Identifier is empty' if unique_id.empty?
188
238
 
189
239
  unique_id
@@ -3,23 +3,6 @@
3
3
  require 'pathname'
4
4
  require 'logger'
5
5
 
6
- module LoggerTraceRefinement
7
- # Set to same value as Logger::DEBUG since 0 is the floor
8
- # without either more invasive changes to the Logger class
9
- # or a CustomLogger class that inherits from Logger.
10
- TRACE = 2 unless defined?(TRACE)
11
- refine Logger do
12
-
13
- def trace(progname = nil, &block)
14
- Thread.current[:severity_letter] = 'T'
15
- add(LoggerTraceRefinement::TRACE, nil, progname, &block)
16
- ensure
17
- Thread.current[:severity_letter] = nil
18
- end
19
-
20
- end
21
- end
22
-
23
6
  module Familia
24
7
  @logger = Logger.new($stdout)
25
8
  @logger.progname = name
@@ -38,7 +21,7 @@ module Familia
38
21
  # variable `severity_letter` is arbitrary and could be anything.
39
22
  severity_letter = Thread.current[:severity_letter] || severity_letter
40
23
 
41
- "#{severity_letter}, #{utc_datetime} #{pid} #{thread_id}: #{msg} <#{relative_path}:#{line}>\n"
24
+ "#{severity_letter}, #{utc_datetime} #{pid} #{thread_id}: #{msg} [#{relative_path}:#{line}]\n"
42
25
  end
43
26
 
44
27
  # The Logging module provides a set of methods and constants for logging messages
@@ -120,7 +103,7 @@ module Familia
120
103
  attr_reader :logger
121
104
 
122
105
  # Gives our logger the ability to use our trace method.
123
- #using LoggerTraceRefinement if Familia.debug
106
+ using LoggerTraceRefinement if LoggerTraceRefinement::ENABLED
124
107
 
125
108
  def info(*msg)
126
109
  @logger.info(*msg)
@@ -156,14 +139,14 @@ module Familia
156
139
  # @return [nil]
157
140
  #
158
141
  def trace(label, redis_instance, ident, context = nil)
159
- return unless Familia.debug? && ENV.key?('FAMILIA_TRACE')
142
+ return unless LoggerTraceRefinement::ENABLED
160
143
  instance_id = redis_instance&.id
161
144
  codeline = if context
162
145
  context = [context].flatten
163
146
  context.reject! { |line| line =~ %r{lib/familia} }
164
147
  context.first
165
148
  end
166
- @logger.debug format('[%s] %s -> %s <- at %s', label, instance_id, ident, codeline)
149
+ @logger.trace format('[%s] %s -> %s <- at %s', label, instance_id, ident, codeline)
167
150
  end
168
151
 
169
152
  end
@@ -61,10 +61,10 @@ module Familia
61
61
  end
62
62
  end
63
63
 
64
- attr_reader :name, :parent, :opts
64
+ attr_reader :keystring, :parent, :opts
65
65
  attr_writer :dump_method, :load_method
66
66
 
67
- # +name+: If parent is set, this will be used as the suffix
67
+ # +keystring+: If parent is set, this will be used as the suffix
68
68
  # for rediskey. Otherwise this becomes the value of the key.
69
69
  # If this is an Array, the elements will be joined.
70
70
  #
@@ -92,10 +92,10 @@ module Familia
92
92
  #
93
93
  # Uses the redis connection of the parent or the value of
94
94
  # opts[:redis] or Familia.redis (in that order).
95
- def initialize(name, opts = {})
95
+ def initialize(keystring, opts = {})
96
96
  #Familia.ld " [initializing] #{self.class} #{opts}"
97
- @name = name
98
- @name = @name.join(Familia.delim) if @name.is_a?(Array)
97
+ @keystring = keystring
98
+ @keystring = @keystring.join(Familia.delim) if @keystring.is_a?(Array)
99
99
 
100
100
  # Remove all keys from the opts that are not in the allowed list
101
101
  @opts = opts || {}
@@ -112,7 +112,7 @@ module Familia
112
112
 
113
113
  # Produces the full redis key for this object.
114
114
  def rediskey
115
- Familia.ld "[rediskey] #{name} for #{self.class} (#{opts})"
115
+ Familia.ld "[rediskey] #{keystring} for #{self.class} (#{opts})"
116
116
 
117
117
  # Return the hardcoded key if it's set. This is useful for
118
118
  # support legacy keys that aren't derived in the same way.
@@ -121,15 +121,15 @@ module Familia
121
121
  if parent_instance?
122
122
  # This is an instance-level redistype object so the parent instance's
123
123
  # rediskey method is defined in Familia::Horreum::InstanceMethods.
124
- parent.rediskey(name)
124
+ parent.rediskey(keystring)
125
125
  elsif parent_class?
126
126
  # This is a class-level redistype object so the parent class' rediskey
127
127
  # method is defined in Familia::Horreum::ClassMethods.
128
- parent.rediskey(name, nil)
128
+ parent.rediskey(keystring, nil)
129
129
  else
130
- # This is a standalone RedisType object where it's name
130
+ # This is a standalone RedisType object where it's keystring
131
131
  # is the full key.
132
- name
132
+ keystring
133
133
  end
134
134
  end
135
135
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'logger'
5
+
6
+ # Controls whether tracing is enabled via an environment variable
7
+ FAMILIA_TRACE = ENV.fetch('FAMILIA_TRACE', 'false').downcase
8
+
9
+ # FlexibleHashAccess
10
+ #
11
+ # This module provides a refinement for the Hash class to allow flexible access
12
+ # to hash keys using either strings or symbols interchangeably for reading values.
13
+ #
14
+ # Note: This refinement only affects reading from the hash. Writing to the hash
15
+ # maintains the original key type.
16
+ #
17
+ # @example Using the refinement
18
+ # using FlexibleHashAccess
19
+ #
20
+ # h = { name: "Alice", "age" => 30 }
21
+ # h[:name] # => "Alice"
22
+ # h["name"] # => "Alice"
23
+ # h[:age] # => 30
24
+ # h["age"] # => 30
25
+ #
26
+ # h["job"] = "Developer"
27
+ # h[:job] # => "Developer"
28
+ # h["job"] # => "Developer"
29
+ #
30
+ # h[:salary] = 75000
31
+ # h[:salary] # => 75000
32
+ # h["salary"] # => nil (original key type is preserved)
33
+ #
34
+ module FlexibleHashAccess
35
+ refine Hash do
36
+ ##
37
+ # Retrieves a value from the hash using either a string or symbol key.
38
+ #
39
+ # @param key [String, Symbol] The key to look up
40
+ # @return [Object, nil] The value associated with the key, or nil if not found
41
+ def [](key)
42
+ super(key.to_s) || super(key.to_sym)
43
+ end
44
+ end
45
+ end
46
+
47
+ # LoggerTraceRefinement
48
+ #
49
+ # This module adds a 'trace' log level to the Ruby Logger class.
50
+ # It is enabled when the FAMILIA_TRACE environment variable is set to
51
+ # '1', 'true', or 'yes' (case-insensitive).
52
+ #
53
+ # @example Enabling trace logging
54
+ # # Set environment variable
55
+ # ENV['FAMILIA_TRACE'] = 'true'
56
+ #
57
+ # # In your Ruby code
58
+ # require 'logger'
59
+ # using LoggerTraceRefinement
60
+ #
61
+ # logger = Logger.new(STDOUT)
62
+ # logger.trace("This is a trace message")
63
+ #
64
+ module LoggerTraceRefinement
65
+ # Indicates whether trace logging is enabled
66
+ ENABLED = %w[1 true yes].include?(FAMILIA_TRACE)
67
+
68
+ # The numeric level for trace logging (same as DEBUG)
69
+ TRACE = 0 unless defined?(TRACE)
70
+
71
+ refine Logger do
72
+ ##
73
+ # Logs a message at the TRACE level.
74
+ #
75
+ # @param progname [String] The program name to include in the log message
76
+ # @yield A block that evaluates to the message to log
77
+ # @return [true] Always returns true
78
+ #
79
+ # @example Logging a trace message
80
+ # logger.trace("MyApp") { "Detailed trace information" }
81
+ def trace(progname = nil, &block)
82
+ Thread.current[:severity_letter] = 'T'
83
+ add(LoggerTraceRefinement::TRACE, nil, progname, &block)
84
+ ensure
85
+ Thread.current[:severity_letter] = nil
86
+ end
87
+ end
88
+ end
@@ -53,13 +53,12 @@ module Familia
53
53
  multi_from_redis(*el)
54
54
  end
55
55
 
56
- def all
56
+ def hgetall
57
57
  # TODO: Use from_redis. Also name `all` is confusing with
58
58
  # Onetime::Customer.all which returns all customers.
59
59
  redis.hgetall rediskey
60
60
  end
61
- alias to_hash all
62
- alias clone all
61
+ alias all hgetall
63
62
 
64
63
  def has_key?(field)
65
64
  redis.hexists rediskey, field
data/lib/familia/utils.rb CHANGED
@@ -64,8 +64,8 @@ module Familia
64
64
  Time.at(rounded).utc.strftime(pattern)
65
65
  end
66
66
 
67
- def generate_sha_hash(elements)
68
- concatenated_string = Familia.join(elements)
67
+ def generate_sha_hash(*elements)
68
+ concatenated_string = Familia.join(*elements)
69
69
  DIGEST_CLASS.hexdigest(concatenated_string)
70
70
  end
71
71
 
@@ -113,10 +113,6 @@ module Familia
113
113
  Familia.trace :TOREDIS_DISTINGUISHER, redis, "isabase", caller(1..1) if Familia.debug?
114
114
  value_to_distinguish.identifier
115
115
 
116
- elsif dump_method && value_to_distinguish.respond_to?(dump_method)
117
- Familia.trace :TOREDIS_DISTINGUISHER, redis, "#{value_to_distinguish.class}##{dump_method}", caller(1..1) if Familia.debug?
118
- value_to_distinguish.send(dump_method)
119
-
120
116
  else
121
117
  Familia.trace :TOREDIS_DISTINGUISHER, redis, "else2 #{strict_values}", caller(1..1) if Familia.debug?
122
118
  raise Familia::HighRiskFactor, value_to_distinguish if strict_values
data/lib/familia.rb CHANGED
@@ -5,6 +5,7 @@ require 'redis'
5
5
  require 'uri/redis'
6
6
 
7
7
  require_relative 'familia/core_ext'
8
+ require_relative 'familia/refinements'
8
9
  require_relative 'familia/errors'
9
10
  require_relative 'familia/version'
10
11
 
@@ -38,3 +38,56 @@ Familia.debug = true
38
38
  ## Remove the key
39
39
  @hashkey.clear
40
40
  #=> 1
41
+
42
+ ## Horreum objects can update and save their fields (1 of 2)
43
+ @customer.name = 'John Doe'
44
+ #=> "John Doe"
45
+
46
+ ## Horreum objects can update and save their fields (2 of 2)
47
+ @customer.save
48
+ #=> true
49
+
50
+ ## Horreum object fields have a fast writer method (1 of 2)
51
+ Familia.trace :LOAD, @customer.redis, @customer.redisuri, caller if Familia.debug?
52
+ @customer.name! 'Jane Doe'
53
+ #=> 0
54
+
55
+ ## Horreum object fields have a fast writer method (2 of 2)
56
+ @customer.refresh!
57
+ @customer.name
58
+ #=> "Jane Doe"
59
+
60
+ ## Unsaved changes are lost when an object reloads
61
+ @customer.name = 'John Doe'
62
+ @customer.refresh!
63
+ @customer.name
64
+ #=> "Jane Doe"
65
+
66
+ ## Horreum objects can be destroyed
67
+ @customer.destroy!
68
+ #=> true
69
+
70
+ ## All horrerum objects have a key field
71
+ @customer.key
72
+ #=> @identifier
73
+
74
+ ## Even ones that didn't define it
75
+ @cd = CustomDomain.new "www.example.com", "@identifier"
76
+ @cd.key
77
+ #=> nil
78
+
79
+ ## We can call #identifier directly if we want to "lasy load" the unique identifier
80
+ @cd.identifier
81
+ #=> "7565befd"
82
+
83
+ ## The #key field will still be nil
84
+ @cd.key
85
+ #=> nil
86
+
87
+ ## But once we save
88
+ @cd.save
89
+ #=> true
90
+
91
+ ## The key will be set
92
+ @cd.key
93
+ #=> "7565befd"
data/try/test_helpers.rb CHANGED
@@ -70,6 +70,7 @@ class Customer < Familia::Horreum
70
70
  field :email
71
71
  field :role
72
72
  field :key
73
+ field :name
73
74
  field :passphrase_encryption
74
75
  field :passphrase
75
76
  field :verified
@@ -154,6 +155,7 @@ class CustomDomain < Familia::Horreum
154
155
  field :trd
155
156
  field :tld
156
157
  field :sld
158
+ # No :key field (so we can test hte behaviour in Horreum#initialize)
157
159
  field :txt_validation_host
158
160
  field :txt_validation_value
159
161
  field :status
@@ -167,7 +169,11 @@ class CustomDomain < Familia::Horreum
167
169
  # the customer ID. This is used to ensure that the same domain can't be
168
170
  # added twice by the same customer while avoiding collisions between customers.
169
171
  def derive_id
170
- Familia.generate_sha_hash(:display_domain, :custid).slice(0, 8)
172
+ elements = [
173
+ display_domain,
174
+ custid
175
+ ]
176
+ Familia.generate_sha_hash(*elements).slice(0, 8)
171
177
  end
172
178
  end
173
179
  @d = CustomDomain.new
@@ -178,6 +184,8 @@ class Limiter < Familia::Horreum
178
184
 
179
185
  identifier :name
180
186
  field :name
187
+ # No :key field (so we can test hte behaviour in Horreum#initialize)
188
+
181
189
  string :counter, :ttl => 1.hour, :quantize => [10.minutes, '%H:%M', 1302468980]
182
190
 
183
191
  def identifier
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: familia
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.pre.rc1
4
+ version: 1.0.0.pre.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-15 00:00:00.000000000 Z
11
+ date: 2024-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -97,6 +97,7 @@ files:
97
97
  - lib/familia/redistype.rb
98
98
  - lib/familia/redistype/commands.rb
99
99
  - lib/familia/redistype/serialization.rb
100
+ - lib/familia/refinements.rb
100
101
  - lib/familia/settings.rb
101
102
  - lib/familia/types/hashkey.rb
102
103
  - lib/familia/types/list.rb
@@ -141,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  - !ruby/object:Gem::Version
142
143
  version: '0'
143
144
  requirements: []
144
- rubygems_version: 3.5.17
145
+ rubygems_version: 3.5.15
145
146
  signing_key:
146
147
  specification_version: 4
147
148
  summary: An ORM for Redis in Ruby.