From 3b956c7b4e800e620baf1ad491e8676b3ffbecfa Mon Sep 17 00:00:00 2001 From: nametoolong Date: Fri, 20 Jan 2023 22:38:06 +0800 Subject: [PATCH 1/3] Allow direct JSON rendering to a string --- lib/cache_crispies.rb | 1 + lib/cache_crispies/attribute.rb | 51 +++++- lib/cache_crispies/base.rb | 20 ++- lib/cache_crispies/collection.rb | 16 ++ lib/cache_crispies/hash_builder.rb | 21 +-- lib/cache_crispies/json_builder.rb | 67 +++++++ spec/cache_crispies/base_spec.rb | 44 +++++ spec/cache_crispies/collection_spec.rb | 8 + spec/cache_crispies/json_builder_spec.rb | 218 +++++++++++++++++++++++ 9 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 lib/cache_crispies/json_builder.rb create mode 100644 spec/cache_crispies/json_builder_spec.rb diff --git a/lib/cache_crispies.rb b/lib/cache_crispies.rb index 49f2b51..32058ed 100644 --- a/lib/cache_crispies.rb +++ b/lib/cache_crispies.rb @@ -25,6 +25,7 @@ module CacheCrispies autoload :Optional, 'cache_crispies/optional' autoload :Configuration, 'cache_crispies/configuration' autoload :HashBuilder, 'cache_crispies/hash_builder' + autoload :JsonBuilder, 'cache_crispies/json_builder' autoload :Memoizer, 'cache_crispies/memoizer' autoload :Controller, 'cache_crispies/controller' autoload :Plan, 'cache_crispies/plan' diff --git a/lib/cache_crispies/attribute.rb b/lib/cache_crispies/attribute.rb index 126c52c..f6bd360 100644 --- a/lib/cache_crispies/attribute.rb +++ b/lib/cache_crispies/attribute.rb @@ -63,18 +63,29 @@ def initialize( # @raise [InvalidCoercionType] when an invalid argument is passed in the # to: argument def value_for(target, options) - value = - if block? - block.call(target, options) - elsif through? - target.public_send(through)&.public_send(method_name) - else - target.public_send(method_name) - end + value = retrieve_value(target, options) serializer ? serialize(value, options) : coerce(value) end + # Writes a key-value pair of the attribute for the given target object + # and options into the given Oj::StringWriter + # + # @param json_writer [Oj::StringWriter] any Oj::StringWriter instance + # @param target [Object] typically ActiveRecord::Base, but could be anything + # @param options [Hash] any optional values from the serializer instance + # @raise [InvalidCoercionType] when an invalid argument is passed in the + # to: argument + def write_to_json(json_writer, target, options) + value = retrieve_value(target, options) + + if serializer + write_serialized_value(json_writer, value, options) + else + json_writer.push_value(coerce(value), key.to_s) + end + end + private def through? @@ -85,6 +96,16 @@ def block? !block.nil? end + def retrieve_value(target, options) + if block? + block.call(target, options) + elsif through? + target.public_send(through)&.public_send(method_name) + else + target.public_send(method_name) + end + end + # Here we'll render the attribute with a given serializer and attempt to # cache the results for better cache reusability def serialize(value, options) @@ -99,6 +120,20 @@ def serialize(value, options) end end + def write_serialized_value(json_writer, value, options) + plan = CacheCrispies::Plan.new( + serializer, value, collection: collection, **options + ) + + json_writer.push_key(key.to_s) if key + + if key && plan.collection? + Collection.new(value, serializer, options).write_to_json(json_writer) + else + JsonBuilder.new(serializer.new(value, options)).call(json_writer, flat: !key) + end + end + def coerce(value) return value if coerce_to.nil? diff --git a/lib/cache_crispies/base.rb b/lib/cache_crispies/base.rb index 81a5598..20af83f 100644 --- a/lib/cache_crispies/base.rb +++ b/lib/cache_crispies/base.rb @@ -23,7 +23,7 @@ def self.inherited(other) class << self attr_reader :attributes end - delegate :attributes, to: :class + delegate :attributes, :attributes_by_nesting, to: :class # Initializes a new instance of CacheCrispies::Base, or really, it should # always be a subclass of CacheCrispies::Base. @@ -43,6 +43,18 @@ def as_json HashBuilder.new(self).call end + # Renders the serializer instance to an Oj::StringWriter instance + # + # @return [Oj::StringWriter] an Oj::StringWriter instance with the + # serialized content + def write_to_json(json_writer = nil) + json_writer ||= Oj::StringWriter.new(mode: :rails) + + JsonBuilder.new(self).call(json_writer) + + json_writer + end + # Get or set whether or not this serializer class should allow caching of # results. It returns false by default, but can be overridden in child # classes. Calling the method with an argument will set the value, calling @@ -193,6 +205,12 @@ def self.file_hashes ).uniq.sort end + def self.attributes_by_nesting + @attributes_by_nesting ||= ( + attributes.sort_by(&:nesting).group_by(&:nesting) + ) + end + private def self.file_hash diff --git a/lib/cache_crispies/collection.rb b/lib/cache_crispies/collection.rb index 395908b..a0b6115 100644 --- a/lib/cache_crispies/collection.rb +++ b/lib/cache_crispies/collection.rb @@ -29,6 +29,22 @@ def as_json end end + # Renders the collection to an Oj::StringWriter instance without caching + # + # @return [Oj::StringWriter] an Oj::StringWriter instance with the + # serialized content + def write_to_json(json_writer = nil) + json_writer ||= Oj::StringWriter.new(mode: :rails) + json_writer.push_array + + collection.each do |model| + serializer.new(model, options).write_to_json(json_writer) + end + + json_writer.pop + json_writer + end + private attr_reader :collection, :serializer, :options diff --git a/lib/cache_crispies/hash_builder.rb b/lib/cache_crispies/hash_builder.rb index 36b2e6f..cb8e9a3 100644 --- a/lib/cache_crispies/hash_builder.rb +++ b/lib/cache_crispies/hash_builder.rb @@ -16,7 +16,7 @@ def initialize(serializer) # # @return [Hash] def call - return unless @serializer.model + return if @serializer.model.nil? hash = {} @@ -42,7 +42,7 @@ def call hash end - private + protected attr_reader :serializer, :condition_results @@ -56,18 +56,19 @@ def show?(attribute) end end - def value_for(attribute) + def target_for(attribute) meth = attribute.method_name - target = - if meth != :itself && serializer.respond_to?(meth) - serializer - else - serializer.model - end + if serializer.respond_to?(meth) && meth != :itself + serializer + else + serializer.model + end + end + def value_for(attribute) # TODO: rescue NoMethodErrors here with something more telling - attribute.value_for(target, serializer.options) + attribute.value_for(target_for(attribute), serializer.options) end end end diff --git a/lib/cache_crispies/json_builder.rb b/lib/cache_crispies/json_builder.rb new file mode 100644 index 0000000..c801242 --- /dev/null +++ b/lib/cache_crispies/json_builder.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module CacheCrispies + # Builds out a JSON string directly with Oj::StringWriter + class JsonBuilder < HashBuilder + # Builds the JSON string with Oj::StringWriter + # + # @return [Oj::StringWriter] + def call(json_writer, flat: false) + if @serializer.model.nil? + json_writer.push_value(nil) + return + end + + json_writer.push_object unless flat + + last_nesting = [] + + serializer.attributes_by_nesting.each do |nesting, attributes| + prefix_length = common_prefix_length(last_nesting, nesting) + + (last_nesting.length - prefix_length).times do + json_writer.pop + end + + nesting[prefix_length..-1].each do |key| + json_writer.push_object(key.to_s) + end + + attributes.each do |attrib| + write_attribute(json_writer, attrib) if show?(attrib) + end + + last_nesting = nesting + end + + last_nesting.each do + json_writer.pop + end + + json_writer.pop unless flat + + json_writer + end + + protected + + def write_attribute(json_writer, attribute) + # TODO: rescue NoMethodErrors here with something more telling + attribute.write_to_json( + json_writer, + target_for(attribute), + serializer.options + ) + end + + private + + def common_prefix_length(array1, array2) + (0...[array1.length, array2.length].min).each do |i| + return i if array1[i] != array2[i] + end + + array1.length + end + end +end diff --git a/spec/cache_crispies/base_spec.rb b/spec/cache_crispies/base_spec.rb index c62aa74..ba814d9 100644 --- a/spec/cache_crispies/base_spec.rb +++ b/spec/cache_crispies/base_spec.rb @@ -105,6 +105,50 @@ class CacheCrispiesEngineTestSerializer < CacheCrispiesTestSerializer end end + describe '#write_to_json' do + let(:recovered_hash) { Oj.compat_load(subject.write_to_json.to_s, symbol_keys: true) } + + it 'serializes to an Oj::StringWriter' do + expect(subject.write_to_json).to be_kind_of(Oj::StringWriter) + + expect(recovered_hash).to eq( + id: '42', + name: 'Cookie Crisp', + company: 'General Mills', + nested: { + nested_again: { + deeply_nested: 'TRUE' + } + }, + nutrition_info: { + calories: 1000 + }, + organic: true, + parent_company: 'Disney probably' + ) + end + + context 'when nutrition_info is nil' do + before { model.nutrition_info = nil } + + it 'serializes to an Oj::StringWriter' do + expect(recovered_hash).to eq( + id: '42', + name: 'Cookie Crisp', + company: 'General Mills', + nested: { + nested_again: { + deeply_nested: 'TRUE' + } + }, + nutrition_info: nil, + organic: true, + parent_company: 'Disney probably' + ) + end + end + end + describe '.key' do it 'underscores the demodulized class name by default' do expect(serializer.key).to eq :cache_crispies_test diff --git a/spec/cache_crispies/collection_spec.rb b/spec/cache_crispies/collection_spec.rb index 37b5203..73db6f6 100644 --- a/spec/cache_crispies/collection_spec.rb +++ b/spec/cache_crispies/collection_spec.rb @@ -80,4 +80,12 @@ def models.cache_key() end end end end + + describe '#write_to_json' do + let(:recovered_hash) { Oj.compat_load(subject.write_to_json.to_s, symbol_keys: true) } + + it 'serializes to an Oj::StringWriter' do + expect(recovered_hash).to eq [{ name: name1 }, { name: name2 }] + end + end end diff --git a/spec/cache_crispies/json_builder_spec.rb b/spec/cache_crispies/json_builder_spec.rb new file mode 100644 index 0000000..49e1e0b --- /dev/null +++ b/spec/cache_crispies/json_builder_spec.rb @@ -0,0 +1,218 @@ +require 'spec_helper' + +describe CacheCrispies::JsonBuilder do + # These example serializers are meant to show a variety of options, + # configurations, and data types in order to really put the HashBuilder class + # through the ringer. + class ConstituentSerializer < CacheCrispies::Base + serialize :name + end + + class AllergenSerializer < CacheCrispies::Base + serialize :name + end + + class BuzzwordSerializer < CacheCrispies::Base + serialize :tagline, :small_print + + def tagline + "#{model.tagline}#{options[:footnote_marker]}" + end + + def small_print + "#{options[:footnote_marker]}this doesn't mean jack-squat" + end + end + + class CerealSerializerForJsonBuilder < CacheCrispies::Base + serialize :uid, from: :id, to: String + serialize :name, :company + merge :itself, with: BuzzwordSerializer + + nest_in :about do + nest_in :nutritional_information do + serialize :calories + serialize :ingredients, with: ConstituentSerializer + + serialize :allergies, with: AllergenSerializer, optional: true + end + end + + show_if ->(_model, options) { options[:be_trendy] } do + nest_in :health do + serialize :organic + + show_if ->(model) { model.organic } do + serialize :certification + end + end + end + + def certification + 'Totally Not A Scam Certifiers Inc' + end + end + + let(:organic) { false } + let(:ingredients) { + [ + OpenStruct.new(name: 'Sugar'), + OpenStruct.new(name: 'Other Kind of Sugar') + ] + } + let(:allergies) { + [ + OpenStruct.new(name: 'Peanuts'), + OpenStruct.new(name: 'Lactose') + ] + } + let(:model) { + OpenStruct.new( + id: 42, + name: 'Lucky Charms', + company: 'General Mills', + calories: 1_000, + organic: organic, + tagline: "Part of a balanced breakfast", + ingredients: ingredients, + allergies: allergies + ) + } + let(:options) { { footnote_marker: '*' } } + let(:serializer) { CerealSerializerForJsonBuilder.new(model, options) } + let(:json_writer) { Oj::StringWriter.new(mode: :rails) } + subject { described_class.new(serializer) } + + describe '#call' do + let(:recovered_hash) { Oj.compat_load(subject.call(json_writer).to_s, symbol_keys: true) } + + it 'correctly renders the hash' do + expect(recovered_hash).to eq ({ + uid: '42', + name: 'Lucky Charms', + company: 'General Mills', + tagline: 'Part of a balanced breakfast*', + small_print: "*this doesn't mean jack-squat", + about: { + nutritional_information: { + calories: 1000, + ingredients: [ + { name: 'Sugar' }, + { name: 'Other Kind of Sugar' }, + ] + } + }, + health: {} + }) + end + + context 'when the outer show_if is true' do + let(:options) { { footnote_marker: '†', be_trendy: true } } + + it 'builds values wrapped in the outer if' do + expect(recovered_hash).to eq ({ + uid: '42', + name: 'Lucky Charms', + company: 'General Mills', + tagline: 'Part of a balanced breakfast†', + small_print: "†this doesn't mean jack-squat", + about: { + nutritional_information: { + calories: 1000, + ingredients: [ + { name: 'Sugar' }, + { name: 'Other Kind of Sugar' }, + ] + } + }, + health: { + organic: false + } + }) + end + + context 'when the inner show_if is true' do + let(:organic) { true } + + it 'builds values wrapped in the outer and inner if' do + expect(recovered_hash).to eq ({ + uid: '42', + name: 'Lucky Charms', + company: 'General Mills', + tagline: 'Part of a balanced breakfast†', + small_print: "†this doesn't mean jack-squat", + about: { + nutritional_information: { + calories: 1000, + ingredients: [ + { name: 'Sugar' }, + { name: 'Other Kind of Sugar' }, + ] + } + }, + health: { + organic: true, + certification: 'Totally Not A Scam Certifiers Inc' + } + }) + end + end + end + + context 'when allergies are included' do + let(:options) { { footnote_marker: '*', include: :allergies } } + + it 'includes the allergies' do + expect(recovered_hash).to eq ({ + uid: '42', + name: 'Lucky Charms', + company: 'General Mills', + tagline: 'Part of a balanced breakfast*', + small_print: "*this doesn't mean jack-squat", + about: { + nutritional_information: { + calories: 1000, + ingredients: [ + { name: 'Sugar' }, + { name: 'Other Kind of Sugar' }, + ], + allergies: [ + { name: 'Peanuts' }, + { name: 'Lactose' }, + ] + } + }, + health: {} + }) + end + end + + context 'when everything is included' do + let(:options) { { footnote_marker: '*', include: '*' } } + + it 'includes the allergies' do + expect(recovered_hash).to eq ({ + uid: '42', + name: 'Lucky Charms', + company: 'General Mills', + tagline: 'Part of a balanced breakfast*', + small_print: "*this doesn't mean jack-squat", + about: { + nutritional_information: { + calories: 1000, + ingredients: [ + { name: 'Sugar' }, + { name: 'Other Kind of Sugar' }, + ], + allergies: [ + { name: 'Peanuts' }, + { name: 'Lactose' }, + ] + } + }, + health: {} + }) + end + end + end +end From c39e01ceb7faf65edb23fb0f294702428a0a7fce Mon Sep 17 00:00:00 2001 From: nametoolong Date: Sat, 21 Jan 2023 14:16:20 +0800 Subject: [PATCH 2/3] Fix common prefix calculation --- lib/cache_crispies/json_builder.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/cache_crispies/json_builder.rb b/lib/cache_crispies/json_builder.rb index c801242..8399355 100644 --- a/lib/cache_crispies/json_builder.rb +++ b/lib/cache_crispies/json_builder.rb @@ -57,11 +57,13 @@ def write_attribute(json_writer, attribute) private def common_prefix_length(array1, array2) - (0...[array1.length, array2.length].min).each do |i| + shorter_length = [array1.length, array2.length].min + + (0...shorter_length).each do |i| return i if array1[i] != array2[i] end - array1.length + shorter_length end end end From 8cabe6257920ca6670db2f1efed0009438eeffb5 Mon Sep 17 00:00:00 2001 From: nametoolong Date: Sat, 21 Jan 2023 23:01:18 +0800 Subject: [PATCH 3/3] Move attributes_by_nesting delegation to conform to coding style --- lib/cache_crispies/base.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cache_crispies/base.rb b/lib/cache_crispies/base.rb index 20af83f..9758ff7 100644 --- a/lib/cache_crispies/base.rb +++ b/lib/cache_crispies/base.rb @@ -23,7 +23,7 @@ def self.inherited(other) class << self attr_reader :attributes end - delegate :attributes, :attributes_by_nesting, to: :class + delegate :attributes, to: :class # Initializes a new instance of CacheCrispies::Base, or really, it should # always be a subclass of CacheCrispies::Base. @@ -210,6 +210,7 @@ def self.attributes_by_nesting attributes.sort_by(&:nesting).group_by(&:nesting) ) end + delegate :attributes_by_nesting, to: :class private