Skip to content
Open
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
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Lint/MissingSuper:
Enabled: false

Metrics/MethodLength:
Max: 15
Max: 20

Metrics/AbcSize:
Enabled: false
Expand Down
10 changes: 7 additions & 3 deletions lib/blueprinter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class << self
# end
#
# @return [Field] A Field object
def identifier(method, name: method, extractor: Blueprinter.configuration.extractor_default.new, &block)
def identifier(method, name: method, extractor: Blueprinter.configuration.default_extractor, &block)
view_collection[:identifier] << Field.new(
method,
name,
Expand Down Expand Up @@ -116,7 +116,7 @@ def field(method, options = {}, &block)
current_view << Field.new(
method,
options.fetch(:name) { method },
options.fetch(:extractor) { Blueprinter.configuration.extractor_default.new },
options.fetch(:extractor) { Blueprinter.configuration.default_extractor },
self,
options.merge(block:)
)
Expand Down Expand Up @@ -159,7 +159,7 @@ def association(method, options = {}, &block)
current_view << Association.new(
method:,
name: options.fetch(:name) { method },
extractor: options.fetch(:extractor) { AssociationExtractor.new },
extractor: options.fetch(:extractor) { association_extractor },
blueprint: options.fetch(:blueprint),
parent_blueprint: self,
view: options.fetch(:view, :default),
Expand Down Expand Up @@ -369,6 +369,10 @@ def current_view
def inherited(subclass)
subclass.send(:view_collection).inherit(view_collection)
end

def association_extractor
@_association_extractor ||= AssociationExtractor.new
end
end
end
end
19 changes: 17 additions & 2 deletions lib/blueprinter/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@ class Configuration
:datetime_format,
:default_transformers,
:deprecations,
:extractor_default,
:field_default,
:generator,
:if,
:method,
:sort_fields_by,
:unless
)
attr_reader :extensions
attr_reader :extensions, :extractor_default

VALID_CALLABLES = %i[if unless].freeze

Expand Down Expand Up @@ -59,5 +58,21 @@ def jsonify(blob)
def valid_callable?(callable_name)
VALID_CALLABLES.include?(callable_name)
end

# @param extractor [Class<Blueprinter::AutoExtractor>]
def extractor_default=(extractor)
reset_default_extractor!

@extractor_default = extractor
end

# @return [Blueprinter::AutoExtractor]
def default_extractor
@_default_extractor ||= extractor_default.new
end

def reset_default_extractor!
@_default_extractor = nil
end
end
end
9 changes: 7 additions & 2 deletions lib/blueprinter/extractors/association_extractor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ class AssociationExtractor < Extractor
include EmptyTypes

def initialize
@extractor = Blueprinter.configuration.extractor_default.new
@extractor = Blueprinter.configuration.default_extractor
end

def extract(association_name, object, local_options, options = {})
options_without_default = options.except(:default, :default_if)
options_without_default = if options.key?(:default) || options.key?(:default_if)
options.except(:default, :default_if)
else
options
end

# Merge in assocation options hash
local_options = local_options.merge(options[:options]) if options[:options].is_a?(Hash)
value = @extractor.extract(association_name, object, local_options, options_without_default)
Expand Down
2 changes: 1 addition & 1 deletion lib/blueprinter/formatters/date_time_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Blueprinter
class DateTimeFormatter
InvalidDateTimeFormatterError = Class.new(BlueprinterError)
class InvalidDateTimeFormatterError < BlueprinterError; end

def format(value, options)
return value if value.nil?
Expand Down
32 changes: 20 additions & 12 deletions lib/blueprinter/rendering.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ module Rendering
#
# @return [String] JSON formatted String
def render(object, options = {})
jsonify(build_result(object: object, options: options))
jsonify(build_result(object:, options:))
end

# Generates a Hash representation of the provided object.
Expand All @@ -55,7 +55,7 @@ def render(object, options = {})
#
# @return [Hash]
def render_as_hash(object, options = {})
build_result(object: object, options: options)
build_result(object:, options:)
end

# Generates a JSONified hash.
Expand All @@ -80,7 +80,7 @@ def render_as_hash(object, options = {})
#
# @return [Hash]
def render_as_json(object, options = {})
build_result(object: object, options: options).as_json
build_result(object:, options:).as_json
end

# Converts an object into a Hash representation based on provided view.
Expand Down Expand Up @@ -122,35 +122,43 @@ def prepare(object, view_name:, local_options:)
attr_reader :blueprint, :options

def prepare_data(object, view_name, local_options)
# Since we're currently providing the current view in the local_options hash when we extract fields, we can merge
# it in ahead of time to avoid allocating a new hash for every field extraction.
local_options_with_view = local_options.merge(view: view_name)

if array_like?(object)
object.map do |obj|
object_to_hash(
obj,
view_name: view_name,
local_options: local_options
view_name:,
local_options: local_options_with_view
)
end
else
object_to_hash(
object,
view_name: view_name,
local_options: local_options
view_name:,
local_options: local_options_with_view
)
end
end

def object_to_hash(object, view_name:, local_options:)
result_hash = view_collection.fields_for(view_name).each_with_object({}) do |field, hash|
result_hash = {}

view_collection.fields_for(view_name).each do |field|
next if field.skip?(field.name, object, local_options)

value = field.extract(object, local_options.merge(view: view_name))
value = field.extract(object, local_options)
next if value.nil? && field.options[:exclude_if_nil]

hash[field.name] = value
result_hash[field.name] = value
end

view_collection.transformers(view_name).each do |transformer|
transformer.transform(result_hash, object, local_options)
end

result_hash
end

Expand All @@ -173,11 +181,11 @@ def add_metadata(object:, metadata:, root:)
end

def build_result(object:, options:)
view_name = options.fetch(:view, :default) || :default
view_name = options[:view] || :default

prepared_object = hashify(
object,
view_name: view_name,
view_name:,
local_options: options.except(:view, :root, :meta)
)
object_with_root = apply_root_key(
Expand Down
88 changes: 77 additions & 11 deletions lib/blueprinter/view_collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

module Blueprinter
# @api private
#
# ViewCollection manages the views defined in a Blueprint, along with their fields and transformers.
#
# To improve performance, the "structure" of each view is cached. Once the first view is accessed, we prewarm the
# cache for _all_ views (while this isn't the _most_ optimal approach, the overhead is negligible, and allows the
# caching logic to be quite simple).
#
# To ensure thread safety, we build out the cache within a double-checked mutex.
#
# rubocop:disable Metrics/ClassLength
class ViewCollection
attr_reader :views, :sort_by_definition

Expand All @@ -13,45 +23,100 @@ def initialize
default: View.new(:default)
}
@sort_by_definition = Blueprinter.configuration.sort_fields_by.eql?(:definition)
@cache_mutex = Mutex.new
@finalized = false
end

def inherit(view_collection)
view_collection.views.each do |view_name, view|
self[view_name].inherit(view)
end

# Reset finalization just in case since the structure of the views has changed.
@finalized = false
end

# @param [String] view_name
# @return [Boolean] true if the view exists, false otherwise
def view?(view_name)
views.key? view_name
end

# Returns an array of Field objects for the provided View.
# @param [String] view_name
# @return [Array<Field>]
def fields_for(view_name)
return identifier_fields if view_name == :identifier
ensure_finalized!

fields, excluded_fields = sortable_fields(view_name)
sorted_fields = sort_by_definition ? sort_by_def(view_name, fields) : fields.values.sort_by(&:name)

(identifier_fields + sorted_fields).tap do |fields_array|
fields_array.reject! { |field| excluded_fields.include?(field.name) }
end
@cached_fields_for[view_name]
end

# Returns an array of Transformer objects for the provided View.
# @param [String] view_name
# @return [Array<Transformer>]
def transformers(view_name)
included_transformers = gather_transformers_from_included_views(view_name).reverse
all_transformers = [*views[:default].view_transformers, *included_transformers].uniq
all_transformers.empty? ? Blueprinter.configuration.default_transformers : all_transformers
ensure_finalized!

@cached_transformers[view_name]
end

# @param [String] view_name
# @return [View]
def [](view_name)
@views[view_name] ||= View.new(view_name)
return @views[view_name] if @views.key?(view_name)

@cache_mutex.synchronize do
unless @views.key?(view_name)
@views[view_name] = View.new(view_name)
@finalized = false
end
@views[view_name]
end
end

private

attr_reader :cache_mutex

def identifier_fields
views[:identifier].fields.values
end

def ensure_finalized!
return if @finalized

@cache_mutex.synchronize do
return if @finalized

@cached_fields_for = {}
@cached_transformers = {}

views.each_key do |view_name|
@cached_fields_for[view_name] = build_fields_for(view_name).freeze
@cached_transformers[view_name] = build_transformers(view_name).freeze
end

@finalized = true
end
end

def build_fields_for(view_name)
return identifier_fields if view_name == :identifier

fields, excluded_fields = sortable_fields(view_name)
sorted_fields = sort_by_definition ? sort_by_def(view_name, fields) : fields.values.sort_by(&:name)

(identifier_fields + sorted_fields).tap do |fields_array|
fields_array.reject! { |field| excluded_fields.include?(field.name) }
end
end

def build_transformers(view_name)
included_transformers = gather_transformers_from_included_views(view_name).reverse
all_transformers = [*views[:default].view_transformers, *included_transformers].uniq
all_transformers.empty? ? Blueprinter.configuration.default_transformers : all_transformers
end

# @param [String] view_name
# @return [Array<(Hash, Hash<String, NilClass>)>] fields, excluded_fields
def sortable_fields(view_name)
Expand Down Expand Up @@ -104,4 +169,5 @@ def gather_transformers_from_included_views(view_name)
[*already_included_transformers, *current_view.view_transformers].uniq
end
end
# rubocop:enable Metrics/ClassLength
end
31 changes: 19 additions & 12 deletions spec/units/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require 'oj'
require 'yajl'

describe 'Blueprinter' do
describe 'Blueprinter::Configuration' do
describe '#configure' do
before { Blueprinter.configure { |config| config.generator = JSON } }
after { reset_blueprinter_config! }
Expand Down Expand Up @@ -88,19 +88,26 @@ class UpcaseTransform < Blueprinter::Transformer; end
end
end

describe "::Configuration" do
describe '#valid_callable?' do
it 'should return true for valid callables' do
[:if, :unless].each do |option|
actual = Blueprinter.configuration.valid_callable?(option)
expect(actual).to be(true)
end
end
describe '#default_extractor' do
it 'returns an instance of the default extractor' do
expect(Blueprinter.configuration.default_extractor).to be_an_instance_of(Blueprinter::AutoExtractor)
end

it 'should return false for invalid callable' do
actual = Blueprinter.configuration.valid_callable?(:invalid_option)
expect(actual).to be(false)
it 'does not create a new instance on subsequent calls' do
expect(Blueprinter.configuration.default_extractor).to eq(Blueprinter.configuration.default_extractor)
end
end
describe '#valid_callable?' do
it 'should return true for valid callables' do
[:if, :unless].each do |option|
actual = Blueprinter.configuration.valid_callable?(option)
expect(actual).to be(true)
end
end

it 'should return false for invalid callable' do
actual = Blueprinter.configuration.valid_callable?(:invalid_option)
expect(actual).to be(false)
end
end
end
Loading