Close #518; Merge branch 'json_attribute_search'
Add support for querying of PostgreSQL's JSON and JSONB column types via VersionConcern#where_object and VersionConcern#where_object_changes
This commit is contained in:
commit
cff34a7b11
|
@ -34,4 +34,6 @@ matrix:
|
|||
gemfile: Gemfile
|
||||
- rvm: 1.8.7
|
||||
gemfile: Gemfile
|
||||
|
||||
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
|
|
|
@ -23,7 +23,8 @@ module PaperTrail
|
|||
# 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' || ::YAML.to_s == 'Psych'
|
||||
if (defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == 'psych') ||
|
||||
(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
|
||||
|
|
|
@ -86,12 +86,22 @@ module PaperTrail
|
|||
# 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)
|
||||
|
@ -99,12 +109,23 @@ module PaperTrail
|
|||
|
||||
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
|
||||
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)
|
||||
|
@ -118,12 +139,12 @@ module PaperTrail
|
|||
|
||||
# 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
|
||||
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
if JsonVersion.table_exists?
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe JsonVersion, :type => :model do
|
||||
it "should include the `VersionConcern` module to get base functionality" do
|
||||
expect(JsonVersion).to include(PaperTrail::VersionConcern)
|
||||
end
|
||||
|
||||
describe "Methods" do
|
||||
describe "Class" do
|
||||
|
||||
describe '#where_object' do
|
||||
it { expect(JsonVersion).to respond_to(:where_object) }
|
||||
|
||||
context "invalid arguments" do
|
||||
it "should raise an error" do
|
||||
expect { JsonVersion.where_object(:foo) }.to raise_error(ArgumentError)
|
||||
expect { JsonVersion.where_object([]) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
context "valid arguments", :versioning => true do
|
||||
let(:fruit_names) { %w(apple orange lemon banana lime coconut strawberry blueberry) }
|
||||
let(:fruit) { Fruit.new }
|
||||
let(:name) { 'pomegranate' }
|
||||
let(:color) { Faker::Color.name }
|
||||
|
||||
before do
|
||||
fruit.update_attributes!(:name => name)
|
||||
fruit.update_attributes!(:name => fruit_names.sample, :color => color)
|
||||
fruit.update_attributes!(:name => fruit_names.sample, :color => Faker::Color.name)
|
||||
end
|
||||
|
||||
it "should be able to locate versions according to their `object` contents" do
|
||||
expect(JsonVersion.where_object(:name => name)).to eq([fruit.versions[1]])
|
||||
expect(JsonVersion.where_object(:color => color)).to eq([fruit.versions[2]])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#where_object_changes' do
|
||||
it { expect(JsonVersion).to respond_to(:where_object_changes) }
|
||||
|
||||
context "invalid arguments" do
|
||||
it "should raise an error" do
|
||||
expect { JsonVersion.where_object_changes(:foo) }.to raise_error(ArgumentError)
|
||||
expect { JsonVersion.where_object_changes([]) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
context "valid arguments", :versioning => true do
|
||||
let(:fruit_names) { %w(apple orange lemon banana lime strawberry blueberry) }
|
||||
let(:tropical_fruit_names) { %w(coconut pineapple kiwi mango melon) }
|
||||
let(:fruit) { Fruit.new }
|
||||
let(:name) { 'pomegranate' }
|
||||
let(:color) { Faker::Color.name }
|
||||
|
||||
before do
|
||||
fruit.update_attributes!(:name => name)
|
||||
fruit.update_attributes!(:name => tropical_fruit_names.sample, :color => color)
|
||||
fruit.update_attributes!(:name => fruit_names.sample, :color => Faker::Color.name)
|
||||
end
|
||||
|
||||
it "should be able to locate versions according to their `object_changes` contents" do
|
||||
expect(fruit.versions.where_object_changes(:name => name)).to eq(fruit.versions[0..1])
|
||||
expect(fruit.versions.where_object_changes(:color => color)).to eq(fruit.versions[1..2])
|
||||
end
|
||||
|
||||
it "should be able to handle queries for multiple attributes" do
|
||||
expect(fruit.versions.where_object_changes(:color => color, :name => name)).to eq([fruit.versions[1]])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end
|
|
@ -65,100 +65,128 @@ describe PaperTrail::Version, :type => :model do
|
|||
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 }
|
||||
|
||||
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
|
||||
column_overrides = [false]
|
||||
if ENV['DB'] == 'postgres' && ::ActiveRecord::VERSION::MAJOR >= 4
|
||||
column_overrides << 'json'
|
||||
# 'jsonb' column types are only supported for ActiveRecord 4.2+
|
||||
column_overrides << 'jsonb' if ::ActiveRecord::VERSION::STRING >= '4.2'
|
||||
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 }
|
||||
|
||||
column_overrides.shuffle.each do |override|
|
||||
context "with a #{override || 'text'} column" do
|
||||
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])
|
||||
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
|
||||
|
||||
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 do
|
||||
if override
|
||||
ActiveRecord::Base.connection.execute("ROLLBACK TO SAVEPOINT pgtest;")
|
||||
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 }
|
||||
describe '#where_object' do
|
||||
it { expect(PaperTrail::Version).to respond_to(:where_object) }
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
after(:all) { PaperTrail.serializer = PaperTrail::Serializers::YAML }
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class Fruit < ActiveRecord::Base
|
||||
if ENV['DB'] == 'postgres' || JsonVersion.table_exists?
|
||||
has_paper_trail :class_name => 'JsonVersion'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
class JsonVersion < PaperTrail::Version
|
||||
self.table_name = 'json_versions'
|
||||
end
|
|
@ -60,6 +60,19 @@ class SetUpTestTables < ActiveRecord::Migration
|
|||
end
|
||||
add_index :post_versions, [:item_type, :item_id]
|
||||
|
||||
if ENV['DB'] == 'postgres' && ::ActiveRecord::VERSION::MAJOR >= 4
|
||||
create_table :json_versions, :force => true do |t|
|
||||
t.string :item_type, :null => false
|
||||
t.integer :item_id, :null => false
|
||||
t.string :event, :null => false
|
||||
t.string :whodunnit
|
||||
t.json :object
|
||||
t.json :object_changes
|
||||
t.datetime :created_at
|
||||
end
|
||||
add_index :json_versions, [:item_type, :item_id]
|
||||
end
|
||||
|
||||
create_table :wotsits, :force => true do |t|
|
||||
t.integer :widget_id
|
||||
t.string :name
|
||||
|
@ -164,6 +177,11 @@ class SetUpTestTables < ActiveRecord::Migration
|
|||
t.integer :order_id
|
||||
t.string :product
|
||||
end
|
||||
|
||||
create_table :fruits, :force => true do |t|
|
||||
t.string :name
|
||||
t.string :color
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
|
@ -183,14 +201,20 @@ class SetUpTestTables < ActiveRecord::Migration
|
|||
drop_table :post_versions
|
||||
remove_index :versions, :column => [:item_type, :item_id]
|
||||
drop_table :versions
|
||||
if JsonVersion.table_exists?
|
||||
remove_index :json_versions, :column => [:item_type, :item_id]
|
||||
drop_table :json_versions
|
||||
end
|
||||
drop_table :widgets
|
||||
drop_table :documents
|
||||
drop_table :legacy_widgets
|
||||
drop_table :things
|
||||
drop_table :translations
|
||||
drop_table :gadgets
|
||||
drop_table :customers
|
||||
drop_table :orders
|
||||
drop_table :line_items
|
||||
drop_table :fruits
|
||||
remove_index :version_associations, :column => [:version_id]
|
||||
remove_index :version_associations, :name => 'index_version_associations_on_foreign_key'
|
||||
drop_table :version_associations
|
||||
|
|
Loading…
Reference in New Issue