familia 2.0.0.pre12 → 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 +1 -1
- data/Gemfile +1 -6
- data/Gemfile.lock +13 -7
- data/changelog.d/README.md +5 -5
- data/{setup.cfg → changelog.d/scriv.ini} +1 -1
- data/docs/guides/Feature-System-Autoloading.md +228 -0
- data/docs/guides/time-utilities.md +221 -0
- data/docs/migrating/v2.0.0-pre11.md +14 -16
- data/docs/migrating/v2.0.0-pre13.md +329 -0
- data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
- data/examples/autoloader/mega_customer.rb +17 -0
- data/familia.gemspec +1 -0
- data/lib/familia/autoloader.rb +53 -0
- data/lib/familia/base.rb +5 -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.rb +1 -1
- data/lib/familia/errors.rb +3 -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/quantization.rb +5 -0
- data/lib/familia/features/safe_dump.rb +7 -0
- data/lib/familia/features.rb +20 -16
- data/lib/familia/field_type.rb +2 -0
- data/lib/familia/horreum/core/serialization.rb +3 -3
- data/lib/familia/horreum/subclass/definition.rb +3 -4
- 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/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/version.rb +1 -1
- data/lib/familia.rb +15 -3
- 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/time_utils_try.rb +130 -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/feature_improvements_try.rb +2 -1
- data/try/features/real_feature_integration_try.rb +1 -1
- data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
- data/try/helpers/test_helpers.rb +24 -0
- data/try/integration/cross_component_try.rb +3 -1
- metadata +33 -6
- data/CHANGELOG.md +0 -247
- data/lib/familia/core_ext.rb +0 -135
- data/lib/familia/features/autoloader.rb +0 -57
@@ -245,11 +245,16 @@ module Familia
|
|
245
245
|
# NoDefault.qstamp() # Uses 10.minutes as fallback quantum
|
246
246
|
#
|
247
247
|
module Quantization
|
248
|
+
|
249
|
+
using Familia::Refinements::TimeUtils
|
250
|
+
|
248
251
|
def self.included(base)
|
249
252
|
Familia.trace :LOADED, self, base, caller(1..1) if Familia.debug?
|
250
253
|
base.extend ClassMethods
|
251
254
|
end
|
252
255
|
|
256
|
+
# Familia::Quantization::ClassMethods
|
257
|
+
#
|
253
258
|
module ClassMethods
|
254
259
|
# Generates a quantized timestamp based on the given parameters
|
255
260
|
#
|
@@ -1,5 +1,6 @@
|
|
1
1
|
# lib/familia/features/safe_dump.rb
|
2
2
|
|
3
|
+
|
3
4
|
# rubocop:disable ThreadSafety/ClassInstanceVariable
|
4
5
|
#
|
5
6
|
# Class instance variables are used here for feature configuration
|
@@ -40,10 +41,16 @@ module Familia::Features
|
|
40
41
|
# of symbols in the order they were defined.
|
41
42
|
#
|
42
43
|
module SafeDump
|
44
|
+
include Familia::Features::Autoloadable
|
45
|
+
using Familia::Refinements::SnakeCase
|
46
|
+
|
43
47
|
@dump_method = :to_json
|
44
48
|
@load_method = :from_json
|
45
49
|
|
46
50
|
def self.included(base)
|
51
|
+
# Call the Autoloadable module's included method for post-inclusion setup
|
52
|
+
super
|
53
|
+
|
47
54
|
Familia.trace(:LOADED, self, base, caller(1..1)) if Familia.debug?
|
48
55
|
base.extend ClassMethods
|
49
56
|
|
data/lib/familia/features.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# lib/familia/features.rb
|
2
2
|
|
3
|
+
# Load the Autoloader first, then use it to load all other features
|
4
|
+
require_relative 'autoloader'
|
5
|
+
|
3
6
|
module Familia
|
4
7
|
FeatureDefinition = Data.define(:name, :depends_on)
|
5
8
|
|
@@ -22,9 +25,9 @@ module Familia
|
|
22
25
|
# feature options. When you enable a feature with options in different models,
|
23
26
|
# each model stores its own separate configuration without interference.
|
24
27
|
#
|
25
|
-
# ## Project Organization with
|
28
|
+
# ## Project Organization with Autoloadable
|
26
29
|
#
|
27
|
-
# For large projects, use {Familia::Features::
|
30
|
+
# For large projects, use {Familia::Features::Autoloadable} to automatically load
|
28
31
|
# project-specific features from a dedicated directory structure. This helps
|
29
32
|
# organize complex models by separating features into individual files.
|
30
33
|
#
|
@@ -54,14 +57,16 @@ module Familia
|
|
54
57
|
# # In your model file: app/models/customer.rb
|
55
58
|
# class Customer < Familia::Horreum
|
56
59
|
# module Features
|
57
|
-
# include Familia::Features::
|
60
|
+
# include Familia::Features::Autoloadable
|
58
61
|
# # Automatically loads all .rb files from app/models/customer/features/
|
59
62
|
# end
|
60
63
|
# end
|
61
64
|
#
|
62
|
-
# @see Familia::Features::
|
65
|
+
# @see Familia::Features::Autoloadable For automatic feature loading
|
63
66
|
#
|
64
67
|
module Features
|
68
|
+
include Familia::Autoloader
|
69
|
+
|
65
70
|
@features_enabled = nil
|
66
71
|
attr_reader :features_enabled
|
67
72
|
|
@@ -131,14 +136,23 @@ module Familia
|
|
131
136
|
# Add it to the list available features_enabled for Familia::Base classes.
|
132
137
|
features_enabled << feature_name
|
133
138
|
|
134
|
-
#
|
135
|
-
|
139
|
+
# Always capture and store the calling location for every feature
|
140
|
+
calling_location = caller_locations(1, 1)&.first
|
141
|
+
options[:calling_location] = calling_location&.path
|
142
|
+
|
143
|
+
# Add feature options if the class supports them (Horreum classes)
|
144
|
+
if respond_to?(:add_feature_options)
|
136
145
|
add_feature_options(feature_name, **options)
|
137
146
|
end
|
138
147
|
|
139
148
|
# Extend the Familia::Base subclass (e.g. Customer) with the feature module
|
140
149
|
include feature_class
|
141
150
|
|
151
|
+
# Trigger post-inclusion autoloading for features that support it
|
152
|
+
if feature_class.respond_to?(:post_inclusion_autoload)
|
153
|
+
feature_class.post_inclusion_autoload(self, feature_name, options)
|
154
|
+
end
|
155
|
+
|
142
156
|
# NOTE: Do we want to extend Familia::DataType here? That would make it
|
143
157
|
# possible to call safe_dump on relations fields (e.g. list, zset, hashkey).
|
144
158
|
#
|
@@ -152,13 +166,3 @@ module Familia
|
|
152
166
|
end
|
153
167
|
end
|
154
168
|
end
|
155
|
-
|
156
|
-
# Load all feature files from the features directory
|
157
|
-
features_dir = File.join(__dir__, 'features')
|
158
|
-
Familia.ld "[DEBUG] Loading features from #{features_dir}"
|
159
|
-
if Dir.exist?(features_dir)
|
160
|
-
Dir.glob(File.join(features_dir, '*.rb')).each do |feature_file|
|
161
|
-
Familia.ld "[DEBUG] Loading feature #{feature_file}"
|
162
|
-
require_relative feature_file
|
163
|
-
end
|
164
|
-
end
|
data/lib/familia/field_type.rb
CHANGED
@@ -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
|
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
|