diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c0768b0..d632fa57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/). ### Added +- `where_object_changes_to` queries for versions where the object's attributes + changed to one set of known values from any other set of values. - `where_object_changes_from` queries for versions where the object's attributes changed from one set of known values to any other set of values. diff --git a/README.md b/README.md index a1afd26b3..2bbc0e48a 100644 --- a/README.md +++ b/README.md @@ -1339,6 +1339,7 @@ An adapter can implement any or all of the following methods: 2. load_changeset: Returns the changeset for a given version object 3. where_object_changes: Returns the records resulting from the given hash of attributes. 4. where_object_changes_from: Returns the records resulting from the given hash of attributes where the attributes changed *from* the provided value(s). +5. where_object_changes_to: Returns the records resulting from the given hash of attributes where the attributes changed *to* the provided value(s). Depending on what your adapter does, you may have to implement all three. diff --git a/lib/paper_trail/queries/versions/where_object_changes_to.rb b/lib/paper_trail/queries/versions/where_object_changes_to.rb new file mode 100644 index 000000000..d7b605abf --- /dev/null +++ b/lib/paper_trail/queries/versions/where_object_changes_to.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module PaperTrail + module Queries + module Versions + # For public API documentation, see `where_object_changes_to` in + # `paper_trail/version_concern.rb`. + # @api private + class WhereObjectChangesTo + # - version_model_class - The class that VersionConcern was mixed into. + # - attributes - A `Hash` of attributes and values. See the public API + # documentation for details. + # @api private + def initialize(version_model_class, attributes) + @version_model_class = version_model_class + @attributes = attributes + end + + # @api private + def execute + if PaperTrail.config.object_changes_adapter&.respond_to?(:where_object_changes_to) + return PaperTrail.config.object_changes_adapter.where_object_changes_to( + @version_model_class, @attributes + ) + end + + case @version_model_class.columns_hash["object_changes"].type + when :jsonb, :json + json + else + text + end + end + + private + + # @api private + def json + predicates = [] + values = [] + @attributes.each do |field, value| + predicates.push( + "(object_changes->>? ILIKE ?)" + ) + values.concat([field, "[%#{value.to_json}]"]) + end + sql = predicates.join(" and ") + @version_model_class.where(sql, *values) + end + + # @api private + def text + arel_field = @version_model_class.arel_table[:object_changes] + + where_conditions = @attributes.map do |field, value| + ::PaperTrail.serializer.where_object_changes_to_condition(arel_field, field, value) + end + + where_conditions = where_conditions.reduce { |a, e| a.and(e) } + @version_model_class.where(where_conditions) + end + end + end + end +end diff --git a/lib/paper_trail/serializers/json.rb b/lib/paper_trail/serializers/json.rb index 0a58f3e45..7ea4bb5d0 100644 --- a/lib/paper_trail/serializers/json.rb +++ b/lib/paper_trail/serializers/json.rb @@ -49,6 +49,14 @@ def where_object_changes_from_condition(*) column. The json and jsonb datatypes are supported. STR end + + # Raises an exception as this operation is not allowed from text columns. + def where_object_changes_to_condition(*) + raise <<-STR.squish.freeze + where_object_changes_to does not support reading JSON from a text + column. The json and jsonb datatypes are supported. + STR + end end end end diff --git a/lib/paper_trail/serializers/yaml.rb b/lib/paper_trail/serializers/yaml.rb index 666b4dd73..54d1db999 100644 --- a/lib/paper_trail/serializers/yaml.rb +++ b/lib/paper_trail/serializers/yaml.rb @@ -46,6 +46,14 @@ def where_object_changes_from_condition(*) column. The json and jsonb datatypes are supported. STR end + + # Raises an exception as this operation is not allowed with YAML. + def where_object_changes_to_condition(*) + raise <<-STR.squish.freeze + where_object_changes_to does not support reading YAML from a text + column. The json and jsonb datatypes are supported. + STR + end end end end diff --git a/lib/paper_trail/version_concern.rb b/lib/paper_trail/version_concern.rb index f7549a84e..d0bad7b1b 100644 --- a/lib/paper_trail/version_concern.rb +++ b/lib/paper_trail/version_concern.rb @@ -4,6 +4,7 @@ require "paper_trail/queries/versions/where_object" require "paper_trail/queries/versions/where_object_changes" require "paper_trail/queries/versions/where_object_changes_from" +require "paper_trail/queries/versions/where_object_changes_to" module PaperTrail # Originally, PaperTrail did not provide this module, and all of this @@ -136,6 +137,21 @@ def where_object_changes_from(args = {}) Queries::Versions::WhereObjectChangesFrom.new(self, args).execute end + # Given a hash of attributes like `name: 'Joan'`, query the + # `versions.objects_changes` column for changes where the version changed + # to the hash of attributes from other values. + # + # This is useful for finding versions where the attribute started with an + # unknown value and changed to a known value. This is in comparison to + # `where_object_changes` which will find both the changes before and + # after. + # + # @api public + def where_object_changes_to(args = {}) + raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash) + Queries::Versions::WhereObjectChangesTo.new(self, args).execute + end + def primary_key_is_int? @primary_key_is_int ||= columns_hash[primary_key].type == :integer rescue StandardError # TODO: Rescue something more specific diff --git a/spec/models/version_spec.rb b/spec/models/version_spec.rb index d23521025..0d68f22fc 100644 --- a/spec/models/version_spec.rb +++ b/spec/models/version_spec.rb @@ -352,6 +352,88 @@ module PaperTrail end end end + + describe "#where_object_changes_to", versioning: true do + it "requires its argument to be a Hash" do + expect { + PaperTrail::Version.where_object_changes_to(:foo) + }.to raise_error(ArgumentError) + expect { + PaperTrail::Version.where_object_changes_to([]) + }.to raise_error(ArgumentError) + end + + context "with object_changes_adapter configured" do + after do + PaperTrail.config.object_changes_adapter = nil + end + + it "calls the adapter's where_object_changes_to method" do + adapter = instance_spy("CustomObjectChangesAdapter") + bicycle = Bicycle.create!(name: "abc") + bicycle.update!(name: "xyz") + + allow(adapter).to( + receive(:where_object_changes_to).with(Version, name: "xyz") + ).and_return([bicycle.versions[1]]) + + PaperTrail.config.object_changes_adapter = adapter + expect( + bicycle.versions.where_object_changes_to(name: "xyz") + ).to match_array([bicycle.versions[1]]) + expect(adapter).to have_received(:where_object_changes_to) + end + + it "defaults to the original behavior" do + adapter = Class.new.new + PaperTrail.config.object_changes_adapter = adapter + bicycle = Bicycle.create!(name: "abc") + bicycle.update!(name: "xyz") + + if column_datatype_override + expect( + bicycle.versions.where_object_changes_to(name: "xyz") + ).to match_array([bicycle.versions[1]]) + else + expect do + bicycle.versions.where_object_changes_to(name: "xyz") + end.to raise_error(/does not support reading YAML/) + end + end + end + + # Only test json and jsonb columns. where_object_changes_to does + # not support text columns. + if column_datatype_override + it "locates versions according to their object_changes contents" do + widget.update!(name: name, an_integer: 0) + widget.update!(name: "foobar", an_integer: 100) + widget.update!(name: FFaker::Name.last_name, an_integer: int) + + expect( + widget.versions.where_object_changes_to(name: name) + ).to eq([widget.versions[0]]) + expect( + widget.versions.where_object_changes_to(an_integer: 100) + ).to eq([widget.versions[1]]) + expect( + widget.versions.where_object_changes_to(an_integer: int) + ).to eq([widget.versions[2]]) + expect( + widget.versions.where_object_changes_to(an_integer: 100, name: "foobar") + ).to eq([widget.versions[1]]) + expect( + widget.versions.where_object_changes_to(an_integer: -1) + ).to eq([]) + end + else + it "raises error" do + expect { + widget.versions.where_object_changes_to(name: "foo").to_a + }.to(raise_error(/does not support reading YAML from a text column/)) + end + end + end end end end diff --git a/spec/support/custom_object_changes_adapter.rb b/spec/support/custom_object_changes_adapter.rb index 419bfe614..ba0afaa91 100644 --- a/spec/support/custom_object_changes_adapter.rb +++ b/spec/support/custom_object_changes_adapter.rb @@ -17,4 +17,8 @@ def where_object_changes(klass, attributes) def where_object_changes_from(klass, attributes) klass.where(attributes) end + + def where_object_changes_to(klass, attributes) + klass.where(attributes) + end end