diff --git a/app/models/concerns/model/jbuilder.rb b/app/models/concerns/model/jbuilder.rb index c468018b..f4584c47 100644 --- a/app/models/concerns/model/jbuilder.rb +++ b/app/models/concerns/model/jbuilder.rb @@ -5,15 +5,98 @@ module Model::Jbuilder extend ActiveSupport::Concern class_methods do - def add_builder_expansion(*args, **kwargs) - builder_expansions.add_builder_expansion(*args, **kwargs) + # + # Builder expansions address a common issue when using Jbuilder for generated JSON for object events work with `#init_builder` + # In some situations, a model may reference another object but, depending on the situation, may not want to expand that object + # + # As an example, consider an hypothetical Supporter class with a reference to a Nonprofit + # + # class Supporter < ApplicationRecord + # belongs_to :nonprofit + # belongs_to :group + # def to_builder(*expand) + # init_builder(*expand) do + # # build you JBuilder object + # end + # end + # end + # + # When generating the the Jbuilder object, you may want to expand the nonprofit object or in other situations not expand it. To handle this + # you would need to write the following code: + # ` + # if expand.include? :nonprofit + # json.nonprofit nonprofit.to_builder.attributes! + # else + # json.nonprofit nonprofit.to_id + # end + # ` + # You would have to write the same code for `group`. As your number of expandable attributes increase, your code gets filled with boilerplate code. + # + # `add_builder_expansion` addresses this by autocreating this code in to_builder. + # For example, if you want nonprofit to be expandable as in the nonprofit json attribute, and group into the group json attribute. You only need to write: + # `add_builder_expansion :nonprofit, :group` + # You can put as many expandable attributes there as you'd like. + # + # On the other hand, let's say you want to make group expandable BUT you want to assign it to the "supporter_group" json attribute. To do that, you + # pass in the attribute you want to be expandable along with the "json_attribute" method key set to 'supporter_group': + # `add_builder_expansion :group, json_attribute: 'supporter_group'` + # + # For enumerable attributes (like a has_many or an array), there are two ways you may want to include them into your json output. If it's a set of simple values + # like, an array of strings or numbers, you may want to want to the array output as-is. For example, let's say you have a array of tags which are strings. You may want + # it to be output like so: + # ```` + # tags: [ 'founders circle', 'large donor'] + # ``` + # We call these `:flat` enumerable attribtes. Assuming the supporter class before has a tags attribute, you would add the tags builder_expansion using: + # ``` + # add_builder_expansion :tags, enum_type: :flat + # ``` + # + # On the other hand, you may want to have an array of other Jbuilder created objects. Let's say your supporter has many groups. You may want these attributes expanded in + # one of two ways: expanded or unexpanded. + # + # For expanded, you would receive have something like this in your output: + # + # { + # # ... some other parts of the supporter json + # groups: [{ id: 546, name: 'group name 1'}, {id: 235, name: 'group name 2'}] + # } + # + # However, for unexpanded, you'd just want the ids: + # + # { + # # ... some other parts of the supporter json + # groups: [546, 235] + # } + # + # For this type of builder expansion, you would use: + # ``` + # add_builder expansion :groups, enum_type: :expandable + # ``` + # + # @param [Symbol] *attributes the attributes you'd like to make expandable. If you want to set options, there should only be a single attribute here. + # @param **options options for configuring the builder expansion. The options right now are: + # - `json_attribute`: the json attribute name in the outputted Jbuilder. Defaults to the attribute name. + # - `enum_type`: the type of enumerable for the attribute. pass :flat if the enumerable attribute is flat, or :expandable if it's expandable. ANy other value + # including the default of nil, means the attribute is not enumerable. + # + def add_builder_expansion(*attributes, **options) + builder_expansions.add_builder_expansion(*attributes, **options) end - def builder_expansions + # + # A set of all the builder expansions configured for a class + # + # @return [Array] the builder expansions + # + def builder_expansions @builder_expansions ||= BuilderExpansionSet.new end end + def to_id + id + end class BuilderExpansionSet < Set def add_builder_expansion(*args, **kwargs) @@ -44,37 +127,59 @@ module Model::Jbuilder class BuilderExpansion include ActiveModel::AttributeAssignment - attr_accessor :key, :json_attrib, :to_id, - :to_expand, :if, :unless, :on_else, :to_attrib + attr_accessor :key, :json_attribute, :enum_type def initialize(new_attributes) assign_attributes(new_attributes) end + def enumerable? + expandable_enum? || flat_enum? + end - def json_attrib - @json_attrib || key + def expandable_enum? + enum_type == :expandable + end + + def flat_enum? + enum_type == :flat + end + + def json_attribute + (@json_attribute || key).to_s end def to_id - if @to_id - return @to_id - elsif @to_attrib - return -> (model, be=self) { to_attrib.(model).id } - else - return ->(model,be=self) { model.send(be.key).id} - end + return ->(model,be=self) { + value = be.get_attribute_value model + if be.expandable_enum? + value&.map{|i| i&.to_id} + elsif be.flat_enum? + value + else + value&.to_id + end + } end - def to_expand - if @to_expand - return @to_expand - elsif @to_attrib - return -> (model, be=self) { to_attrib.(model).to_builder } - else - return ->(model,be=self) { model.send(be.key).to_builder} - end + def to_builder + return ->(model,be=self) { + value = be.get_attribute_value model + if be.expandable_enum? + value&.map{|i| i&.to_builder} + elsif be.flat_enum? + value + else + value&.to_builder + end + } + end - + def get_attribute_value(model) + if model.respond_to? key + model.send(key) + else + raise ActiveModel::MissingAttributeError, "missing attribute: #{key}" + end end end @@ -86,9 +191,9 @@ module Model::Jbuilder json.object self.class.name.underscore builder_expansions.keys.each do |k| if expand.include? k - json.set! builder_expansions.get_by_key(k).json_attrib, builder_expansions.get_by_key(k).to_expand.(self) + json.set! builder_expansions.get_by_key(k).json_attribute, builder_expansions.get_by_key(k).to_builder.(self) else - json.set! builder_expansions.get_by_key(k).json_attrib, builder_expansions.get_by_key(k).to_id.(self) + json.set! builder_expansions.get_by_key(k).json_attribute, builder_expansions.get_by_key(k).to_id.(self) end end yield(json) diff --git a/app/models/concerns/model/trx_assignable.rb b/app/models/concerns/model/trx_assignable.rb index 4e4761c6..ba6dd3c3 100644 --- a/app/models/concerns/model/trx_assignable.rb +++ b/app/models/concerns/model/trx_assignable.rb @@ -13,7 +13,7 @@ module Model::TrxAssignable add_builder_expansion :nonprofit, :supporter add_builder_expansion :trx, - json_attrib: :transaction + json_attribute: :transaction has_one :transaction_assignment, as: :assignable has_one :trx, through: :transaction_assignment diff --git a/app/models/modern_campaign_gift.rb b/app/models/modern_campaign_gift.rb index 5c78b808..a2d7a194 100644 --- a/app/models/modern_campaign_gift.rb +++ b/app/models/modern_campaign_gift.rb @@ -10,7 +10,7 @@ class ModernCampaignGift < ApplicationRecord add_builder_expansion :nonprofit, :supporter, :campaign, :campaign_gift_option, :campaign_gift_purchase add_builder_expansion :trx, - json_attrib: :transaction + json_attribute: :transaction belongs_to :campaign_gift_purchase belongs_to :legacy_campaign_gift, class_name: 'CampaignGift', foreign_key: :campaign_gift_id, inverse_of: :modern_campaign_gift diff --git a/app/models/supporter_note.rb b/app/models/supporter_note.rb index 6bbfaf51..a955f7b2 100644 --- a/app/models/supporter_note.rb +++ b/app/models/supporter_note.rb @@ -18,10 +18,7 @@ class SupporterNote < ApplicationRecord validates :supporter_id, presence: true # TODO replace with Discard gem - add_builder_expansion :supporter, :nonprofit - add_builder_expansion :user, - to_id: ->(model) { model.user&.id}, - to_expand: ->(model) { model.user&.to_builder} + add_builder_expansion :supporter, :nonprofit, :user define_model_callbacks :discard diff --git a/app/models/ticket_purchase.rb b/app/models/ticket_purchase.rb index ed9df611..cf2dae7a 100644 --- a/app/models/ticket_purchase.rb +++ b/app/models/ticket_purchase.rb @@ -6,11 +6,8 @@ class TicketPurchase < ApplicationRecord include Model::TrxAssignable setup_houid :tktpur - add_builder_expansion :event + add_builder_expansion :event, :event_discount - add_builder_expansion :event_discount, - to_id: -> (model) { model.event_discount&.id }, - to_expand: -> (model) { model.event_discount&.to_builder } before_create :set_original_discount diff --git a/app/models/ticket_to_legacy_ticket.rb b/app/models/ticket_to_legacy_ticket.rb index ad65d438..c8cb8f0d 100644 --- a/app/models/ticket_to_legacy_ticket.rb +++ b/app/models/ticket_to_legacy_ticket.rb @@ -21,11 +21,7 @@ class TicketToLegacyTicket < ApplicationRecord setup_houid :tkt - add_builder_expansion :ticket_purchase, :ticket_level, :supporter, :event, :nonprofit - - add_builder_expansion :event_discount, - to_id: -> (model) { model.event_discount&.id}, - to_expand: -> (model) { model.event_discount&.to_builder} + add_builder_expansion :ticket_purchase, :ticket_level, :supporter, :event, :nonprofit, :event_discount def to_builder(*expand) init_builder(*expand) do |json| diff --git a/spec/models/concerns/model/jbuilder_spec.rb b/spec/models/concerns/model/jbuilder_spec.rb new file mode 100644 index 00000000..c5f4bb26 --- /dev/null +++ b/spec/models/concerns/model/jbuilder_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +# License: AGPL-3.0-or-later WITH WTO-AP-3.0-or-later +# Full license explanation at https://github.com/houdiniproject/houdini/blob/master/LICENSE +require 'rails_helper' + +RSpec.describe Model::Jbuilder do + describe Model::Jbuilder::BuilderExpansion do + let(:model) do + stub_const('HasToBuilderAndToId', Struct.new(:to_id, :to_builder)) + + stub_const('ModelClass', Struct.new(:filled, :unfilled, :enumerable)) + + ModelClass.new( + HasToBuilderAndToId.new('id_result', 'builder_result'), + nil, + [ + HasToBuilderAndToId.new('enumerable_id_result_1', 'enumerable_builder_result_1'), + HasToBuilderAndToId.new('enumerable_id_result_2', 'enumerable_builder_result_2') + ] + ) + end + let(:filled_expansion) { described_class.new(key: :filled, json_attribute: 'filled_attrib') } + let(:unfilled_expansion) { described_class.new(key: :unfilled) } + let(:nonexistent_expansion) { described_class.new(key: :nonexistent) } + let(:expandable_expansion) { described_class.new(key: :enumerable, enum_type: :expandable) } + let(:flat_expansion) { described_class.new(key: :enumerable, enum_type: :flat) } + + describe 'expansion where the attribute is filled' do + subject { filled_expansion } + + it { + is_expected.to have_attributes( + json_attribute: 'filled_attrib', + enumerable?: false, + flat_enum?: false, + expandable_enum?: false + ) + } + + describe '#to_id' do + subject { filled_expansion.to_id.call(model) } + + it { is_expected.to eq 'id_result' } + end + + describe '#to_builder' do + subject { filled_expansion.to_builder.call(model) } + + it { is_expected.to eq 'builder_result' } + end + end + + describe 'expansion where the attribute is unfilled' do + subject { unfilled_expansion } + + it { + is_expected.to have_attributes( + json_attribute: 'unfilled', + enumerable?: false, + flat_enum?: false, + expandable_enum?: false + ) + } + + describe '#to_id' do + subject { unfilled_expansion.to_id.call(model) } + + it { is_expected.to eq nil } + end + + describe '#to_builder' do + subject { unfilled_expansion.to_builder.call(model) } + + it { is_expected.to eq nil } + end + end + + describe 'expansion where the attribute is nonexistent' do + subject { nonexistent_expansion } + + it { + is_expected.to have_attributes( + json_attribute: 'nonexistent', + enumerable?: false, + flat_enum?: false, + expandable_enum?: false + ) + } + + it '.to_id raises error' do + expect { nonexistent_expansion.to_id.call(model) }.to raise_error ActiveModel::MissingAttributeError + end + + it '.to_builder raises error' do + expect { nonexistent_expansion.to_builder.call(model) }.to raise_error ActiveModel::MissingAttributeError + end + end + + describe 'expansion of expandable enumerable returns an enumerable' do + subject { expandable_expansion } + + it { + is_expected.to have_attributes( + json_attribute: 'enumerable', + enumerable?: true, + flat_enum?: false, + expandable_enum?: true + ) + } + + describe '#to_id' do + subject { expandable_expansion.to_id.call(model) } + + it { is_expected.to match(%w[enumerable_id_result_1 enumerable_id_result_2]) } + end + + describe '#to_builder' do + subject { expandable_expansion.to_builder.call(model) } + + it { is_expected.to match(%w[enumerable_builder_result_1 enumerable_builder_result_2]) } + end + end + + describe 'expansion of flat enumerable returns an enumerable' do + subject { flat_expansion } + + it { + is_expected.to have_attributes( + json_attribute: 'enumerable', + enumerable?: true, + flat_enum?: true, + expandable_enum?: false + ) + } + + describe '#to_id' do + subject { expandable_expansion.to_id.call(model) } + + it { is_expected.to match(%w[enumerable_id_result_1 enumerable_id_result_2]) } + end + + describe '#to_builder' do + subject { expandable_expansion.to_builder.call(model) } + + it { is_expected.to match(%w[enumerable_builder_result_1 enumerable_builder_result_2]) } + end + end + end +end diff --git a/spec/models/transaction_spec.rb b/spec/models/transaction_spec.rb index 3e4590e7..b8fe5b34 100644 --- a/spec/models/transaction_spec.rb +++ b/spec/models/transaction_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Transaction, type: :model do describe 'to_builder' do subject { supporter.transactions.create(amount: 1000).to_builder.attributes!} it 'will create a proper builder result' do - expect(subject).to match({ + is_expected.to match({ 'id' => match('trx_[a-zA-Z0-9]{22}'), 'nonprofit' => nonprofit.id, 'supporter' => supporter.id,