# frozen_string_literal: true require 'ostruct' require 'chronic_duration' module Chronicle module ETL # A mixin that gives a class # a {Chronicle::ETL::Configurable::ClassMethods#setting} macro to define # settings and their properties (require, type, etc) # # @example Basic usage # class Test < Chronicle::ETL::Extractor # include Chronicle::ETL::Configurable # setting :when, type: :date, required: true # end # # t = Test.new(when: '2022-02-24') # t.config.when module Configurable # An individual setting for this Configurable Setting = Struct.new(:default, :required, :type, :description) private_constant :Setting # Collection of user-supplied options for this Configurable class Config < OpenStruct # Config values that aren't nil, as a hash def compacted_h to_h.compact end end # @private def self.included(klass) klass.extend(ClassMethods) klass.include(InstanceMethods) klass.prepend(Initializer) end # Initializer method for classes that have Configurable mixed in module Initializer # Make sure this class has a default @config ready to use def initialize(*args) @config = initialize_default_config super end end # Instance methods for classes that have Configurable mixed in module InstanceMethods attr_reader :config # Take given options and apply them to this class's settings # and make them available in @config and validates that they # conform to setting rules def apply_options(options) options.transform_keys!(&:to_sym) options.each do |name, value| setting = self.class.all_settings[name] # Do nothing with a given option if it's not a connector setting next unless setting @config[name] = coerced_value(setting, name, value) end validate_config options end # Name of all settings available to this class def self.settings self.class.all_settings.keys end private def initialize_default_config self.class.config_with_defaults end def validate_config missing = (self.class.all_required_settings.keys - @config.compacted_h.keys) raise Chronicle::ETL::ConnectorConfigurationError, "Missing options: #{missing}" if missing.count.positive? end def coerced_value(setting, name, value) setting.type ? __send__("coerce_#{setting.type}", value) : value rescue StandardError raise( Chronicle::ETL::ConnectorConfigurationError, "Could not convert value '#{value}' into a #{setting.type} for setting '#{name}'" ) end def coerce_hash(value) value.is_a?(Hash) ? value : {} end def coerce_string(value) value.to_s end # TODO: think about whether to split up float, integer def coerce_numeric(value) value.to_f end def coerce_boolean(value) if value.is_a?(String) value.downcase == 'true' else value end end def coerce_array(value) value.is_a?(Array) ? value : [value] end def coerce_time(value) # parsing yml files might result in us getting Date objects # we convert to DateTime first to to ensure UTC return value.to_datetime.to_time if value.is_a?(Date) return value unless value.is_a?(String) # Hacky check for duration strings like "60m" if value.match(/[a-z]+/) ChronicDuration.raise_exceptions = true duration_ago = ChronicDuration.parse(value) Time.now - duration_ago else Time.parse(value) end end end # Class methods for classes that have Configurable mixed in module ClassMethods # Macro for creating a setting on a class {::Chronicle::ETL::Configurable} # # @param [String] name Name of the setting # @param [Boolean] required whether setting is required # @param [Object] default Default value # @param [Symbol] type Type # # @example Basic usage # setting :when, type: :date, required: true # # @see ::Chronicle::ETL::Configurable def setting(name, default: nil, required: false, type: nil, description: nil) s = Setting.new(default, required, type, description) settings[name] = s end # Collect all settings defined on this class and its ancestors (that # have Configurable mixin included) def all_settings if superclass.include?(Chronicle::ETL::Configurable) superclass.all_settings.merge(settings) else settings end end # Filters settings to those that are required. def all_required_settings all_settings.select { |_name, setting| setting.required } || {} end def settings @settings ||= {} end def setting_exists?(name) all_settings.keys.include? name end def config_with_defaults s = all_settings.transform_values(&:default) Config.new(s) end end end end end