familia 2.0.0.pre10 → 2.0.0.pre13
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/.rubocop_todo.yml +2 -3
- data/CHANGELOG.rst +507 -0
- data/CLAUDE.md +5 -55
- data/Gemfile +1 -6
- data/Gemfile.lock +13 -7
- data/changelog.d/README.md +45 -34
- data/changelog.d/scriv.ini +5 -0
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
- data/docs/archive/FAMILIA_UPDATE.md +1 -1
- data/docs/archive/README.md +15 -19
- data/docs/guides/Feature-System-Autoloading.md +228 -0
- data/docs/guides/Home.md +1 -1
- data/docs/guides/Implementation-Guide.md +1 -1
- data/docs/guides/relationships-methods.md +1 -1
- data/docs/guides/time-utilities.md +221 -0
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +253 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre13.md +329 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
- data/examples/autoloader/mega_customer.rb +17 -0
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/{relationships_basic.rb → relationships.rb} +2 -3
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +5 -4
- data/lib/familia/autoloader.rb +53 -0
- data/lib/familia/base.rb +57 -0
- data/lib/familia/data_type.rb +4 -0
- data/lib/familia/encryption/encrypted_data.rb +4 -4
- data/lib/familia/encryption/manager.rb +6 -4
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/encryption.rb +1 -1
- data/lib/familia/errors.rb +5 -0
- data/lib/familia/features/autoloadable.rb +113 -0
- data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
- data/lib/familia/features/expiration.rb +4 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/quantization.rb +5 -0
- data/lib/familia/features/safe_dump.rb +74 -73
- data/lib/familia/features.rb +109 -17
- data/lib/familia/field_type.rb +2 -0
- data/lib/familia/horreum/core/serialization.rb +3 -3
- data/lib/familia/horreum/subclass/definition.rb +50 -7
- data/lib/familia/horreum.rb +2 -0
- data/lib/familia/json_serializer.rb +70 -0
- data/lib/familia/logging.rb +12 -10
- data/lib/familia/refinements/logger_trace.rb +57 -0
- data/lib/familia/refinements/snake_case.rb +40 -0
- data/lib/familia/refinements/time_utils.rb +248 -0
- data/lib/familia/refinements.rb +3 -49
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/utils.rb +2 -0
- data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
- data/lib/familia/validation.rb +1 -1
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +15 -2
- data/try/core/autoloader_try.rb +112 -0
- data/try/core/extensions_try.rb +38 -21
- data/try/core/familia_extended_try.rb +4 -3
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/time_utils_try.rb +130 -0
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/data_types/datatype_base_try.rb +3 -2
- data/try/features/autoloadable/autoloadable_try.rb +61 -0
- data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
- data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
- data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +127 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +8 -7
- data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +41 -17
- data/try/integration/cross_component_try.rb +3 -1
- metadata +61 -26
- data/CHANGELOG.md +0 -184
- data/changelog.d/fragments/.keep +0 -0
- data/changelog.d/template.md.j2 +0 -29
- data/lib/familia/core_ext.rb +0 -135
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
- data/setup.cfg +0 -12
@@ -470,7 +470,7 @@ module Familia
|
|
470
470
|
# If the distinguisher returns nil, try using the dump_method but only
|
471
471
|
# use JSON serialization for complex types that need it.
|
472
472
|
if prepared.nil? && (val.is_a?(Hash) || val.is_a?(Array))
|
473
|
-
prepared = val.respond_to?(dump_method) ? val.send(dump_method) :
|
473
|
+
prepared = val.respond_to?(dump_method) ? val.send(dump_method) : Familia::JsonSerializer.dump(val)
|
474
474
|
end
|
475
475
|
|
476
476
|
# If both the distinguisher and dump_method return nil, log an error
|
@@ -493,11 +493,11 @@ module Familia
|
|
493
493
|
|
494
494
|
# Try to parse as JSON first for complex types
|
495
495
|
begin
|
496
|
-
parsed =
|
496
|
+
parsed = Familia::JsonSerializer.parse(val, symbolize_names: symbolize)
|
497
497
|
# Only return parsed value if it's a complex type (Hash/Array)
|
498
498
|
# Simple values should remain as strings
|
499
499
|
return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
500
|
-
rescue
|
500
|
+
rescue Familia::SerializerError
|
501
501
|
# Not valid JSON, return as-is
|
502
502
|
end
|
503
503
|
|
@@ -46,6 +46,8 @@ module Familia
|
|
46
46
|
include Familia::Settings
|
47
47
|
include Familia::Horreum::RelatedFieldsManagement # Provides DataType field methods
|
48
48
|
|
49
|
+
using Familia::Refinements::SnakeCase
|
50
|
+
|
49
51
|
# Sets or retrieves the unique identifier field for the class.
|
50
52
|
#
|
51
53
|
# This method defines or returns the field or method that contains the unique
|
@@ -183,10 +185,7 @@ module Familia
|
|
183
185
|
#
|
184
186
|
# @return [String] The underscored class name as a string
|
185
187
|
def config_name
|
186
|
-
name.
|
187
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
188
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
189
|
-
.downcase
|
188
|
+
name.snake_case
|
190
189
|
end
|
191
190
|
|
192
191
|
def dump_method
|
@@ -237,10 +236,36 @@ module Familia
|
|
237
236
|
field_types[field_type.name] = field_type
|
238
237
|
end
|
239
238
|
|
240
|
-
#
|
239
|
+
# Retrieves feature options for the current class.
|
240
|
+
#
|
241
|
+
# Feature options are stored **per-class** in instance variables, ensuring
|
242
|
+
# complete isolation between different Familia::Horreum subclasses. Each
|
243
|
+
# class maintains its own @feature_options hash that does not interfere
|
244
|
+
# with other classes' configurations.
|
245
|
+
#
|
246
|
+
# @param feature_name [Symbol, String, nil] the name of the feature to get options for.
|
247
|
+
# If nil, returns the entire feature options hash for this class.
|
248
|
+
# @return [Hash] the feature options hash, either for a specific feature or all features
|
249
|
+
#
|
250
|
+
# @example Getting options for a specific feature
|
251
|
+
# class MyModel < Familia::Horreum
|
252
|
+
# feature :object_identifier, generator: :uuid_v4
|
253
|
+
# end
|
254
|
+
#
|
255
|
+
# MyModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
256
|
+
# MyModel.feature_options #=> {object_identifier: {generator: :uuid_v4}}
|
257
|
+
#
|
258
|
+
# @example Per-class isolation
|
259
|
+
# class UserModel < Familia::Horreum
|
260
|
+
# feature :object_identifier, generator: :uuid_v4
|
261
|
+
# end
|
241
262
|
#
|
242
|
-
#
|
243
|
-
#
|
263
|
+
# class SessionModel < Familia::Horreum
|
264
|
+
# feature :object_identifier, generator: :hex
|
265
|
+
# end
|
266
|
+
#
|
267
|
+
# UserModel.feature_options(:object_identifier) #=> {generator: :uuid_v4}
|
268
|
+
# SessionModel.feature_options(:object_identifier) #=> {generator: :hex}
|
244
269
|
#
|
245
270
|
def feature_options(feature_name = nil)
|
246
271
|
@feature_options ||= {}
|
@@ -255,10 +280,28 @@ module Familia
|
|
255
280
|
# without worrying about initialization state. Similar to register_field_type
|
256
281
|
# for field types.
|
257
282
|
#
|
283
|
+
# Feature options are stored at the **class level** using instance variables,
|
284
|
+
# ensuring complete isolation between different Familia::Horreum subclasses.
|
285
|
+
# Each class maintains its own @feature_options hash.
|
286
|
+
#
|
258
287
|
# @param feature_name [Symbol] The feature name
|
259
288
|
# @param options [Hash] The options to add/merge
|
260
289
|
# @return [Hash] The updated options for the feature
|
261
290
|
#
|
291
|
+
# @note This method only sets defaults for options that don't already exist,
|
292
|
+
# using the ||= operator to prevent overwrites.
|
293
|
+
#
|
294
|
+
# @example Per-class storage behavior
|
295
|
+
# class ModelA < Familia::Horreum
|
296
|
+
# # This stores options in ModelA's @feature_options
|
297
|
+
# add_feature_options(:my_feature, key: 'value_a')
|
298
|
+
# end
|
299
|
+
#
|
300
|
+
# class ModelB < Familia::Horreum
|
301
|
+
# # This stores options in ModelB's @feature_options (separate from ModelA)
|
302
|
+
# add_feature_options(:my_feature, key: 'value_b')
|
303
|
+
# end
|
304
|
+
#
|
262
305
|
def add_feature_options(feature_name, **options)
|
263
306
|
@feature_options ||= {}
|
264
307
|
@feature_options[feature_name.to_sym] ||= {}
|
data/lib/familia/horreum.rb
CHANGED
@@ -0,0 +1,70 @@
|
|
1
|
+
# lib/familia/json_serializer.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
# JsonSerializer provides a high-performance JSON interface using OJ
|
5
|
+
#
|
6
|
+
# This module wraps OJ with a clean API that can be easily swapped out
|
7
|
+
# or benchmarked against other JSON implementations. Uses OJ's :strict
|
8
|
+
# mode for RFC 7159 compliant JSON output.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# data = { name: 'test', value: 123 }
|
12
|
+
# json = Familia::JsonSerializer.dump(data)
|
13
|
+
# parsed = Familia::JsonSerializer.parse(json, symbolize_names: true)
|
14
|
+
#
|
15
|
+
module JsonSerializer
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Parse JSON string into Ruby objects
|
19
|
+
#
|
20
|
+
# @param source [String] JSON string to parse
|
21
|
+
# @param opts [Hash] parsing options
|
22
|
+
# @option opts [Boolean] :symbolize_names convert hash keys to symbols
|
23
|
+
# @return [Object] parsed Ruby object
|
24
|
+
# @raise [SerializerError] if JSON is malformed
|
25
|
+
def parse(source, opts = {})
|
26
|
+
return nil if source.nil? || source == ''
|
27
|
+
|
28
|
+
symbolize_names = opts[:symbolize_names] || opts['symbolize_names']
|
29
|
+
|
30
|
+
if symbolize_names
|
31
|
+
Oj.load(source, mode: :strict, symbol_keys: true)
|
32
|
+
else
|
33
|
+
Oj.load(source, mode: :strict)
|
34
|
+
end
|
35
|
+
rescue Oj::ParseError, Oj::Error, EncodingError => e
|
36
|
+
raise SerializerError, e.message
|
37
|
+
end
|
38
|
+
|
39
|
+
# Serialize Ruby object to JSON string
|
40
|
+
#
|
41
|
+
# @param obj [Object] Ruby object to serialize
|
42
|
+
# @return [String] JSON string
|
43
|
+
def dump(obj)
|
44
|
+
Oj.dump(obj, mode: :strict)
|
45
|
+
rescue Oj::Error, TypeError, EncodingError => e
|
46
|
+
raise SerializerError, e.message
|
47
|
+
end
|
48
|
+
|
49
|
+
# Alias for dump for JSON gem compatibility
|
50
|
+
#
|
51
|
+
# @param obj [Object] Ruby object to serialize
|
52
|
+
# @return [String] JSON string
|
53
|
+
def generate(obj)
|
54
|
+
Oj.dump(obj, mode: :strict)
|
55
|
+
rescue Oj::Error, TypeError, EncodingError => e
|
56
|
+
raise SerializerError, e.message
|
57
|
+
end
|
58
|
+
|
59
|
+
# Serialize Ruby object to pretty-formatted JSON string
|
60
|
+
#
|
61
|
+
# @param obj [Object] Ruby object to serialize
|
62
|
+
# @return [String] pretty-formatted JSON string
|
63
|
+
def pretty_generate(obj)
|
64
|
+
Oj.dump(obj, mode: :strict, indent: 2)
|
65
|
+
rescue Oj::Error, TypeError, EncodingError => e
|
66
|
+
raise SerializerError, e.message
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/familia/logging.rb
CHANGED
@@ -17,7 +17,7 @@ module Familia
|
|
17
17
|
|
18
18
|
# Get the severity letter from the thread local variable or use
|
19
19
|
# the default. The thread local variable is set in the trace
|
20
|
-
# method in the
|
20
|
+
# method in the Familia::Refinements::LoggerTrace module. The name of the
|
21
21
|
# variable `severity_letter` is arbitrary and could be anything.
|
22
22
|
severity_letter = Thread.current[:severity_letter] || severity_letter
|
23
23
|
|
@@ -36,7 +36,7 @@ module Familia
|
|
36
36
|
# == Methods:
|
37
37
|
# trace::
|
38
38
|
# Logs a message at the TRACE level. This method is only available if the
|
39
|
-
#
|
39
|
+
# Familia::Refinements::LoggerTrace is used.
|
40
40
|
#
|
41
41
|
# debug::
|
42
42
|
# Logs a message at the DEBUG level. This is used for low-level system information
|
@@ -59,14 +59,14 @@ module Familia
|
|
59
59
|
# that will presumably lead the application to abort.
|
60
60
|
#
|
61
61
|
# == Usage:
|
62
|
-
# To use the Logging module, you need to include the
|
62
|
+
# To use the Logging module, you need to include the Familia::Refinements::LoggerTrace module
|
63
63
|
# and use the `using` keyword to enable the refinement. This will add the TRACE
|
64
64
|
# log level and the trace method to the Logger class.
|
65
65
|
#
|
66
66
|
# Example:
|
67
67
|
# require 'logger'
|
68
68
|
#
|
69
|
-
# module
|
69
|
+
# module Familia::Refinements::LoggerTrace
|
70
70
|
# refine Logger do
|
71
71
|
# TRACE = 0
|
72
72
|
#
|
@@ -76,7 +76,7 @@ module Familia
|
|
76
76
|
# end
|
77
77
|
# end
|
78
78
|
#
|
79
|
-
# using
|
79
|
+
# using Familia::Refinements::LoggerTrace
|
80
80
|
#
|
81
81
|
# logger = Logger.new(STDOUT)
|
82
82
|
# logger.trace("This is a trace message")
|
@@ -86,13 +86,13 @@ module Familia
|
|
86
86
|
# logger.error("This is an error message")
|
87
87
|
# logger.fatal("This is a fatal message")
|
88
88
|
#
|
89
|
-
# In this example, the
|
89
|
+
# In this example, the Familia::Refinements::LoggerTrace module is defined with a refinement
|
90
90
|
# for the Logger class. The TRACE constant and trace method are added to the Logger
|
91
91
|
# class within the refinement. The `using` keyword is used to apply the refinement
|
92
92
|
# in the scope where it's needed.
|
93
93
|
#
|
94
94
|
# == Conditions:
|
95
|
-
# The trace method and TRACE log level are only available if the
|
95
|
+
# The trace method and TRACE log level are only available if the Familia::Refinements::LoggerTrace
|
96
96
|
# module is used with the `using` keyword. Without this, the Logger class will not
|
97
97
|
# have the trace method or the TRACE log level.
|
98
98
|
#
|
@@ -103,7 +103,9 @@ module Familia
|
|
103
103
|
attr_reader :logger
|
104
104
|
|
105
105
|
# Gives our logger the ability to use our trace method.
|
106
|
-
|
106
|
+
if Familia::Refinements::LoggerTrace::ENABLED
|
107
|
+
using Familia::Refinements::LoggerTrace
|
108
|
+
end
|
107
109
|
|
108
110
|
def info(*msg)
|
109
111
|
@logger.info(*msg)
|
@@ -140,13 +142,13 @@ module Familia
|
|
140
142
|
#
|
141
143
|
# @return [nil]
|
142
144
|
#
|
143
|
-
# @note This method only executes if
|
145
|
+
# @note This method only executes if Familia::Refinements::LoggerTrace::ENABLED is true.
|
144
146
|
# @note The dbclient can be a Database object, Redis::Future (used in
|
145
147
|
# pipelined and multi blocks), or nil (when the database connection isn't
|
146
148
|
# relevant).
|
147
149
|
#
|
148
150
|
def trace(label, dbclient, ident, context = nil)
|
149
|
-
return unless
|
151
|
+
return unless Familia::Refinements::LoggerTrace::ENABLED
|
150
152
|
|
151
153
|
# Usually dbclient is a Database object, but it could be
|
152
154
|
# a Redis::Future which is what is used inside of pipelined
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# lib/familia/refinements/logger_trace.rb
|
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
|
+
# Familia::Refinements::LoggerTrace
|
10
|
+
#
|
11
|
+
# This module adds a 'trace' log level to the Ruby Logger class.
|
12
|
+
# It is enabled when the FAMILIA_TRACE environment variable is set to
|
13
|
+
# '1', 'true', or 'yes' (case-insensitive).
|
14
|
+
#
|
15
|
+
# @example Enabling trace logging
|
16
|
+
# # Set environment variable
|
17
|
+
# ENV['FAMILIA_TRACE'] = 'true'
|
18
|
+
#
|
19
|
+
# # In your Ruby code
|
20
|
+
# require 'logger'
|
21
|
+
# using Familia::Refinements::LoggerTrace
|
22
|
+
#
|
23
|
+
# logger = Logger.new(STDOUT)
|
24
|
+
# logger.trace("This is a trace message")
|
25
|
+
#
|
26
|
+
module Familia
|
27
|
+
module Refinements
|
28
|
+
|
29
|
+
# Familia::Refinements::LoggerTrace
|
30
|
+
module LoggerTrace
|
31
|
+
unless defined?(ENABLED)
|
32
|
+
# Indicates whether trace logging is enabled
|
33
|
+
ENABLED = %w[1 true yes].include?(FAMILIA_TRACE).freeze
|
34
|
+
# The numeric level for trace logging (same as DEBUG)
|
35
|
+
TRACE = 0
|
36
|
+
end
|
37
|
+
|
38
|
+
refine Logger do
|
39
|
+
##
|
40
|
+
# Logs a message at the TRACE level.
|
41
|
+
#
|
42
|
+
# @param progname [String] The program name to include in the log message
|
43
|
+
# @yield A block that evaluates to the message to log
|
44
|
+
# @return [true] Always returns true
|
45
|
+
#
|
46
|
+
# @example Logging a trace message
|
47
|
+
# logger.trace("MyApp") { "Detailed trace information" }
|
48
|
+
def trace(progname = nil, &block)
|
49
|
+
Thread.current[:severity_letter] = 'T'
|
50
|
+
add(Familia::Refinements::LoggerTrace::TRACE, nil, progname, &block)
|
51
|
+
ensure
|
52
|
+
Thread.current[:severity_letter] = nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# lib/familia/refinements/snake_case.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Refinements
|
5
|
+
module SnakeCase
|
6
|
+
# We refine String rather than Class or Module because this method operates on
|
7
|
+
# string representations of class names (like those from `Class#name`) rather
|
8
|
+
# than the class objects themselves. Refining String is safer because it
|
9
|
+
# limits its scope to only the subset string manipulation contexts where it is
|
10
|
+
# used.
|
11
|
+
#
|
12
|
+
# Appropriate for converting Ruby class names to database table names, config
|
13
|
+
# keys, part of a path or any other snake_case identifiers. The only situation
|
14
|
+
# it is not appropriate for is investigating actual snakes.
|
15
|
+
refine String do
|
16
|
+
# Converts a string from PascalCase/camelCase to snake_case format.
|
17
|
+
#
|
18
|
+
# @return [String] the snake_case version of the string
|
19
|
+
#
|
20
|
+
# @example Converting simple CamelCase
|
21
|
+
# "FirstName".snake_case #=> "first_name"
|
22
|
+
#
|
23
|
+
# @example Converting PascalCase with acronyms
|
24
|
+
# XMLHttpRequest.name.snake_case #=> "xml_http_request"
|
25
|
+
#
|
26
|
+
# @example Converting namespaced class names
|
27
|
+
# "MyApp::UserAccount".snake_case #=> "user_account"
|
28
|
+
#
|
29
|
+
# @example Handling mixed case with numbers
|
30
|
+
# "parseHTML5Document".snake_case #=> "parse_html5_document"
|
31
|
+
def snake_case
|
32
|
+
split('::').last
|
33
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
34
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
35
|
+
.downcase
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,248 @@
|
|
1
|
+
# lib/familia/refinements/time_utils.rb
|
2
|
+
|
3
|
+
module Familia
|
4
|
+
module Refinements
|
5
|
+
|
6
|
+
# Familia::Refinements::TimeUtils
|
7
|
+
module TimeUtils
|
8
|
+
# Time unit constants
|
9
|
+
PER_MICROSECOND = 0.000001
|
10
|
+
PER_MILLISECOND = 0.001
|
11
|
+
PER_MINUTE = 60.0
|
12
|
+
PER_HOUR = 3600.0
|
13
|
+
PER_DAY = 86_400.0
|
14
|
+
PER_WEEK = 604_800.0
|
15
|
+
PER_YEAR = 31_556_952.0 # 365.2425 days (Gregorian year)
|
16
|
+
PER_MONTH = PER_YEAR / 12.0 # 30.437 days (consistent with Gregorian year)
|
17
|
+
|
18
|
+
UNIT_METHODS = {
|
19
|
+
'y' => :years,
|
20
|
+
'year' => :years,
|
21
|
+
'years' => :years,
|
22
|
+
'mo' => :months,
|
23
|
+
'month' => :months,
|
24
|
+
'months' => :months,
|
25
|
+
'w' => :weeks,
|
26
|
+
'week' => :weeks,
|
27
|
+
'weeks' => :weeks,
|
28
|
+
'd' => :days,
|
29
|
+
'day' => :days,
|
30
|
+
'days' => :days,
|
31
|
+
'h' => :hours,
|
32
|
+
'hour' => :hours,
|
33
|
+
'hours' => :hours,
|
34
|
+
'm' => :minutes,
|
35
|
+
'minute' => :minutes,
|
36
|
+
'minutes' => :minutes,
|
37
|
+
'ms' => :milliseconds,
|
38
|
+
'millisecond' => :milliseconds,
|
39
|
+
'milliseconds' => :milliseconds,
|
40
|
+
'us' => :microseconds,
|
41
|
+
'microsecond' => :microseconds,
|
42
|
+
'microseconds' => :microseconds,
|
43
|
+
'μs' => :microseconds,
|
44
|
+
}.freeze
|
45
|
+
|
46
|
+
refine Numeric do
|
47
|
+
def microseconds = seconds * PER_MICROSECOND
|
48
|
+
def milliseconds = seconds * PER_MILLISECOND
|
49
|
+
def seconds = self
|
50
|
+
def minutes = seconds * PER_MINUTE
|
51
|
+
def hours = seconds * PER_HOUR
|
52
|
+
def days = seconds * PER_DAY
|
53
|
+
def weeks = seconds * PER_WEEK
|
54
|
+
def months = seconds * PER_MONTH
|
55
|
+
def years = seconds * PER_YEAR
|
56
|
+
|
57
|
+
# Aliases with singular forms
|
58
|
+
alias_method :microsecond, :microseconds
|
59
|
+
alias_method :millisecond, :milliseconds
|
60
|
+
alias_method :second, :seconds
|
61
|
+
alias_method :minute, :minutes
|
62
|
+
alias_method :hour, :hours
|
63
|
+
alias_method :day, :days
|
64
|
+
alias_method :week, :weeks
|
65
|
+
alias_method :month, :months
|
66
|
+
alias_method :year, :years
|
67
|
+
|
68
|
+
# Fun aliases
|
69
|
+
alias_method :ms, :milliseconds
|
70
|
+
alias_method :μs, :microseconds
|
71
|
+
|
72
|
+
# Seconds -> other time units
|
73
|
+
def in_years = seconds / PER_YEAR
|
74
|
+
def in_months = seconds / PER_MONTH
|
75
|
+
def in_weeks = seconds / PER_WEEK
|
76
|
+
def in_days = seconds / PER_DAY
|
77
|
+
def in_hours = seconds / PER_HOUR
|
78
|
+
def in_minutes = seconds / PER_MINUTE
|
79
|
+
def in_milliseconds = seconds / PER_MILLISECOND
|
80
|
+
def in_microseconds = seconds / PER_MICROSECOND
|
81
|
+
# For semantic purposes
|
82
|
+
def in_seconds = seconds
|
83
|
+
|
84
|
+
# Time manipulation
|
85
|
+
def ago = Time.now.utc - seconds
|
86
|
+
def from_now = Time.now.utc + seconds
|
87
|
+
def before(time) = time - seconds
|
88
|
+
def after(time) = time + seconds
|
89
|
+
def in_time = Time.at(seconds).utc
|
90
|
+
|
91
|
+
# Milliseconds conversion
|
92
|
+
def to_ms = seconds * 1000.0
|
93
|
+
|
94
|
+
# Converts seconds to specified time unit
|
95
|
+
#
|
96
|
+
# @param u [String, Symbol] Unit to convert to
|
97
|
+
# @return [Float] Converted time value
|
98
|
+
def in_seconds(u = nil)
|
99
|
+
return self unless u
|
100
|
+
|
101
|
+
case UNIT_METHODS.fetch(u.to_s.downcase, nil)
|
102
|
+
when :milliseconds then self * PER_MILLISECOND
|
103
|
+
when :microseconds then self * PER_MICROSECOND
|
104
|
+
when :minutes then self * PER_MINUTE
|
105
|
+
when :hours then self * PER_HOUR
|
106
|
+
when :days then self * PER_DAY
|
107
|
+
when :weeks then self * PER_WEEK
|
108
|
+
when :months then self * PER_MONTH
|
109
|
+
when :years then self * PER_YEAR
|
110
|
+
else self
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Converts the number to a human-readable string representation
|
115
|
+
#
|
116
|
+
# @return [String] A formatted string e.g. "1 day" or "10 seconds"
|
117
|
+
#
|
118
|
+
# @example
|
119
|
+
# 10.to_humanize #=> "10 seconds"
|
120
|
+
# 60.to_humanize #=> "1 minute"
|
121
|
+
# 3600.to_humanize #=> "1 hour"
|
122
|
+
# 86400.to_humanize #=> "1 day"
|
123
|
+
def humanize
|
124
|
+
gte_zero = positive? || zero?
|
125
|
+
duration = (gte_zero ? self : abs) # let's keep it positive up in here
|
126
|
+
text = case (s = duration.to_i)
|
127
|
+
in 0..59 then "#{s} second#{'s' if s != 1}"
|
128
|
+
in 60..3599 then "#{s /= 60} minute#{'s' if s != 1}"
|
129
|
+
in 3600..86_399 then "#{s /= 3600} hour#{'s' if s != 1}"
|
130
|
+
else "#{s /= 86_400} day#{'s' if s != 1}"
|
131
|
+
end
|
132
|
+
gte_zero ? text : "#{text} ago"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Converts the number to a human-readable byte representation using binary units
|
136
|
+
#
|
137
|
+
# @return [String] A formatted string of bytes, KiB, MiB, GiB, or TiB
|
138
|
+
#
|
139
|
+
# @example
|
140
|
+
# 1024.to_bytes #=> "1.00 KiB"
|
141
|
+
# 2_097_152.to_bytes #=> "2.00 MiB"
|
142
|
+
# 3_221_225_472.to_bytes #=> "3.00 GiB"
|
143
|
+
#
|
144
|
+
def to_bytes
|
145
|
+
units = %w[B KiB MiB GiB TiB]
|
146
|
+
size = abs.to_f
|
147
|
+
unit = 0
|
148
|
+
|
149
|
+
while size >= 1024 && unit < units.length - 1
|
150
|
+
size /= 1024
|
151
|
+
unit += 1
|
152
|
+
end
|
153
|
+
|
154
|
+
format('%3.2f %s', size, units[unit])
|
155
|
+
end
|
156
|
+
|
157
|
+
# Calculates age of timestamp in specified unit from reference time
|
158
|
+
#
|
159
|
+
# @param unit [String, Symbol] Time unit ('days', 'hours', 'minutes', 'weeks')
|
160
|
+
# @param from_time [Time, nil] Reference time (defaults to Time.now.utc)
|
161
|
+
# @return [Float] Age in specified unit
|
162
|
+
# @example
|
163
|
+
# timestamp = 2.days.ago.to_i
|
164
|
+
# timestamp.age_in(:days) #=> ~2.0
|
165
|
+
# timestamp.age_in('hours') #=> ~48.0
|
166
|
+
# timestamp.age_in(:days, 1.day.ago) #=> ~1.0
|
167
|
+
def age_in(unit, from_time = nil)
|
168
|
+
from_time ||= Time.now.utc
|
169
|
+
age_seconds = from_time.to_f - to_f
|
170
|
+
case UNIT_METHODS.fetch(unit.to_s.downcase, nil)
|
171
|
+
when :days then age_seconds / PER_DAY
|
172
|
+
when :hours then age_seconds / PER_HOUR
|
173
|
+
when :minutes then age_seconds / PER_MINUTE
|
174
|
+
when :weeks then age_seconds / PER_WEEK
|
175
|
+
when :months then age_seconds / PER_MONTH
|
176
|
+
when :years then age_seconds / PER_YEAR
|
177
|
+
else age_seconds
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# Convenience methods for `age_in(unit)` calls.
|
182
|
+
#
|
183
|
+
# @param from_time [Time, nil] Reference time (defaults to Time.now.utc)
|
184
|
+
# @return [Float] Age in days
|
185
|
+
# @example
|
186
|
+
# timestamp.days_old #=> 2.5
|
187
|
+
def days_old(*) = age_in(:days, *)
|
188
|
+
def hours_old(*) = age_in(:hours, *)
|
189
|
+
def minutes_old(*) = age_in(:minutes, *)
|
190
|
+
def weeks_old(*) = age_in(:weeks, *)
|
191
|
+
def months_old(*) = age_in(:months, *)
|
192
|
+
def years_old(*) = age_in(:years, *)
|
193
|
+
|
194
|
+
# Checks if timestamp is older than specified duration in seconds
|
195
|
+
#
|
196
|
+
# @param duration [Numeric] Duration in seconds to compare against
|
197
|
+
# @return [Boolean] true if timestamp is older than duration
|
198
|
+
# @note Both older_than? and newer_than? can return false when timestamp
|
199
|
+
# is within the same second. Use within? to check this case.
|
200
|
+
#
|
201
|
+
# @example
|
202
|
+
# Time.now.older_than?(1.second) #=> false
|
203
|
+
def older_than?(duration)
|
204
|
+
self < (Time.now.utc.to_f - duration)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Checks if timestamp is newer than specified duration in the future
|
208
|
+
#
|
209
|
+
# @example
|
210
|
+
# Time.now.newer_than?(1.second) #=> false
|
211
|
+
def newer_than?(duration)
|
212
|
+
self > (Time.now.utc.to_f + duration)
|
213
|
+
end
|
214
|
+
|
215
|
+
# Checks if timestamp is within specified duration of now (past or future)
|
216
|
+
#
|
217
|
+
# @param duration [Numeric] Duration in seconds to compare against
|
218
|
+
# @return [Boolean] true if timestamp is within duration of now
|
219
|
+
# @example
|
220
|
+
# 30.minutes.ago.to_i.within?(1.hour) #=> true
|
221
|
+
# 30.minutes.from_now.to_i.within?(1.hour) #=> true
|
222
|
+
# 2.hours.ago.to_i.within?(1.hour) #=> false
|
223
|
+
def within?(duration)
|
224
|
+
(self - Time.now.utc.to_f).abs <= duration
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
refine ::String do
|
229
|
+
# Converts string time representation to seconds
|
230
|
+
#
|
231
|
+
# @example
|
232
|
+
# "60m".in_seconds #=> 3600.0
|
233
|
+
# "2.5h".in_seconds #=> 9000.0
|
234
|
+
# "1y".in_seconds #=> 31536000.0
|
235
|
+
#
|
236
|
+
# @return [Float, nil] Time in seconds or nil if invalid
|
237
|
+
def in_seconds
|
238
|
+
q, u = scan(/([\d.]+)([a-zA-Zμs]+)?/).flatten
|
239
|
+
return nil unless q
|
240
|
+
|
241
|
+
q = q.to_f
|
242
|
+
u ||= 's'
|
243
|
+
q.in_seconds(u)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
data/lib/familia/refinements.rb
CHANGED
@@ -1,51 +1,5 @@
|
|
1
1
|
# lib/familia/refinements.rb
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
# Controls whether tracing is enabled via an environment variable
|
7
|
-
FAMILIA_TRACE = ENV.fetch('FAMILIA_TRACE', 'false').downcase
|
8
|
-
|
9
|
-
# LoggerTraceRefinement
|
10
|
-
#
|
11
|
-
# This module adds a 'trace' log level to the Ruby Logger class.
|
12
|
-
# It is enabled when the FAMILIA_TRACE environment variable is set to
|
13
|
-
# '1', 'true', or 'yes' (case-insensitive).
|
14
|
-
#
|
15
|
-
# @example Enabling trace logging
|
16
|
-
# # Set environment variable
|
17
|
-
# ENV['FAMILIA_TRACE'] = 'true'
|
18
|
-
#
|
19
|
-
# # In your Ruby code
|
20
|
-
# require 'logger'
|
21
|
-
# using LoggerTraceRefinement
|
22
|
-
#
|
23
|
-
# logger = Logger.new(STDOUT)
|
24
|
-
# logger.trace("This is a trace message")
|
25
|
-
#
|
26
|
-
module LoggerTraceRefinement
|
27
|
-
unless defined?(ENABLED)
|
28
|
-
# Indicates whether trace logging is enabled
|
29
|
-
ENABLED = %w[1 true yes].include?(FAMILIA_TRACE).freeze
|
30
|
-
# The numeric level for trace logging (same as DEBUG)
|
31
|
-
TRACE = 0
|
32
|
-
end
|
33
|
-
|
34
|
-
refine Logger do
|
35
|
-
##
|
36
|
-
# Logs a message at the TRACE level.
|
37
|
-
#
|
38
|
-
# @param progname [String] The program name to include in the log message
|
39
|
-
# @yield A block that evaluates to the message to log
|
40
|
-
# @return [true] Always returns true
|
41
|
-
#
|
42
|
-
# @example Logging a trace message
|
43
|
-
# logger.trace("MyApp") { "Detailed trace information" }
|
44
|
-
def trace(progname = nil, &block)
|
45
|
-
Thread.current[:severity_letter] = 'T'
|
46
|
-
add(LoggerTraceRefinement::TRACE, nil, progname, &block)
|
47
|
-
ensure
|
48
|
-
Thread.current[:severity_letter] = nil
|
49
|
-
end
|
50
|
-
end
|
51
|
-
end
|
3
|
+
require_relative 'refinements/logger_trace'
|
4
|
+
require_relative 'refinements/snake_case'
|
5
|
+
require_relative 'refinements/time_utils'
|