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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +2 -3
  3. data/CHANGELOG.rst +507 -0
  4. data/CLAUDE.md +1 -1
  5. data/Gemfile +1 -6
  6. data/Gemfile.lock +13 -7
  7. data/changelog.d/README.md +5 -5
  8. data/{setup.cfg → changelog.d/scriv.ini} +1 -1
  9. data/docs/guides/Feature-System-Autoloading.md +228 -0
  10. data/docs/guides/time-utilities.md +221 -0
  11. data/docs/migrating/v2.0.0-pre11.md +14 -16
  12. data/docs/migrating/v2.0.0-pre13.md +329 -0
  13. data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
  14. data/examples/autoloader/mega_customer.rb +17 -0
  15. data/familia.gemspec +1 -0
  16. data/lib/familia/autoloader.rb +53 -0
  17. data/lib/familia/base.rb +5 -0
  18. data/lib/familia/data_type.rb +4 -0
  19. data/lib/familia/encryption/encrypted_data.rb +4 -4
  20. data/lib/familia/encryption/manager.rb +6 -4
  21. data/lib/familia/encryption.rb +1 -1
  22. data/lib/familia/errors.rb +3 -0
  23. data/lib/familia/features/autoloadable.rb +113 -0
  24. data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
  25. data/lib/familia/features/expiration.rb +4 -0
  26. data/lib/familia/features/quantization.rb +5 -0
  27. data/lib/familia/features/safe_dump.rb +7 -0
  28. data/lib/familia/features.rb +20 -16
  29. data/lib/familia/field_type.rb +2 -0
  30. data/lib/familia/horreum/core/serialization.rb +3 -3
  31. data/lib/familia/horreum/subclass/definition.rb +3 -4
  32. data/lib/familia/horreum.rb +2 -0
  33. data/lib/familia/json_serializer.rb +70 -0
  34. data/lib/familia/logging.rb +12 -10
  35. data/lib/familia/refinements/logger_trace.rb +57 -0
  36. data/lib/familia/refinements/snake_case.rb +40 -0
  37. data/lib/familia/refinements/time_utils.rb +248 -0
  38. data/lib/familia/refinements.rb +3 -49
  39. data/lib/familia/utils.rb +2 -0
  40. data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
  41. data/lib/familia/validation.rb +1 -1
  42. data/lib/familia/version.rb +1 -1
  43. data/lib/familia.rb +15 -3
  44. data/try/core/autoloader_try.rb +112 -0
  45. data/try/core/extensions_try.rb +38 -21
  46. data/try/core/familia_extended_try.rb +4 -3
  47. data/try/core/time_utils_try.rb +130 -0
  48. data/try/data_types/datatype_base_try.rb +3 -2
  49. data/try/features/autoloadable/autoloadable_try.rb +61 -0
  50. data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
  51. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
  52. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
  53. data/try/features/feature_improvements_try.rb +2 -1
  54. data/try/features/real_feature_integration_try.rb +1 -1
  55. data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
  56. data/try/helpers/test_helpers.rb +24 -0
  57. data/try/integration/cross_component_try.rb +3 -1
  58. metadata +33 -6
  59. data/CHANGELOG.md +0 -247
  60. data/lib/familia/core_ext.rb +0 -135
  61. 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
 
@@ -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 Autoloader
28
+ # ## Project Organization with Autoloadable
26
29
  #
27
- # For large projects, use {Familia::Features::Autoloader} to automatically load
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::Autoloader
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::Autoloader For automatic feature loading
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
- # Store feature options if any were provided using the new pattern
135
- if options.any?
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
@@ -29,6 +29,8 @@ module Familia
29
29
  class FieldType
30
30
  attr_reader :name, :options, :method_name, :fast_method_name, :on_conflict, :loggable
31
31
 
32
+ using Familia::Refinements::TimeUtils
33
+
32
34
  # Initialize a new field type
33
35
  #
34
36
  # @param name [Symbol] The field name
@@ -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) : JSON.dump(val)
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 = JSON.parse(val, symbolize_names: symbolize)
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 JSON::ParserError
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.split('::').last
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
@@ -31,6 +31,8 @@ module Familia
31
31
  include Familia::Horreum::Core
32
32
  include Familia::Horreum::Settings
33
33
 
34
+ using Familia::Refinements::TimeUtils
35
+
34
36
  # Singleton Class Context
35
37
  #
36
38
  # The code within this block operates on the singleton class (also known as
@@ -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
@@ -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 LoggerTraceRefinement module. The name of 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
- # LoggerTraceRefinement is used.
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 LoggerTraceRefinement module
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 LoggerTraceRefinement
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 LoggerTraceRefinement
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 LoggerTraceRefinement module is defined with a refinement
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 LoggerTraceRefinement
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
- using LoggerTraceRefinement if LoggerTraceRefinement::ENABLED
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 LoggerTraceRefinement::ENABLED is true.
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 LoggerTraceRefinement::ENABLED
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