Skip to content
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
7 changes: 7 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ Rake::TestTask.new do |t|
end

task default: :test

desc 'Run benchmarks'
namespace :test do
Rake::TestTask.new(:benchmark) do |t|
t.pattern = 'test/benchmark/*_benchmark.rb'
end
end
2 changes: 2 additions & 0 deletions jsonapi-resources.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'minitest-spec-rails'
spec.add_development_dependency 'simplecov'
spec.add_development_dependency 'pry'
spec.add_development_dependency 'concurrent-ruby-ext'
spec.add_dependency 'rails', '>= 4.0'
spec.add_dependency 'concurrent-ruby'
end
1 change: 1 addition & 0 deletions lib/jsonapi-resources.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'jsonapi/naive_cache'
require 'jsonapi/resource'
require 'jsonapi/response_document'
require 'jsonapi/acts_as_resource_controller'
Expand Down
2 changes: 1 addition & 1 deletion lib/jsonapi/acts_as_resource_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def serialization_options
# JSONAPI.configuration.route = :camelized_route
#
# Override if you want to set a per controller key format.
# Must return a class derived from KeyFormatter.
# Must return an instance of a class derived from KeyFormatter.
def key_formatter
JSONAPI.configuration.key_formatter
end
Expand Down
63 changes: 56 additions & 7 deletions lib/jsonapi/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
require 'jsonapi/formatter'
require 'jsonapi/operations_processor'
require 'jsonapi/active_record_operations_processor'
require 'concurrent'

module JSONAPI
class Configuration
attr_reader :json_key_format,
:resource_key_type,
:key_formatter,
:route_format,
:route_formatter,
:raise_if_parameters_not_allowed,
:operations_processor,
:allow_include,
Expand All @@ -23,7 +22,8 @@ class Configuration
:top_level_meta_record_count_key,
:exception_class_whitelist,
:always_include_to_one_linkage_data,
:always_include_to_many_linkage_data
:always_include_to_many_linkage_data,
:cache_formatters

def initialize
#:underscored_key, :camelized_key, :dasherized_key, or custom
Expand Down Expand Up @@ -74,20 +74,69 @@ def initialize
# NOTE: always_include_to_many_linkage_data is not currently implemented
self.always_include_to_one_linkage_data = false
self.always_include_to_many_linkage_data = false

# Formatter Caching
# Set to false to disable caching of string operations on keys and links.
self.cache_formatters = true
end

def cache_formatters=(bool)
@cache_formatters = bool
if bool
@key_formatter_tlv = Concurrent::ThreadLocalVar.new
@route_formatter_tlv = Concurrent::ThreadLocalVar.new
else
@key_formatter_tlv = nil
@route_formatter_tlv = nil
end
end

def json_key_format=(format)
@json_key_format = format
@key_formatter = JSONAPI::Formatter.formatter_for(format)
if @cache_formatters
@key_formatter_tlv = Concurrent::ThreadLocalVar.new
end
end

def route_format=(format)
@route_format = format
if @cache_formatters
@route_formatter_tlv = Concurrent::ThreadLocalVar.new
end
end

def key_formatter
if self.cache_formatters
formatter = @key_formatter_tlv.value
return formatter if formatter
end

formatter = JSONAPI::Formatter.formatter_for(self.json_key_format)

if self.cache_formatters
formatter = @key_formatter_tlv.value = formatter.cached
end

return formatter
end

def resource_key_type=(key_type)
@resource_key_type = key_type
end

def route_format=(format)
@route_format = format
@route_formatter = JSONAPI::Formatter.formatter_for(format)
def route_formatter
if self.cache_formatters
formatter = @route_formatter_tlv.value
return formatter if formatter
end

formatter = JSONAPI::Formatter.formatter_for(self.route_format)

if self.cache_formatters
formatter = @route_formatter_tlv.value = formatter.cached
end

return formatter
end

def operations_processor=(operations_processor)
Expand Down
33 changes: 29 additions & 4 deletions lib/jsonapi/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ def unformat(arg)
arg
end

def cached
return FormatterWrapperCache.new(self)
end

def formatter_for(format)
formatter_class_name = "#{format.to_s.camelize}Formatter"
formatter_class_name.safe_constantize
"#{format.to_s.camelize}Formatter".safe_constantize
end
end
end
Expand Down Expand Up @@ -51,11 +54,33 @@ def unformat(value)
end

def value_formatter_for(type)
formatter_name = "#{type.to_s.camelize}Value"
formatter_for(formatter_name)
"#{type.to_s.camelize}ValueFormatter".safe_constantize
end
end
end

# Warning: Not thread-safe. Wrap in ThreadLocalVar as needed.
class FormatterWrapperCache
attr_reader :formatter_klass

def initialize(formatter_klass)
@formatter_klass = formatter_klass
@format_cache = NaiveCache.new{|arg| formatter_klass.format(arg) }
@unformat_cache = NaiveCache.new{|arg| formatter_klass.unformat(arg) }
end

def format(arg)
@format_cache.get(arg)
end

def unformat(arg)
@unformat_cache.get(arg)
end

def cached
self
end
end
end

class UnderscoredKeyFormatter < JSONAPI::KeyFormatter
Expand Down
34 changes: 17 additions & 17 deletions lib/jsonapi/link_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ module JSONAPI
class LinkBuilder
attr_reader :base_url,
:primary_resource_klass,
:route_formatter
:route_formatter,
:engine_name

def initialize(config = {})
@base_url = config[:base_url]
@primary_resource_klass = config[:primary_resource_klass]
@route_formatter = config[:route_formatter]
@is_engine = !!engine_name
end
@engine_name = build_engine_name

def engine?
@is_engine
# Warning: These make LinkBuilder non-thread-safe. That's not a problem with the
# request-specific way it's currently used, though.
@resources_path_cache = JSONAPI::NaiveCache.new do |source_klass|
formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s)
end
end

def engine_name
@engine_name ||= build_engine_name
def engine?
!!@engine_name
end

def primary_resources_url
Expand Down Expand Up @@ -96,7 +99,7 @@ def engine_resources_path_name_from_class(klass)
end

def format_route(route)
route_formatter.format(route.to_s)
route_formatter.format(route)
end

def formatted_module_path_from_class(klass)
Expand All @@ -113,23 +116,20 @@ def module_scopes_from_class(klass)
klass.name.to_s.split("::")[0...-1]
end

def regular_resources_path(source_klass)
@resources_path_cache.get(source_klass)
end

def regular_primary_resources_path
[
formatted_module_path_from_class(primary_resource_klass),
format_route(primary_resource_klass._type.to_s),
].join
regular_resources_path(primary_resource_klass)
end

def regular_primary_resources_url
"#{ base_url }#{ regular_primary_resources_path }"
end

def regular_resource_path(source)
[
formatted_module_path_from_class(source.class),
format_route(source.class._type.to_s),
"/#{ source.id }",
].join
"#{regular_resources_path(source.class)}/#{source.id}"
end

def regular_resource_url(source)
Expand Down
30 changes: 30 additions & 0 deletions lib/jsonapi/naive_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module JSONAPI

# Cache which memoizes the given block.
#
# It's "naive" because it clears the least-recently-inserted cache entry
# rather than the least-recently-used. This makes lookups faster but cache
# misses more frequent after cleanups. Therefore you the best time to use
# this cache is when you expect only a small number of unique lookup keys, so
# that the cache never has to clear.
#
# Also, it's not thread safe (although jsonapi-resources is careful to only
# use it in a thread safe way).
class NaiveCache
def initialize(cap = 10000, &calculator)
@cap = cap
@data = {}
@calculator = calculator
end

def get(key)
found = true
value = @data.fetch(key) { found = false }
return value if found
value = @calculator.call(key)
@data[key] = value
@data.shift if @data.length > @cap
return value
end
end
end
2 changes: 1 addition & 1 deletion lib/jsonapi/relationship.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def primary_key
end

def resource_klass
@resource_klass = @parent_resource.resource_for(@class_name)
@resource_klass ||= @parent_resource.resource_for(@class_name)
end

def table_name
Expand Down
6 changes: 4 additions & 2 deletions lib/jsonapi/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -601,8 +601,10 @@ def find(filters, options = {})
records = apply_pagination(records, options[:paginator], order_options)

resources = []
resource_classes = {}
records.each do |model|
resources.push self.resource_for_model(model).new(model, context)
resource_class = resource_classes[model.class] ||= self.resource_for_model(model)
resources.push resource_class.new(model, context)
end

resources
Expand Down Expand Up @@ -665,7 +667,7 @@ def key_type(key_type)
end

def resource_key_type
@_resource_key_type || JSONAPI.configuration.resource_key_type
@_resource_key_type ||= JSONAPI.configuration.resource_key_type
end

def verify_key(key, context = nil)
Expand Down
26 changes: 19 additions & 7 deletions lib/jsonapi/resource_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ResourceSerializer
# Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and
# relationship ids in the links section for a resource. Fields are global for a resource type.
# Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]}
# key_formatter: KeyFormatter class to override the default configuration
# key_formatter: KeyFormatter instance to override the default configuration
# serializer_options: additional options that will be passed to resource meta and links lambdas

def initialize(primary_resource_klass, options = {})
Expand All @@ -21,12 +21,17 @@ def initialize(primary_resource_klass, options = {})
@include = options.fetch(:include, [])
@include_directives = options[:include_directives]
@key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter)
@id_formatter = ValueFormatter.value_formatter_for(:id)
@link_builder = generate_link_builder(primary_resource_klass, options)
@always_include_to_one_linkage_data = options.fetch(:always_include_to_one_linkage_data,
JSONAPI.configuration.always_include_to_one_linkage_data)
@always_include_to_many_linkage_data = options.fetch(:always_include_to_many_linkage_data,
JSONAPI.configuration.always_include_to_many_linkage_data)
@serialization_options = options.fetch(:serialization_options, {})

# Warning: This makes ResourceSerializer non-thread-safe. That's not a problem with the
# request-specific way it's currently used, though.
@value_formatter_type_cache = NaiveCache.new{|arg| ValueFormatter.value_formatter_for(arg) }
end

# Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure
Expand Down Expand Up @@ -81,8 +86,7 @@ def format_key(key)
end

def format_value(value, format)
value_formatter = JSONAPI::ValueFormatter.value_formatter_for(format)
value_formatter.format(value)
@value_formatter_type_cache.get(format).format(value)
end

private
Expand Down Expand Up @@ -300,18 +304,26 @@ def link_object(source, relationship, include_linkage = false)
def foreign_key_value(source, relationship)
foreign_key = relationship.foreign_key
value = source.public_send(foreign_key)
IdValueFormatter.format(value)
@id_formatter.format(value)
end

def foreign_key_types_and_values(source, relationship)
if relationship.is_a?(JSONAPI::Relationship::ToMany)
if relationship.polymorphic?
source._model.public_send(relationship.name).pluck(:type, :id).map do |type, id|
[type.underscore.pluralize, IdValueFormatter.format(id)]
assoc = source._model.public_send(relationship.name)
# Avoid hitting the database again for values already pre-loaded
if assoc.respond_to?(:loaded?) and assoc.loaded?
assoc.map do |obj|
[obj.type.underscore.pluralize, @id_formatter.format(obj.id)]
end
else
assoc.pluck(:type, :id).map do |type, id|
[type.underscore.pluralize, @id_formatter.format(id)]
end
end
else
source.public_send(relationship.foreign_key).map do |value|
[relationship.type, IdValueFormatter.format(value)]
[relationship.type, @id_formatter.format(value)]
end
end
end
Expand Down
14 changes: 14 additions & 0 deletions test/benchmark/request_benchmark.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
require File.expand_path('../../test_helper', __FILE__)

class RequestBenchmark < IntegrationBenchmark
def setup
$test_user = Person.find(1)
end

def bench_large_index_request
10.times do
get '/api/v2/books?include=bookComments,bookComments.author'
assert_jsonapi_response 200
end
end
end
Loading