Queries: object_changes: Simplify error instantiation

- Introduces a uniform error class, UnsupportedColumnType
- Simplifies the built-in serializers (paper_trail/serializers)

Since 9.2.0, when `object_changes_adapter` was introduced, if someone must use a
text column, and still wants to use these queries, they must write an
`object_changes_adapter`. AFAIK, no one has ever done this. The only public
adapter I know of, paper_trail-hashdiff, only supports json/b columns.

It's also theoretically possible that, after `where_object_changes` dropped
support for text columns, someone wrote a custom serializer (see
`PaperTrail.serializer=`). AFAIK, no one has done that either. Such a technique
was never documented under [6.b. Custom
Serializer](https://github.com/paper-trail-gem/paper_trail#6b-custom-serializer)
This commit is contained in:
Jared Beck 2021-04-05 17:17:09 -04:00
parent 15a018f669
commit 4ef8a0bfed
10 changed files with 141 additions and 189 deletions

100
README.md
View File

@ -704,42 +704,20 @@ widget = widget.paper_trail.previous_version
widget.paper_trail.live? # false widget.paper_trail.live? # false
``` ```
And you can perform `WHERE` queries for object versions based on attributes: See also: Section 3.e. Queries
```ruby
# Find versions that meet these criteria.
PaperTrail::Version.where_object(content: 'Hello', title: 'Article')
# Find versions before and after attribute `atr` had value `v`:
PaperTrail::Version.where_object_changes(atr: 'v')
```
See also:
- `where_object_changes_from`
- `where_object_changes_to`
- `where_attribute_changes`
Using `where_object_changes*` or `where_attribute_changes` to read YAML or JSON
from a text column was deprecated in 8.1.0, and will now raise an error. Use a
`json` or `jsonb` column if possible. If you must use a `text` column, you'll
have to write a custom `object_changes_adapter`.
### 3.c. Diffing Versions ### 3.c. Diffing Versions
There are two scenarios: diffing adjacent versions and diffing non-adjacent There are two scenarios: diffing adjacent versions and diffing non-adjacent
versions. versions.
The best way to diff adjacent versions is to get PaperTrail to do it for you. The best way to diff adjacent versions is to get PaperTrail to do it for you. If
If you add an `object_changes` text column to your `versions` table, either at you add an `object_changes` column to your `versions` table, PaperTrail will
installation time with the `rails generate paper_trail:install --with-changes` store the `changes` diff in each version. Ignored attributes are omitted.
option or manually, PaperTrail will store the `changes` diff (excluding any
attributes PaperTrail is ignoring) in each `update` version. You can use the
`version.changeset` method to retrieve it. For example:
```ruby ```ruby
widget = Widget.create name: 'Bob' widget = Widget.create name: 'Bob'
widget.versions.last.changeset widget.versions.last.changeset # reads object_changes column
# { # {
# "name"=>[nil, "Bob"], # "name"=>[nil, "Bob"],
# "created_at"=>[nil, 2015-08-10 04:10:40 UTC], # "created_at"=>[nil, 2015-08-10 04:10:40 UTC],
@ -760,11 +738,12 @@ widget.versions.last.changeset
Prior to 10.0.0, the `object_changes` were only stored for create and update Prior to 10.0.0, the `object_changes` were only stored for create and update
events. As of 10.0.0, they are stored for all three events. events. As of 10.0.0, they are stored for all three events.
Please be aware that PaperTrail doesn't use diffs internally. When I designed PaperTrail doesn't use diffs internally.
PaperTrail I wanted simplicity and robustness so I decided to make each version
of an object self-contained. A version stores all of its object's data, not a > When I designed PaperTrail I wanted simplicity and robustness so I decided to
diff from the previous version. This means you can delete any version without > make each version of an object self-contained. A version stores all of its
affecting any other. > object's data, not a diff from the previous version. This means you can
> delete any version without affecting any other. -Andy
To diff non-adjacent versions you'll have to write your own code. These To diff non-adjacent versions you'll have to write your own code. These
libraries may help: libraries may help:
@ -800,6 +779,30 @@ sql> delete from versions where created_at < '2010-06-01';
PaperTrail::Version.where('created_at < ?', 1.day.ago).delete_all PaperTrail::Version.where('created_at < ?', 1.day.ago).delete_all
``` ```
### 3.e. Queries
You can query records in the `versions` table based on their `object` or
`object_changes` columns.
```ruby
# Find versions that meet these criteria.
PaperTrail::Version.where_object(content: 'Hello', title: 'Article')
# Find versions before and after attribute `atr` had value `v`:
PaperTrail::Version.where_object_changes(atr: 'v')
```
See also:
- `where_object_changes_from`
- `where_object_changes_to`
- `where_attribute_changes`
Only `where_object` supports text columns. Your `object_changes` column should
be a `json` or `jsonb` column if possible. If you must use a `text` column,
you'll have to write a [custom
`object_changes_adapter`](#6c-custom-object-changes).
## 4. Saving More Information About Versions ## 4. Saving More Information About Versions
### 4.a. Finding Out Who Was Responsible For A Change ### 4.a. Finding Out Who Was Responsible For A Change
@ -1097,10 +1100,12 @@ Be advised that redefining an association is an undocumented feature of Rails.
### 5.c. Generators ### 5.c. Generators
PaperTrail has one generator, `paper_trail:install`. It writes, but does not PaperTrail has one generator, `paper_trail:install`. It writes, but does not
run, a migration file. run, a migration file. The migration creates the `versions` table.
The migration adds (at least) the `versions` table. The
most up-to-date documentation for this generator can be found by running `rails #### Reference
generate paper_trail:install --help`, but a copy is included here for
The most up-to-date documentation for this generator can be found by running
`rails generate paper_trail:install --help`, but a copy is included here for
convenience. convenience.
``` ```
@ -1365,17 +1370,24 @@ reading `::PaperTrail::Events::Base#recordable_object_changes`.
An adapter can implement any or all of the following methods: An adapter can implement any or all of the following methods:
1. diff: Returns the changeset in the desired format given the changeset in the original format 1. diff: Returns the changeset in the desired format given the changeset in the
original format
2. load_changeset: Returns the changeset for a given version object 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. 3. where_object_changes: Returns the records resulting from the given hash of
4. where_object_changes_from: Returns the records resulting from the given hash of attributes where the attributes changed *from* the provided value(s). attributes.
5. where_object_changes_to: Returns the records resulting from the given hash of attributes where the attributes changed *to* the provided value(s). 4. where_object_changes_from: Returns the records resulting from the given hash
6. where_attribute_changes: Returns the records where the attribute changed to or from any value. 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).
6. where_attribute_changes: Returns the records where the attribute changed to
or from any value.
Depending on what your adapter does, you may have to implement all three. Depending on your needs, you may choose to implement only a subset of these
methods.
For an example of a complete and useful adapter, see #### Known Adapters
[paper_trail-hashdiff](https://github.com/hashwin/paper_trail-hashdiff)
- [paper_trail-hashdiff](https://github.com/hashwin/paper_trail-hashdiff)
### 6.d. Excluding the Object Column ### 6.d. Excluding the Object Column

View File

@ -10,4 +10,24 @@ module PaperTrail
# @api public # @api public
class InvalidOption < Error class InvalidOption < Error
end end
# The application's database schema is not supported.
# @api public
class UnsupportedSchema < Error
end
# The application's database column type is not supported.
# @api public
class UnsupportedColumnType < UnsupportedSchema
def initialize(method:, expected:, actual:)
super(
format(
"%s expected %s column, got %s",
method,
expected,
actual
)
)
end
end
end end

View File

@ -23,12 +23,16 @@ module PaperTrail
@version_model_class, @attribute @version_model_class, @attribute
) )
end end
column_type = @version_model_class.columns_hash["object_changes"].type
case @version_model_class.columns_hash["object_changes"].type case column_type
when :jsonb, :json when :jsonb, :json
json json
else else
text raise UnsupportedColumnType.new(
method: "where_attribute_changes",
expected: "json or jsonb",
actual: column_type
)
end end
end end
@ -40,15 +44,6 @@ module PaperTrail
@version_model_class.where(sql, @attribute) @version_model_class.where(sql, @attribute)
end end
# @api private
def text
arel_field = @version_model_class.arel_table[:object_changes]
@version_model_class.where(
::PaperTrail.serializer.where_attribute_changes(arel_field, @attribute)
)
end
end end
end end
end end

View File

@ -28,13 +28,18 @@ module PaperTrail
@version_model_class, @attributes @version_model_class, @attributes
) )
end end
case @version_model_class.columns_hash["object_changes"].type column_type = @version_model_class.columns_hash["object_changes"].type
case column_type
when :jsonb when :jsonb
jsonb jsonb
when :json when :json
json json
else else
text raise UnsupportedColumnType.new(
method: "where_object_changes",
expected: "json or jsonb",
actual: column_type
)
end end
end end
@ -59,16 +64,6 @@ module PaperTrail
@attributes.each { |field, value| @attributes[field] = [value] } @attributes.each { |field, value| @attributes[field] = [value] }
@version_model_class.where("object_changes @> ?", @attributes.to_json) @version_model_class.where("object_changes @> ?", @attributes.to_json)
end end
# @api private
def text
arel_field = @version_model_class.arel_table[:object_changes]
where_conditions = @attributes.map { |field, value|
::PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
}
where_conditions = where_conditions.reduce { |a, e| a.and(e) }
@version_model_class.where(where_conditions)
end
end end
end end
end end

View File

@ -23,12 +23,16 @@ module PaperTrail
@version_model_class, @attributes @version_model_class, @attributes
) )
end end
column_type = @version_model_class.columns_hash["object_changes"].type
case @version_model_class.columns_hash["object_changes"].type case column_type
when :jsonb, :json when :jsonb, :json
json json
else else
text raise UnsupportedColumnType.new(
method: "where_object_changes_from",
expected: "json or jsonb",
actual: column_type
)
end end
end end
@ -47,18 +51,6 @@ module PaperTrail
sql = predicates.join(" and ") sql = predicates.join(" and ")
@version_model_class.where(sql, *values) @version_model_class.where(sql, *values)
end 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_from_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
end end

View File

@ -23,12 +23,16 @@ module PaperTrail
@version_model_class, @attributes @version_model_class, @attributes
) )
end end
column_type = @version_model_class.columns_hash["object_changes"].type
case @version_model_class.columns_hash["object_changes"].type case column_type
when :jsonb, :json when :jsonb, :json
json json
else else
text raise UnsupportedColumnType.new(
method: "where_object_changes_to",
expected: "json or jsonb",
actual: column_type
)
end end
end end
@ -47,18 +51,6 @@ module PaperTrail
sql = predicates.join(" and ") sql = predicates.join(" and ")
@version_model_class.where(sql, *values) @version_model_class.where(sql, *values)
end 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
end end

View File

@ -14,14 +14,6 @@ module PaperTrail
ActiveSupport::JSON.encode object ActiveSupport::JSON.encode object
end end
# Raises an exception as this operation is not allowed from text columns.
def where_attribute_changes(*)
raise Error, <<-STR.squish.freeze
where_attribute_changes does not support reading JSON from a text
column. The json and jsonb datatypes are supported.
STR
end
# Returns a SQL LIKE condition to be used to match the given field and # Returns a SQL LIKE condition to be used to match the given field and
# value in the serialized object. # value in the serialized object.
def where_object_condition(arel_field, field, value) def where_object_condition(arel_field, field, value)
@ -39,32 +31,6 @@ module PaperTrail
arel_field.matches("%\"#{field}\":#{json_value}%") arel_field.matches("%\"#{field}\":#{json_value}%")
end end
end end
def where_object_changes_condition(*)
raise Error, <<-STR.squish.freeze
where_object_changes no longer supports reading JSON from a text
column. The old implementation was inaccurate, returning more records
than you wanted. This feature was deprecated in 7.1.0 and removed in
8.0.0. The json and jsonb datatypes are still supported. See the
discussion at https://github.com/paper-trail-gem/paper_trail/issues/803
STR
end
# Raises an exception as this operation is not allowed from text columns.
def where_object_changes_from_condition(*)
raise Error, <<-STR.squish.freeze
where_object_changes_from does not support reading JSON from a text
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 Error, <<-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 end
end end

View File

@ -21,47 +21,11 @@ module PaperTrail
::YAML.dump object ::YAML.dump object
end end
# Raises an exception as this operation is not allowed from text columns.
def where_attribute_changes(*)
raise Error, <<-STR.squish.freeze
where_attribute_changes does not support reading YAML from a text
column. The json and jsonb datatypes are supported.
STR
end
# Returns a SQL LIKE condition to be used to match the given field and # Returns a SQL LIKE condition to be used to match the given field and
# value in the serialized object. # value in the serialized object.
def where_object_condition(arel_field, field, value) def where_object_condition(arel_field, field, value)
arel_field.matches("%\n#{field}: #{value}\n%") arel_field.matches("%\n#{field}: #{value}\n%")
end end
# Returns a SQL LIKE condition to be used to match the given field and
# value in the serialized `object_changes`.
def where_object_changes_condition(*)
raise Error, <<-STR.squish.freeze
where_object_changes no longer supports reading YAML from a text
column. The old implementation was inaccurate, returning more records
than you wanted. This feature was deprecated in 8.1.0 and removed in
9.0.0. The json and jsonb datatypes are still supported. See
discussion at https://github.com/paper-trail-gem/paper_trail/pull/997
STR
end
# Raises an exception as this operation is not allowed with YAML.
def where_object_changes_from_condition(*)
raise Error, <<-STR.squish.freeze
where_object_changes_from does not support reading YAML from a text
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 Error, <<-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 end
end end

View File

@ -4,7 +4,7 @@ require "spec_helper"
module PaperTrail module PaperTrail
::RSpec.describe Version, type: :model do ::RSpec.describe Version, type: :model do
describe "object_changes column", versioning: true do describe "#object_changes", versioning: true do
let(:widget) { Widget.create!(name: "Dashboard") } let(:widget) { Widget.create!(name: "Dashboard") }
let(:value) { widget.versions.last.object_changes } let(:value) { widget.versions.last.object_changes }
@ -190,9 +190,12 @@ module PaperTrail
bicycle.versions.where_attribute_changes(:name) bicycle.versions.where_attribute_changes(:name)
).to match_array([bicycle.versions[0], bicycle.versions[1]]) ).to match_array([bicycle.versions[0], bicycle.versions[1]])
else else
expect do expect {
bicycle.versions.where_attribute_changes(:name) bicycle.versions.where_attribute_changes(:name)
end.to raise_error(/does not support reading YAML/) }.to raise_error(
UnsupportedColumnType,
"where_attribute_changes expected json or jsonb column, got text"
)
end end
end end
end end
@ -218,7 +221,10 @@ module PaperTrail
it "raises error" do it "raises error" do
expect { expect {
widget.versions.where_attribute_changes(:name).to_a widget.versions.where_attribute_changes(:name).to_a
}.to(raise_error(/does not support reading YAML from a text column/)) }.to raise_error(
UnsupportedColumnType,
"where_attribute_changes expected json or jsonb column, got text"
)
end end
end end
end end
@ -311,9 +317,12 @@ module PaperTrail
bicycle.versions.where_object_changes(name: "abc") bicycle.versions.where_object_changes(name: "abc")
).to match_array(bicycle.versions[0..1]) ).to match_array(bicycle.versions[0..1])
else else
expect do expect {
bicycle.versions.where_object_changes(name: "abc") bicycle.versions.where_object_changes(name: "abc")
end.to raise_error(/no longer supports reading YAML/) }.to raise_error(
UnsupportedColumnType,
"where_object_changes expected json or jsonb column, got text"
)
end end
end end
end end
@ -342,7 +351,10 @@ module PaperTrail
it "raises error" do it "raises error" do
expect { expect {
widget.versions.where_object_changes(name: "foo").to_a widget.versions.where_object_changes(name: "foo").to_a
}.to(raise_error(/no longer supports reading YAML from a text column/)) }.to raise_error(
UnsupportedColumnType,
"where_object_changes expected json or jsonb column, got text"
)
end end
end end
end end
@ -389,9 +401,12 @@ module PaperTrail
bicycle.versions.where_object_changes_from(name: "abc") bicycle.versions.where_object_changes_from(name: "abc")
).to match_array([bicycle.versions[1]]) ).to match_array([bicycle.versions[1]])
else else
expect do expect {
bicycle.versions.where_object_changes_from(name: "abc") bicycle.versions.where_object_changes_from(name: "abc")
end.to raise_error(/does not support reading YAML/) }.to raise_error(
UnsupportedColumnType,
"where_object_changes_from expected json or jsonb column, got text"
)
end end
end end
end end
@ -421,7 +436,10 @@ module PaperTrail
it "raises error" do it "raises error" do
expect { expect {
widget.versions.where_object_changes_from(name: "foo").to_a widget.versions.where_object_changes_from(name: "foo").to_a
}.to(raise_error(/does not support reading YAML from a text column/)) }.to raise_error(
UnsupportedColumnType,
"where_object_changes_from expected json or jsonb column, got text"
)
end end
end end
end end
@ -468,9 +486,12 @@ module PaperTrail
bicycle.versions.where_object_changes_to(name: "xyz") bicycle.versions.where_object_changes_to(name: "xyz")
).to match_array([bicycle.versions[1]]) ).to match_array([bicycle.versions[1]])
else else
expect do expect {
bicycle.versions.where_object_changes_to(name: "xyz") bicycle.versions.where_object_changes_to(name: "xyz")
end.to raise_error(/does not support reading YAML/) }.to raise_error(
UnsupportedColumnType,
"where_object_changes_to expected json or jsonb column, got text"
)
end end
end end
end end
@ -503,7 +524,10 @@ module PaperTrail
it "raises error" do it "raises error" do
expect { expect {
widget.versions.where_object_changes_to(name: "foo").to_a widget.versions.where_object_changes_to(name: "foo").to_a
}.to(raise_error(/does not support reading YAML from a text column/)) }.to raise_error(
UnsupportedColumnType,
"where_object_changes_to expected json or jsonb column, got text"
)
end end
end end
end end

View File

@ -59,14 +59,6 @@ module PaperTrail
end end
end end
end end
describe ".where_object_changes_condition" do
it "raises error" do
expect {
described_class.where_object_changes_condition
}.to raise_error(/no longer supports/)
end
end
end end
end end
end end