Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for where_object and where_object_changes with JSON and JSONB columns #518

Closed
4 changes: 3 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ matrix:
gemfile: Gemfile
- rvm: 1.8.7
gemfile: Gemfile


addons:
postgresql: "9.4"
3 changes: 2 additions & 1 deletion lib/paper_trail/serializers/yaml.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def where_object_condition(arel_field, field, value)
# in the serialized object_changes
def where_object_changes_condition(arel_field, field, value)
# Need to check first (before) and secondary (after) fields
if defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == 'psych'
if (defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == 'psych') ||
(defined?(::YAML) && defined?(Psych) && ::YAML == Psych)
arel_field.matches("%\n#{field}:\n- #{value}\n%").
or(arel_field.matches("%\n#{field}:\n-%\n- #{value}\n%"))
else # Syck adds extra spaces into array dumps
Expand Down
45 changes: 33 additions & 12 deletions lib/paper_trail/version_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,25 +86,46 @@ def timestamp_sort_order(direction = 'asc')
# identically-named method in the serializer being used.
def where_object(args = {})
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
arel_field = arel_table[:object]

where_conditions = args.map do |field, value|
PaperTrail.serializer.where_object_condition(arel_field, field, value)
end.reduce do |condition1, condition2|
condition1.and(condition2)
if columns_hash['object'].type == :jsonb
where_conditions = "object @> '#{args.to_json}'::jsonb"
elsif columns_hash['object'].type == :json
where_conditions = args.map do |field, value|
"object->>'#{field}' = '#{value}'"
end
where_conditions = where_conditions.join(" AND ")
else
arel_field = arel_table[:object]

where_conditions = args.map do |field, value|
PaperTrail.serializer.where_object_condition(arel_field, field, value)
end.reduce do |condition1, condition2|
condition1.and(condition2)
end
end

where(where_conditions)
end

def where_object_changes(args = {})
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
arel_field = arel_table[:object_changes]

where_conditions = args.map do |field, value|
PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
end.reduce do |condition1, condition2|
condition1.and(condition2)
if columns_hash['object_changes'].type == :jsonb
args.each { |field, value| args[field] = [value] }
where_conditions = "object_changes @> '#{args.to_json}'::jsonb"
elsif columns_hash['object'].type == :json
where_conditions = args.map do |field, value|
"((object_changes->>'#{field}' ILIKE '[#{value.to_json},%') OR (object_changes->>'#{field}' ILIKE '[%,#{value.to_json}]%'))"
end
where_conditions = where_conditions.join(" AND ")
else
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we shouldn't cache / memoize these?

arel_field = arel_table[:object_changes]

where_conditions = args.map do |field, value|
PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
end.reduce do |condition1, condition2|
condition1.and(condition2)
end
end

where(where_conditions)
Expand All @@ -118,12 +139,12 @@ def primary_key_is_int?

# Returns whether the `object` column is using the `json` type supported by PostgreSQL
def object_col_is_json?
@object_col_is_json ||= [:json, :jsonb].include?(columns_hash['object'].type)
[:json, :jsonb].include?(columns_hash['object'].type)
end

# Returns whether the `object_changes` column is using the `json` type supported by PostgreSQL
def object_changes_col_is_json?
@object_changes_col_is_json ||= [:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
[:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
end
end

Expand Down
178 changes: 101 additions & 77 deletions spec/models/version_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,100 +65,124 @@
end

describe "Class" do
describe '#where_object' do
it { expect(PaperTrail::Version).to respond_to(:where_object) }

context "invalid arguments" do
it "should raise an error" do
expect { PaperTrail::Version.where_object(:foo) }.to raise_error(ArgumentError)
expect { PaperTrail::Version.where_object([]) }.to raise_error(ArgumentError)
end
end

context "valid arguments", :versioning => true do
let(:widget) { Widget.new }
let(:name) { Faker::Name.first_name }
let(:int) { rand(10) + 1 }
column_overrides = [false]
column_overrides.concat(%w[json jsonb]) if ENV['DB'] == 'postgres'

column_overrides.shuffle.each do |override|
context "with a #{override || 'text'} column" do
before do
widget.update_attributes!(:name => name, :an_integer => int)
widget.update_attributes!(:name => 'foobar', :an_integer => 100)
widget.update_attributes!(:name => Faker::Name.last_name, :an_integer => 15)
end

context "`serializer == YAML`" do
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML }

it "should be able to locate versions according to their `object` contents" do
expect(PaperTrail::Version.where_object(:name => name)).to eq([widget.versions[1]])
expect(PaperTrail::Version.where_object(:an_integer => 100)).to eq([widget.versions[2]])
if override
ActiveRecord::Base.connection.execute("SAVEPOINT pgtest;")
%w[object object_changes].each do |column|
ActiveRecord::Base.connection.execute("ALTER TABLE versions DROP COLUMN #{column};")
ActiveRecord::Base.connection.execute("ALTER TABLE versions ADD COLUMN #{column} #{override};")
end
PaperTrail::Version.reset_column_information
end
end

context "`serializer == JSON`" do
before(:all) { PaperTrail.serializer = PaperTrail::Serializers::JSON }
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::JSON }

it "should be able to locate versions according to their `object` contents" do
expect(PaperTrail::Version.where_object(:name => name)).to eq([widget.versions[1]])
expect(PaperTrail::Version.where_object(:an_integer => 100)).to eq([widget.versions[2]])
after do
if override
ActiveRecord::Base.connection.execute("ROLLBACK TO SAVEPOINT pgtest;")
PaperTrail::Version.reset_column_information
end

after(:all) { PaperTrail.serializer = PaperTrail::Serializers::YAML }
end
end
end

describe '#where_object_changes' do
it { expect(PaperTrail::Version).to respond_to(:where_object_changes) }

context "invalid arguments" do
it "should raise an error" do
expect { PaperTrail::Version.where_object_changes(:foo) }.to raise_error(ArgumentError)
expect { PaperTrail::Version.where_object_changes([]) }.to raise_error(ArgumentError)
end
end

context "valid arguments", :versioning => true do
let(:widget) { Widget.new }
let(:name) { Faker::Name.first_name }
let(:int) { rand(5) + 2 }
describe '#where_object' do
it { expect(PaperTrail::Version).to respond_to(:where_object) }

before do
widget.update_attributes!(:name => name, :an_integer => 0)
widget.update_attributes!(:name => 'foobar', :an_integer => 77)
widget.update_attributes!(:name => Faker::Name.last_name, :an_integer => int)
end

context "`serializer == YAML`" do
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML }

it "should be able to locate versions according to their `object_changes` contents" do
expect(widget.versions.where_object_changes(:name => name)).to eq(widget.versions[0..1])
expect(widget.versions.where_object_changes(:an_integer => 77)).to eq(widget.versions[1..2])
expect(widget.versions.where_object_changes(:an_integer => int)).to eq([widget.versions.last])
context "invalid arguments" do
it "should raise an error" do
expect { PaperTrail::Version.where_object(:foo) }.to raise_error(ArgumentError)
expect { PaperTrail::Version.where_object([]) }.to raise_error(ArgumentError)
end
end

it "should be able to handle queries for multiple attributes" do
expect(widget.versions.where_object_changes(:an_integer => 77, :name => 'foobar')).to eq(widget.versions[1..2])
context "valid arguments", :versioning => true do
let(:widget) { Widget.new }
let(:name) { Faker::Name.first_name }
let(:int) { rand(10) + 1 }

before do
widget.update_attributes!(:name => name, :an_integer => int)
widget.update_attributes!(:name => 'foobar', :an_integer => 100)
widget.update_attributes!(:name => Faker::Name.last_name, :an_integer => 15)
end

context "`serializer == YAML`" do
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML }

it "should be able to locate versions according to their `object` contents" do
expect(PaperTrail::Version.where_object(:name => name)).to eq([widget.versions[1]])
expect(PaperTrail::Version.where_object(:an_integer => 100)).to eq([widget.versions[2]])
end
end

context "`serializer == JSON`" do
before(:all) { PaperTrail.serializer = PaperTrail::Serializers::JSON }
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::JSON }

it "should be able to locate versions according to their `object` contents" do
expect(PaperTrail::Version.where_object(:name => name)).to eq([widget.versions[1]])
expect(PaperTrail::Version.where_object(:an_integer => 100)).to eq([widget.versions[2]])
end

after(:all) { PaperTrail.serializer = PaperTrail::Serializers::YAML }
end
end
end

context "`serializer == JSON`" do
before(:all) { PaperTrail.serializer = PaperTrail::Serializers::JSON }
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::JSON }
describe '#where_object_changes' do
it { expect(PaperTrail::Version).to respond_to(:where_object_changes) }

it "should be able to locate versions according to their `object_changes` contents" do
expect(widget.versions.where_object_changes(:name => name)).to eq(widget.versions[0..1])
expect(widget.versions.where_object_changes(:an_integer => 77)).to eq(widget.versions[1..2])
expect(widget.versions.where_object_changes(:an_integer => int)).to eq([widget.versions.last])
context "invalid arguments" do
it "should raise an error" do
expect { PaperTrail::Version.where_object_changes(:foo) }.to raise_error(ArgumentError)
expect { PaperTrail::Version.where_object_changes([]) }.to raise_error(ArgumentError)
end
end

it "should be able to handle queries for multiple attributes" do
expect(widget.versions.where_object_changes(:an_integer => 77, :name => 'foobar')).to eq(widget.versions[1..2])
context "valid arguments", :versioning => true do
let(:widget) { Widget.new }
let(:name) { Faker::Name.first_name }
let(:int) { rand(5) + 2 }

before do
widget.update_attributes!(:name => name, :an_integer => 0)
widget.update_attributes!(:name => 'foobar', :an_integer => 77)
widget.update_attributes!(:name => Faker::Name.last_name, :an_integer => int)
end

context "`serializer == YAML`" do
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML }

it "should be able to locate versions according to their `object_changes` contents" do
expect(widget.versions.where_object_changes(:name => name)).to eq(widget.versions[0..1])
expect(widget.versions.where_object_changes(:an_integer => 77)).to eq(widget.versions[1..2])
expect(widget.versions.where_object_changes(:an_integer => int)).to eq([widget.versions.last])
end

it "should be able to handle queries for multiple attributes" do
expect(widget.versions.where_object_changes(:an_integer => 77, :name => 'foobar')).to eq(widget.versions[1..2])
end
end

context "`serializer == JSON`" do
before(:all) { PaperTrail.serializer = PaperTrail::Serializers::JSON }
specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::JSON }

it "should be able to locate versions according to their `object_changes` contents" do
expect(widget.versions.where_object_changes(:name => name)).to eq(widget.versions[0..1])
expect(widget.versions.where_object_changes(:an_integer => 77)).to eq(widget.versions[1..2])
expect(widget.versions.where_object_changes(:an_integer => int)).to eq([widget.versions.last])
end

it "should be able to handle queries for multiple attributes" do
expect(widget.versions.where_object_changes(:an_integer => 77, :name => 'foobar')).to eq(widget.versions[1..2])
end

after(:all) { PaperTrail.serializer = PaperTrail::Serializers::YAML }
end
end

after(:all) { PaperTrail.serializer = PaperTrail::Serializers::YAML }
end
end
end
Expand Down