From 06a7c1253449c1bbdbd249750d039fb33e328a27 Mon Sep 17 00:00:00 2001 From: Kevin Murphy Date: Mon, 22 Feb 2021 19:44:41 -0500 Subject: [PATCH] Query Versions Where Object Changed To Attributes This extends to the public API to provide more targeted querying of object changes. `where_object_changes` will look for either side of the change of the attributes provided - either versions where the attribute changed __from__ the provided value, or changed __to__ the provided value. The `where_object_changes_to` addition focuses only on one side of that equation. If you want to find versions where the attribute(s) explicitly changed *to* some known value, this will only show those changes, as opposed to both *from* and *to*. --- CHANGELOG.md | 2 + README.md | 1 + .../versions/where_object_changes_to.rb | 65 +++++++++++++++ lib/paper_trail/serializers/json.rb | 8 ++ lib/paper_trail/serializers/yaml.rb | 8 ++ lib/paper_trail/version_concern.rb | 16 ++++ spec/models/version_spec.rb | 82 +++++++++++++++++++ spec/support/custom_object_changes_adapter.rb | 4 + 8 files changed, 186 insertions(+) create mode 100644 lib/paper_trail/queries/versions/where_object_changes_to.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f3998e49..7903aefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,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 bc2f411e..3c179121 100644 --- a/README.md +++ b/README.md @@ -1367,6 +1367,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 00000000..d7b605ab --- /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 0a58f3e4..7ea4bb5d 100644 --- a/lib/paper_trail/serializers/json.rb +++ b/lib/paper_trail/serializers/json.rb @@ -49,6 +49,14 @@ module PaperTrail 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 666b4dd7..54d1db99 100644 --- a/lib/paper_trail/serializers/yaml.rb +++ b/lib/paper_trail/serializers/yaml.rb @@ -46,6 +46,14 @@ module PaperTrail 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 429ac608..8ee20483 100644 --- a/lib/paper_trail/version_concern.rb +++ b/lib/paper_trail/version_concern.rb @@ -4,6 +4,7 @@ require "paper_trail/attribute_serializers/object_changes_attribute" 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 @@ -131,6 +132,21 @@ module PaperTrail 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 761e6e7b..6a86b317 100644 --- a/spec/models/version_spec.rb +++ b/spec/models/version_spec.rb @@ -350,6 +350,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 419bfe61..ba0afaa9 100644 --- a/spec/support/custom_object_changes_adapter.rb +++ b/spec/support/custom_object_changes_adapter.rb @@ -17,4 +17,8 @@ class CustomObjectChangesAdapter def where_object_changes_from(klass, attributes) klass.where(attributes) end + + def where_object_changes_to(klass, attributes) + klass.where(attributes) + end end