familia 2.0.0.pre6 → 2.0.0.pre7

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/claude-code-review.yml +57 -0
  3. data/.github/workflows/claude.yml +71 -0
  4. data/.gitignore +5 -1
  5. data/.rubocop.yml +3 -0
  6. data/CLAUDE.md +32 -13
  7. data/Gemfile +2 -2
  8. data/Gemfile.lock +2 -2
  9. data/docs/wiki/Feature-System-Guide.md +36 -5
  10. data/docs/wiki/Home.md +30 -20
  11. data/docs/wiki/Relationships-Guide.md +684 -0
  12. data/examples/bit_encoding_integration.rb +237 -0
  13. data/examples/redis_command_validation_example.rb +231 -0
  14. data/examples/relationships_basic.rb +273 -0
  15. data/lib/familia/connection.rb +3 -3
  16. data/lib/familia/data_type.rb +7 -4
  17. data/lib/familia/features/encrypted_fields/concealed_string.rb +21 -23
  18. data/lib/familia/features/encrypted_fields.rb +413 -4
  19. data/lib/familia/features/expiration.rb +319 -33
  20. data/lib/familia/features/quantization.rb +385 -44
  21. data/lib/familia/features/relationships/cascading.rb +438 -0
  22. data/lib/familia/features/relationships/indexing.rb +370 -0
  23. data/lib/familia/features/relationships/membership.rb +503 -0
  24. data/lib/familia/features/relationships/permission_management.rb +264 -0
  25. data/lib/familia/features/relationships/querying.rb +620 -0
  26. data/lib/familia/features/relationships/redis_operations.rb +274 -0
  27. data/lib/familia/features/relationships/score_encoding.rb +442 -0
  28. data/lib/familia/features/relationships/tracking.rb +379 -0
  29. data/lib/familia/features/relationships.rb +466 -0
  30. data/lib/familia/features/transient_fields.rb +192 -10
  31. data/lib/familia/features.rb +2 -1
  32. data/lib/familia/horreum/subclass/definition.rb +1 -1
  33. data/lib/familia/validation/command_recorder.rb +336 -0
  34. data/lib/familia/validation/expectations.rb +519 -0
  35. data/lib/familia/validation/test_helpers.rb +443 -0
  36. data/lib/familia/validation/validator.rb +412 -0
  37. data/lib/familia/validation.rb +140 -0
  38. data/lib/familia/version.rb +1 -1
  39. data/try/edge_cases/hash_symbolization_try.rb +1 -0
  40. data/try/edge_cases/reserved_keywords_try.rb +1 -0
  41. data/try/edge_cases/string_coercion_try.rb +2 -0
  42. data/try/encryption/encryption_core_try.rb +3 -1
  43. data/try/features/categorical_permissions_try.rb +515 -0
  44. data/try/features/encryption_fields/concealed_string_core_try.rb +3 -0
  45. data/try/features/encryption_fields/context_isolation_try.rb +1 -0
  46. data/try/features/relationships_edge_cases_try.rb +145 -0
  47. data/try/features/relationships_performance_minimal_try.rb +132 -0
  48. data/try/features/relationships_performance_simple_try.rb +155 -0
  49. data/try/features/relationships_performance_try.rb +420 -0
  50. data/try/features/relationships_performance_working_try.rb +144 -0
  51. data/try/features/relationships_try.rb +237 -0
  52. data/try/features/safe_dump_try.rb +3 -0
  53. data/try/features/transient_fields/redacted_string_try.rb +2 -0
  54. data/try/features/transient_fields/single_use_redacted_string_try.rb +2 -0
  55. data/try/helpers/test_helpers.rb +1 -1
  56. data/try/horreum/base_try.rb +14 -8
  57. data/try/horreum/enhanced_conflict_handling_try.rb +2 -0
  58. data/try/horreum/relations_try.rb +1 -1
  59. data/try/validation/atomic_operations_try.rb.disabled +320 -0
  60. data/try/validation/command_validation_try.rb.disabled +207 -0
  61. data/try/validation/performance_validation_try.rb.disabled +324 -0
  62. data/try/validation/real_world_scenarios_try.rb.disabled +390 -0
  63. metadata +32 -4
  64. data/docs/wiki/RelatableObjects-Guide.md +0 -563
  65. data/lib/familia/features/relatable_objects.rb +0 -125
  66. data/try/features/relatable_objects_try.rb +0 -220
@@ -1,58 +1,399 @@
1
1
  # lib/familia/features/quantization.rb
2
2
 
3
- module Familia::Features
4
- module Quantization
5
- def self.included(base)
6
- Familia.trace :included, base, self, caller(1..1) if Familia.debug?
7
- base.extend ClassMethods
8
- end
3
+ module Familia
4
+ module Features
5
+ # Quantization is a feature that provides time-based data bucketing and quantized
6
+ # timestamp generation for Familia objects. It enables efficient time-series data
7
+ # storage, analytics aggregation, and temporal cache key generation by rounding
8
+ # timestamps to specific intervals.
9
+ #
10
+ # This feature is particularly useful for:
11
+ # - Time-series data collection and storage
12
+ # - Analytics data bucketing by time intervals
13
+ # - Cache key generation with time-based expiration
14
+ # - Log aggregation by time periods
15
+ # - Metrics collection with reduced granularity
16
+ # - Rate limiting with time windows
17
+ #
18
+ # Example:
19
+ #
20
+ # class AnalyticsEvent < Familia::Horreum
21
+ # feature :quantization
22
+ # default_expiration 1.hour # Used as default quantum
23
+ #
24
+ # identifier_field :event_id
25
+ # field :event_id, :event_type, :user_id, :data, :timestamp
26
+ # end
27
+ #
28
+ # # Generate quantized timestamps
29
+ # AnalyticsEvent.qstamp(1.hour) # => 1672531200 (rounded to hour)
30
+ # AnalyticsEvent.qstamp(1.hour, '%Y%m%d%H') # => "2023010114" (formatted)
31
+ # AnalyticsEvent.qstamp([1.hour, '%Y%m%d%H']) # => "2023010114" (array syntax)
32
+ #
33
+ # # Instance method also available
34
+ # event = AnalyticsEvent.new
35
+ # event.qstamp(15.minutes) # => 1672531800 (15-min buckets)
36
+ #
37
+ # Time Bucketing:
38
+ #
39
+ # Quantization rounds timestamps to specific intervals, creating consistent
40
+ # time buckets for data aggregation:
41
+ #
42
+ # # Current time: 2023-01-01 14:37:42
43
+ # User.qstamp(1.hour) # => 1672531200 (14:00:00)
44
+ # User.qstamp(15.minutes) # => 1672532100 (14:35:00)
45
+ # User.qstamp(1.day) # => 1672531200 (00:00:00)
46
+ #
47
+ # Formatted Timestamps:
48
+ #
49
+ # Use strftime patterns to generate formatted timestamp strings:
50
+ #
51
+ # User.qstamp(1.hour, pattern: '%Y%m%d%H') # => "2023010114"
52
+ # User.qstamp(1.day, pattern: '%Y-%m-%d') # => "2023-01-01"
53
+ # User.qstamp(1.week, pattern: '%Y-W%W') # => "2023-W01"
54
+ #
55
+ # Custom Time Reference:
56
+ #
57
+ # Specify a custom time instead of using the current time:
58
+ #
59
+ # custom_time = Time.parse('2023-06-15 14:30:45')
60
+ # User.qstamp(1.hour, time: custom_time) # => 1686834000 (14:00:00)
61
+ # User.qstamp(1.day, time: custom_time, pattern: '%Y%m%d') # => "20230615"
62
+ #
63
+ # Integration Patterns:
64
+ #
65
+ # # Time-based cache keys
66
+ # class MetricsCache < Familia::Horreum
67
+ # feature :quantization
68
+ # identifier_field :cache_key
69
+ #
70
+ # field :cache_key, :data, :computed_at
71
+ # hashkey :hourly_metrics
72
+ #
73
+ # def self.hourly_cache_key(metric_name)
74
+ # timestamp = qstamp(1.hour, pattern: '%Y%m%d%H')
75
+ # "metrics:#{metric_name}:#{timestamp}"
76
+ # end
77
+ #
78
+ # def self.daily_cache_key(metric_name)
79
+ # timestamp = qstamp(1.day, pattern: '%Y%m%d')
80
+ # "daily_metrics:#{metric_name}:#{timestamp}"
81
+ # end
82
+ # end
83
+ #
84
+ # # Usage
85
+ # hourly_key = MetricsCache.hourly_cache_key('page_views')
86
+ # # => "metrics:page_views:2023010114"
87
+ #
88
+ # # Analytics data bucketing
89
+ # class UserActivity < Familia::Horreum
90
+ # feature :quantization
91
+ # identifier_field :bucket_id
92
+ #
93
+ # field :bucket_id, :user_count, :event_count, :bucket_time
94
+ #
95
+ # def self.record_activity(user_id, event_type)
96
+ # # Create hourly buckets
97
+ # bucket_time = qstamp(1.hour)
98
+ # bucket_id = "activity:#{qstamp(1.hour, pattern: '%Y%m%d%H')}"
99
+ #
100
+ # activity = find_or_create(bucket_id) do
101
+ # new(bucket_id: bucket_id, bucket_time: bucket_time,
102
+ # user_count: 0, event_count: 0)
103
+ # end
104
+ #
105
+ # activity.event_count += 1
106
+ # activity.save
107
+ # end
108
+ #
109
+ # def self.activity_for_hour(time = Time.now)
110
+ # bucket_id = "activity:#{qstamp(1.hour, time: time, pattern: '%Y%m%d%H')}"
111
+ # find(bucket_id)
112
+ # end
113
+ # end
114
+ #
115
+ # # Time-series data storage
116
+ # class TimeSeriesMetric < Familia::Horreum
117
+ # feature :quantization
118
+ # identifier_field :series_key
119
+ #
120
+ # field :series_key, :metric_name, :interval, :value, :timestamp
121
+ # zset :data_points # score = timestamp, member = value
122
+ #
123
+ # def self.record_metric(metric_name, value, interval = 5.minutes)
124
+ # timestamp = qstamp(interval)
125
+ # series_key = "#{metric_name}:#{interval.to_i}"
126
+ #
127
+ # metric = find_or_create(series_key) do
128
+ # new(series_key: series_key, metric_name: metric_name,
129
+ # interval: interval.to_i)
130
+ # end
131
+ #
132
+ # metric.data_points.add(timestamp, value)
133
+ # metric.timestamp = timestamp
134
+ # metric.value = value
135
+ # metric.save
136
+ # end
137
+ #
138
+ # def self.get_series(metric_name, interval, start_time, end_time)
139
+ # series_key = "#{metric_name}:#{interval.to_i}"
140
+ # metric = find(series_key)
141
+ # return [] unless metric
142
+ #
143
+ # start_bucket = qstamp(interval, time: start_time)
144
+ # end_bucket = qstamp(interval, time: end_time)
145
+ # metric.data_points.range_by_score(start_bucket, end_bucket, with_scores: true)
146
+ # end
147
+ # end
148
+ #
149
+ # Quantum Calculation:
150
+ #
151
+ # The quantum (time interval) determines the bucket size:
152
+ # - 1.minute: Buckets every minute (00, 01, 02, ...)
153
+ # - 5.minutes: Buckets every 5 minutes (00, 05, 10, 15, ...)
154
+ # - 1.hour: Buckets every hour (00:00, 01:00, 02:00, ...)
155
+ # - 1.day: Daily buckets (00:00:00 each day)
156
+ # - 1.week: Weekly buckets (start of week)
157
+ #
158
+ # Understanding Quantum Boundaries:
159
+ #
160
+ # # Current time: 2023-01-01 14:37:42
161
+ #
162
+ # # 1.hour quantum (rounds down to hour boundary)
163
+ # qstamp(1.hour) # => 1672531200 (2023-01-01 14:00:00)
164
+ #
165
+ # # 15.minutes quantum (rounds down to 15-minute boundary)
166
+ # qstamp(15.minutes) # => 1672532100 (2023-01-01 14:30:00)
167
+ #
168
+ # # 1.day quantum (rounds down to day boundary)
169
+ # qstamp(1.day) # => 1672531200 (2023-01-01 00:00:00)
170
+ #
171
+ # Cross-Timezone Considerations:
172
+ #
173
+ # class GlobalMetrics < Familia::Horreum
174
+ # feature :quantization
175
+ #
176
+ # def self.utc_hourly_key(metric_name)
177
+ # # Always use UTC for consistent global buckets
178
+ # timestamp = qstamp(1.hour, time: Time.now.utc, pattern: '%Y%m%d%H')
179
+ # "global:#{metric_name}:#{timestamp}"
180
+ # end
181
+ #
182
+ # def self.local_daily_key(metric_name, timezone = 'America/New_York')
183
+ # # Use local timezone for region-specific buckets
184
+ # local_time = Time.now.in_time_zone(timezone)
185
+ # timestamp = qstamp(1.day, time: local_time, pattern: '%Y%m%d')
186
+ # "#{timezone.gsub('/', '_')}:#{metric_name}:#{timestamp}"
187
+ # end
188
+ # end
189
+ #
190
+ # Performance Optimization:
191
+ #
192
+ # class OptimizedQuantization < Familia::Horreum
193
+ # feature :quantization
194
+ #
195
+ # # Cache quantized timestamps to avoid repeated calculations
196
+ # def self.cached_qstamp(quantum, pattern: nil, time: nil)
197
+ # cache_key = "qstamp:#{quantum}:#{pattern}:#{(time || Time.now).to_i / quantum}"
198
+ # Rails.cache.fetch(cache_key, expires_in: quantum) do
199
+ # qstamp(quantum, pattern: pattern, time: time)
200
+ # end
201
+ # end
202
+ #
203
+ # # Batch quantize multiple timestamps
204
+ # def self.batch_quantize(timestamps, quantum)
205
+ # timestamps.map { |ts| Familia.qstamp(quantum, time: ts) }
206
+ # end
207
+ #
208
+ # # Pre-generate bucket timestamps for a time range
209
+ # def self.pregenerate_buckets(start_time, end_time, quantum)
210
+ # buckets = []
211
+ # current = Familia.qstamp(quantum, time: start_time)
212
+ # end_bucket = Familia.qstamp(quantum, time: end_time)
213
+ #
214
+ # while current <= end_bucket
215
+ # buckets << current
216
+ # current += quantum
217
+ # end
218
+ # buckets
219
+ # end
220
+ # end
221
+ #
222
+ # Error Handling:
223
+ #
224
+ # The feature validates quantum values and provides descriptive errors:
225
+ #
226
+ # User.qstamp(0) # => ArgumentError: Quantum must be positive
227
+ # User.qstamp(-5) # => ArgumentError: Quantum must be positive
228
+ # User.qstamp("invalid") # => ArgumentError: Quantum must be positive
229
+ #
230
+ # Default Quantum Behavior:
231
+ #
232
+ # If no quantum is specified, the feature uses default_expiration or 10.minutes:
233
+ #
234
+ # class MyModel < Familia::Horreum
235
+ # feature :quantization
236
+ # default_expiration 1.hour
237
+ # end
238
+ #
239
+ # MyModel.qstamp() # Uses 1.hour as quantum
240
+ #
241
+ # class NoDefault < Familia::Horreum
242
+ # feature :quantization
243
+ # end
244
+ #
245
+ # NoDefault.qstamp() # Uses 10.minutes as fallback quantum
246
+ #
247
+ module Quantization
248
+ def self.included(base)
249
+ Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
250
+ base.extend ClassMethods
251
+ end
9
252
 
10
- module ClassMethods
11
- # Generates a quantized timestamp based on the given parameters.
12
- #
13
- # @param quantum [Integer, Array, nil] The time quantum in seconds or an array of [quantum, pattern].
14
- # @param pattern [String, nil] The strftime pattern to format the timestamp.
15
- # @param now [Time, nil] The current time (default: Familia.now).
16
- # @return [Integer, String] A unix timestamp or formatted timestamp string.
253
+ module ClassMethods
254
+ # Generates a quantized timestamp based on the given parameters
255
+ #
256
+ # This method rounds the current time to the nearest quantum and optionally
257
+ # formats it according to the given pattern. It's useful for creating
258
+ # time-based buckets or keys with reduced granularity.
259
+ #
260
+ # @param quantum [Numeric, Array, nil] The time quantum in seconds or an array of [quantum, pattern]
261
+ # @param pattern [String, nil] The strftime pattern to format the timestamp
262
+ # @param time [Time, nil] The reference time (default: current time)
263
+ # @return [Integer, String] A unix timestamp or formatted timestamp string
264
+ #
265
+ # @example Generate hourly bucket timestamp
266
+ # User.qstamp(1.hour) # => 1672531200 (rounded to hour boundary)
267
+ #
268
+ # @example Generate formatted timestamp
269
+ # User.qstamp(1.hour, pattern: '%Y%m%d%H') # => "2023010114"
270
+ #
271
+ # @example Using array syntax
272
+ # User.qstamp([1.hour, '%Y%m%d%H']) # => "2023010114"
273
+ #
274
+ # @example With custom time reference
275
+ # custom_time = Time.parse('2023-06-15 14:30:45')
276
+ # User.qstamp(1.hour, time: custom_time) # => 1686834000
277
+ #
278
+ # @raise [ArgumentError] If quantum is not positive
279
+ #
280
+ def qstamp(quantum = nil, pattern: nil, time: nil)
281
+ # Handle array input format: [quantum, pattern]
282
+ if quantum.is_a?(Array)
283
+ quantum, pattern = quantum
284
+ end
285
+
286
+ # Use default quantum if none specified
287
+ # Priority: provided quantum > class default_expiration > 10.minutes fallback
288
+ quantum ||= default_expiration || 10.minutes
289
+
290
+ # Validate quantum value
291
+ unless quantum.is_a?(Numeric) && quantum.positive?
292
+ raise ArgumentError, "Quantum must be positive (#{quantum.inspect} given)"
293
+ end
294
+
295
+ # Delegate to Familia.qstamp for the actual calculation
296
+ Familia.qstamp(quantum, pattern: pattern, time: time)
297
+ end
298
+
299
+ # Generate multiple quantized timestamps for a time range
300
+ #
301
+ # @param start_time [Time] Start of the time range
302
+ # @param end_time [Time] End of the time range
303
+ # @param quantum [Numeric] Time quantum in seconds
304
+ # @param pattern [String, nil] Optional strftime pattern
305
+ # @return [Array] Array of quantized timestamps
306
+ #
307
+ # @example Generate hourly buckets for a day
308
+ # start_time = Time.parse('2023-01-01 00:00:00')
309
+ # end_time = Time.parse('2023-01-01 23:59:59')
310
+ # User.qstamp_range(start_time, end_time, 1.hour)
311
+ # # => [1672531200, 1672534800, 1672538400, ...] (24 hourly buckets)
312
+ #
313
+ def qstamp_range(start_time, end_time, quantum, pattern: nil)
314
+ timestamps = []
315
+ current = qstamp(quantum, time: start_time)
316
+ end_bucket = qstamp(quantum, time: end_time)
317
+
318
+ while current <= end_bucket
319
+ if pattern
320
+ timestamps << Time.at(current).strftime(pattern)
321
+ else
322
+ timestamps << current
323
+ end
324
+ current += quantum
325
+ end
326
+
327
+ timestamps
328
+ end
329
+
330
+ # Check if a timestamp falls within a quantized bucket
331
+ #
332
+ # @param timestamp [Time, Integer] The timestamp to check
333
+ # @param quantum [Numeric] The quantum interval
334
+ # @param bucket_time [Time, Integer] The bucket reference time
335
+ # @return [Boolean] true if timestamp falls in the bucket
336
+ #
337
+ # @example Check if event falls in hourly bucket
338
+ # event_time = Time.parse('2023-01-01 14:37:42')
339
+ # bucket_time = Time.parse('2023-01-01 14:00:00')
340
+ # User.in_bucket?(event_time, 1.hour, bucket_time) # => true
341
+ #
342
+ def in_bucket?(timestamp, quantum, bucket_time)
343
+ timestamp = timestamp.to_i if timestamp.respond_to?(:to_i)
344
+ bucket_time = bucket_time.to_i if bucket_time.respond_to?(:to_i)
345
+ bucket_start = qstamp(quantum, time: Time.at(bucket_time))
346
+ bucket_end = bucket_start + quantum - 1
347
+
348
+ timestamp >= bucket_start && timestamp <= bucket_end
349
+ end
350
+ end
351
+
352
+ # Instance method version of qstamp
17
353
  #
18
- # This method rounds the current time to the nearest quantum and optionally formats it
19
- # according to the given pattern. It's useful for creating time-based buckets
20
- # or keys with reduced granularity.
354
+ # Generates a quantized timestamp using the same logic as the class method,
355
+ # but can access instance-specific default expiration settings.
21
356
  #
22
- # @example
23
- # User.qstamp(1.hour, '%Y%m%d%H') # Returns a string like "2023060114" for 2:30 PM
24
- # User.qstamp(10.minutes) # Returns an integer timestamp rounded to the nearest 10 minutes
25
- # User.qstamp([1.hour, '%Y%m%d%H']) # Same as the first example
357
+ # @param quantum [Numeric, Array, nil] The time quantum in seconds or array format
358
+ # @param pattern [String, nil] The strftime pattern to format the timestamp
359
+ # @param time [Time, nil] The reference time (default: current time)
360
+ # @return [Integer, String] A unix timestamp or formatted timestamp string
26
361
  #
27
- # @raise [ArgumentError] If quantum is not positive
362
+ # @example Instance usage
363
+ # event = AnalyticsEvent.new
364
+ # event.qstamp(15.minutes) # => 1672532100
28
365
  #
29
366
  def qstamp(quantum = nil, pattern: nil, time: nil)
30
- # Handle default values and array input
31
- quantum, pattern = quantum if quantum.is_a?(Array)
32
-
33
- # Previously we erronously included `@opts.fetch(:quantize, nil)` in
34
- # the list of default values here, but @opts is for horreum instances
35
- # not at the class level. This method `qstamp` is part of the initial
36
- # definition for whatever horreum subclass we're in right now. That's
37
- # why default_expiration works (e.g. `class Plop; feature :quantization; default_expiration 90; end`).
38
- quantum ||= default_expiration || 10.minutes
39
-
40
- # Validate quantum
41
- unless quantum.is_a?(Numeric) && quantum.positive?
42
- raise ArgumentError, "Quantum must be positive (#{quantum.inspect} given)"
43
- end
44
-
45
- # Call Familia.qstamp with our processed parameters
46
- Familia.qstamp(quantum, pattern: pattern, time: time)
367
+ # Use instance default_expiration if available, otherwise delegate to class
368
+ quantum ||= default_expiration if respond_to?(:default_expiration)
369
+ self.class.qstamp(quantum, pattern: pattern, time: time)
47
370
  end
48
- end
49
371
 
50
- def qstamp(quantum = nil, pattern: nil, time: nil)
51
- self.class.qstamp(quantum || self.class.default_expiration, pattern: pattern, time: time)
52
- end
372
+ # Generate a quantized identifier for this instance
373
+ #
374
+ # Creates a time-based identifier using the instance's identifier and
375
+ # a quantized timestamp. Useful for creating time-bucketed cache keys
376
+ # or grouping identifiers.
377
+ #
378
+ # @param quantum [Numeric] Time quantum in seconds
379
+ # @param pattern [String, nil] Optional strftime pattern
380
+ # @param separator [String] Separator between identifier and timestamp
381
+ # @return [String] Combined identifier with quantized timestamp
382
+ #
383
+ # @example Generate time-based cache key
384
+ # user = User.new(id: 123)
385
+ # user.quantized_identifier(1.hour) # => "123:1672531200"
386
+ # user.quantized_identifier(1.hour, pattern: '%Y%m%d%H') # => "123:2023010114"
387
+ #
388
+ def quantized_identifier(quantum, pattern: nil, separator: ':')
389
+ timestamp = qstamp(quantum, pattern: pattern)
390
+ base_id = respond_to?(:identifier) ? identifier : object_id
391
+ "#{base_id}#{separator}#{timestamp}"
392
+ end
53
393
 
54
- extend ClassMethods
394
+ extend ClassMethods
55
395
 
56
- Familia::Base.add_feature self, :quantization
396
+ Familia::Base.add_feature self, :quantization
397
+ end
57
398
  end
58
399
  end