Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow attribute options to accept callable values #132

Merged
merged 2 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## [Unreleased]

- Allow to provide callable/lambdas objects with 1-2 args as attribute
:value object
- Allow to provide callable/lambdas with 1 arg as formatters
- Allow to provide callable/lambdas with 0-2 args as :if plugin options
- Allow to provide callable/lambdas with 1-3 args as :batch plugin options
- Attribute :value option now require to have at least 1 argument
- Attribute batch :loader option now requires to have at least 1 argument
- Attribute :format option and associated configurated formatter
(from formatters plugin) must have 1 argument

```ruby
# Attributes with callable classes
attribute :email, value: EmailFetcher
attribute :email, if: EmailPolicy
attribute :email, batch: {key: EmailKeyFetcher, loader: EmailBatchLoader }
attribute :email, format: EmaileFormatter
```

## [0.16.0] - 2023-10-15

- Add :depth_limit plugin that helps to secure from malicious queries that
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ class UserSerializer < Serega
# Block is used to define attribute value
attribute(:first_name) { |user| user.profile&.first_name }

# Option :value can be used with callable object to define attribute value
# Option :value can be used with proc or callable object to define attribute value
attribute :first_name, value: UserProfile.new # must have #call method
attribute :first_name, value: proc { |user| user.profile&.first_name }

# Option :delegate can be used to define attribute value.
Expand Down Expand Up @@ -555,7 +556,7 @@ attribute :name, batch: { loader: :name_loader, key: :id, default: nil }
`:batch` option must be a hash with this keys:

- `loader` (required) [Symbol, Proc, callable] - Defines how to fetch values for
batch of keys. Receives 3 parameters: keys, context, plan_point.
batch of keys. Receives 3 parameters: keys, context, plan.
- `key` (required) [Symbol, Proc, callable] - Defines current object identifier.
Key is optional if plugin was defined with `default_key` option.
- `default` (optional) - Default value for attribute.
Expand Down
2 changes: 2 additions & 0 deletions lib/serega.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ class Serega
require_relative "serega/helpers/serializer_class_helper"
require_relative "serega/utils/enum_deep_dup"
require_relative "serega/utils/enum_deep_freeze"
require_relative "serega/utils/params_count"
require_relative "serega/utils/symbol_name"
require_relative "serega/utils/to_hash"
require_relative "serega/json/adapter"

require_relative "serega/attribute"
require_relative "serega/attribute_normalizer"
require_relative "serega/validations/utils/check_allowed_keys"
require_relative "serega/validations/utils/check_extra_keyword_arg"
require_relative "serega/validations/utils/check_opt_is_bool"
require_relative "serega/validations/utils/check_opt_is_hash"
require_relative "serega/validations/utils/check_opt_is_string_or_symbol"
Expand Down
20 changes: 18 additions & 2 deletions lib/serega/attribute_normalizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ def prepare_name
# - plugin :formatters (wraps resulted block in formatter block and formats :const values)
#
def prepare_value_block
init_block ||
init_opts[:value] ||
prepare_init_block ||
prepare_value_option_block ||
prepare_const_block ||
prepare_delegate_block ||
prepare_keyword_block
Expand Down Expand Up @@ -139,6 +139,22 @@ def prepare_keyword_block
end
end

def prepare_init_block
return unless init_block

params_count = SeregaUtils::ParamsCount.call(init_block, max_count: 2)
(params_count == 1) ? proc { |obj, _ctx| init_block.call(obj) } : init_block
end

def prepare_value_option_block
init_value = init_opts[:value]
return unless init_value

# We checked in advance in CheckOptValue that we have 1 or 2 parameters
params_count = SeregaUtils::ParamsCount.call(init_value, max_count: 2)
(params_count == 1) ? proc { |obj, _ctx| init_value.call(obj) } : init_value
end

def prepare_delegate_block
delegate = init_opts[:delegate]
return unless delegate
Expand Down
19 changes: 11 additions & 8 deletions lib/serega/plugins/batch/lib/batch_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,20 @@ def initialize(opts)
#
# @return [void]
#
def define(loader_name, &block)
unless block
raise SeregaError, "Block must be given to #define method"
def define(loader_name, callable = nil, &block)
if (!callable && !block) || (callable && block)
raise SeregaError, "Batch loader can be specified with one of arguments - callable value or &block"
end

params = block.parameters
if params.count > 3 || !params.all? { |param| (param[0] == :req) || (param[0] == :opt) }
raise SeregaError, "Block can have maximum 3 regular parameters"
callable ||= block
SeregaValidations::Utils::CheckExtraKeywordArg.call(loader_name, callable)
params_count = SeregaUtils::ParamsCount.call(callable, max_count: 3)

if params_count != 1 && params_count != 2 && params_count != 3
raise SeregaError, "Batch loader should have 1 to 3 parameters (keys, context, plan)"
end

loaders[loader_name] = block
loaders[loader_name] = callable
end

# Shows defined loaders
Expand All @@ -48,7 +51,7 @@ def loaders
#
# @return [Proc] batch loader block
def fetch_loader(loader_name)
loaders[loader_name] || (raise SeregaError, "Batch loader with name `#{loader_name.inspect}` was not defined. Define example: config.batch.define(:#{loader_name}) { |keys, ctx, points| ... }")
loaders[loader_name] || (raise SeregaError, "Batch loader with name `#{loader_name.inspect}` was not defined. Define example: config.batch.define(:#{loader_name}) { |keys| ... }")
end

# Shows option to auto hide attributes with :batch specified
Expand Down
32 changes: 21 additions & 11 deletions lib/serega/plugins/batch/lib/modules/attribute_normalizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,32 @@ def prepare_batch
batch = init_opts[:batch]
return unless batch

# take loader
loader = batch[:loader]
loader = prepare_batch_loader(batch[:loader])

# take key
key = batch[:key] || self.class.serializer_class.config.batch.default_key
proc_key =
if key.is_a?(Symbol)
proc { |object| object.public_send(key) }
else
key
end
key = prepare_batch_key(key)

# take default value
default = batch.fetch(:default) { many ? FROZEN_EMPTY_ARRAY : nil }

{loader: loader, key: proc_key, default: default}
{loader: loader, key: key, default: default}
end

def prepare_batch_key(key)
return proc { |object| object.public_send(key) } if key.is_a?(Symbol)

params_count = SeregaUtils::ParamsCount.call(key, max_count: 2)
(params_count == 1) ? proc { |obj, _ctx| key.call(obj) } : key
end

def prepare_batch_loader(loader)
return loader if loader.is_a?(Symbol)

params_count = SeregaUtils::ParamsCount.call(loader, max_count: 3)
case params_count
when 1 then proc { |obj, _ctx, _plan| loader.call(obj) }
when 2 then proc { |obj, ctx, _plan| loader.call(obj, ctx) }
else loader
end
end
end
end
Expand Down
40 changes: 5 additions & 35 deletions lib/serega/plugins/batch/lib/validations/check_batch_opt_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,15 @@ def call(key)

raise SeregaError, must_be_callable unless key.respond_to?(:call)

if key.is_a?(Proc)
check_block(key)
else
check_callable(key)
end
SeregaValidations::Utils::CheckExtraKeywordArg.call(:key, key)
params_count = SeregaUtils::ParamsCount.call(key, max_count: 2)
raise SeregaError, params_count_error if (params_count != 1) && (params_count != 2)
end

private

def check_block(block)
return if valid_parameters?(block, accepted_count: 0..2)

raise SeregaError, block_parameters_error
end

def check_callable(callable)
return if valid_parameters?(callable.method(:call), accepted_count: 2..2)

raise SeregaError, callable_parameters_error
end

def valid_parameters?(data, accepted_count:)
params = data.parameters
accepted_count.include?(params.count) && valid_parameters_types?(params)
end

def valid_parameters_types?(params)
params.all? do |param|
type = param[0]
(type == :req) || (type == :opt)
end
end

def block_parameters_error
"Invalid :batch option :key. When it is a Proc it can have maximum two regular parameters (object, context)"
end

def callable_parameters_error
"Invalid :batch option :key. When it is a callable object it must have two regular parameters (object, context)"
def params_count_error
"Invalid :batch option :key. When it is a callable object it must have 1 or 2 parameters (object, context)"
end

def must_be_callable
Expand Down
40 changes: 5 additions & 35 deletions lib/serega/plugins/batch/lib/validations/check_batch_opt_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,15 @@ def call(loader)

raise SeregaError, must_be_callable unless loader.respond_to?(:call)

if loader.is_a?(Proc)
check_block(loader)
else
check_callable(loader)
end
SeregaValidations::Utils::CheckExtraKeywordArg.call(:loader, loader)
params_count = SeregaUtils::ParamsCount.call(loader, max_count: 3)
raise SeregaError, params_count_error if (params_count != 1) && (params_count != 2) && (params_count != 3)
end

private

def check_block(block)
return if valid_parameters?(block, accepted_count: 0..3)

raise SeregaError, block_parameters_error
end

def check_callable(callable)
return if valid_parameters?(callable.method(:call), accepted_count: 3..3)

raise SeregaError, callable_parameters_error
end

def valid_parameters?(data, accepted_count:)
params = data.parameters
accepted_count.include?(params.count) && valid_parameters_types?(params)
end

def valid_parameters_types?(params)
params.all? do |param|
type = param[0]
(type == :req) || (type == :opt)
end
end

def block_parameters_error
"Invalid :batch option :loader. When it is a Proc it can have maximum three regular parameters (keys, context, point)"
end

def callable_parameters_error
"Invalid :batch option :loader. When it is a callable object it must have three regular parameters (keys, context, point)"
def params_count_error
"Invalid :batch option :loader. It must accept 1, 2 or 3 parameters (keys, context, plan)"
end

def must_be_callable
Expand Down
79 changes: 79 additions & 0 deletions lib/serega/plugins/formatters/formatters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def self.before_load_plugin(serializer_class, **opts)
def self.load_plugin(serializer_class, **_opts)
serializer_class::SeregaConfig.include(ConfigInstanceMethods)
serializer_class::SeregaAttributeNormalizer.include(AttributeNormalizerInstanceMethods)
serializer_class::CheckAttributeParams.include(CheckAttributeParamsInstanceMethods)
end

#
Expand Down Expand Up @@ -107,6 +108,7 @@ def initialize(opts)
# @return [void]
def add(formatters)
formatters.each_pair do |key, value|
CheckFormatter.call(key, value)
opts[key] = value
end
end
Expand All @@ -124,6 +126,21 @@ def formatters
end
end

#
# Serega::SeregaValidations::CheckAttributeParams additional/patched class methods
#
# @see Serega::SeregaValidations::CheckAttributeParams
#
module CheckAttributeParamsInstanceMethods
private

def check_opts
super

CheckOptFormat.call(opts, self.class.serializer_class)
end
end

#
# Attribute class additional/patched instance methods
#
Expand Down Expand Up @@ -167,6 +184,68 @@ def prepare_formatter
end
end
end

#
# Validator for attribute :format option
#
class CheckOptFormat
class << self
#
# Checks attribute :format option must be registered or valid callable with 1 arg
#
# @param opts [value] Attribute options
#
# @raise [SeregaError] Attribute validation error
#
# @return [void]
#
def call(opts, serializer_class)
return unless opts.key?(:format)

formatter = opts[:format]

if formatter.is_a?(Symbol)
check_formatter_defined(serializer_class, formatter)
else
CheckFormatter.call(:format, formatter)
end
end

private

def check_formatter_defined(serializer_class, formatter)
return if serializer_class.config.formatters.opts.key?(formatter)

raise Serega::SeregaError, "Formatter `#{formatter.inspect}` was not defined"
end
end
end

#
# Validator for formatters defined as config options or directly as attribute :format option
#
class CheckFormatter
class << self
#
# Check formatter type and parameters
#
# @param formatter_name [Symbol] Name of formatter
# @param formatter [#call] Formatter callable object
#
# @return [void]
#
def call(formatter_name, formatter)
raise Serega::SeregaError, "Option #{formatter_name.inspect} must have callable value" unless formatter.respond_to?(:call)

SeregaValidations::Utils::CheckExtraKeywordArg.call(formatter_name, formatter)
params_count = SeregaUtils::ParamsCount.call(formatter, max_count: 1)

if params_count != 1
raise SeregaError, "Formatter should have exactly 1 required parameter (value to format)"
end
end
end
end
end

register_plugin(Formatters.plugin_name, Formatters)
Expand Down
Loading