pg_triggers 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 337b518fc8202e64427560ad223fa770f00539e9
4
- data.tar.gz: 6c461d6c36aa8de5bf4b851a85559513eb61d7e8
3
+ metadata.gz: 19f1fcd14466ad95643f91d2dc62934b7b6eef63
4
+ data.tar.gz: eaaea66c3afaa575234c0fb2be06c3b3f265df0c
5
5
  SHA512:
6
- metadata.gz: 23b8997a7baf9f1c270e26da58ba81a7aaf32e6618fa7fc8f88ea6b7fcf92fc2ee50e6434b223b3735f3b9a7ca51e311db48b13fee1f06f0b64cea7e2aa9dc00
7
- data.tar.gz: 3a75252387d8ff676e0b2c1b18b2ab7ad6128149cfe8ffd5f12ce4cf38f03624eda793d555ca4744fc95fc3e545e5fe824ea37a728392e612560e9e8b4040103
6
+ metadata.gz: f25370dae7dfffba8e21b20c7072b979942c7542b12857166d8473b5307bbc108e897b5726560c22166962182e2d0720503467a86694f8e3c16be0df42ede480
7
+ data.tar.gz: 73b51af990c1a93f284ff1f1402378f5299e995ad39ae1452b7bbe22468b5cd772ac58f9fa4abe8fea9c6a232643ea4daaa7aa656fe85f6f09ae8c4d9e84ed74
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ### 0.1.0 (2014-08-25)
2
+
3
+ * Add audit_table trigger to track changes to a table.
4
+
1
5
  ### 0.0.2 (2014-08-21)
2
6
 
3
7
  * Fix counter cache bug with UPDATEs and :where conditions.
data/lib/pg_triggers.rb CHANGED
@@ -50,5 +50,62 @@ module PgTriggers
50
50
  FOR EACH ROW EXECUTE PROCEDURE pg_triggers_counter_#{main_table}_#{counter_column}();
51
51
  SQL
52
52
  end
53
+
54
+ def create_audit_table
55
+ <<-SQL
56
+ CREATE TABLE audit_table(
57
+ id bigserial PRIMARY KEY,
58
+ table_name text NOT NULL,
59
+ changed_at timestamptz NOT NULL DEFAULT now(),
60
+ changes json NOT NULL
61
+ );
62
+ SQL
63
+ end
64
+
65
+ def audit_table(table_name, options = {})
66
+ incl = options[:include].map{|a| "'#{a}'"}.join(', ') if options[:include]
67
+ ignore = options[:ignore].map{|a| "'#{a}'"}.join(', ') if options[:ignore]
68
+
69
+ <<-SQL
70
+ CREATE OR REPLACE FUNCTION pg_triggers_audit_#{table_name}() RETURNS TRIGGER
71
+ AS $body$
72
+ DECLARE
73
+ changed_keys text[];
74
+ changes json;
75
+ BEGIN
76
+ IF (TG_OP = 'UPDATE') THEN
77
+ SELECT array_agg(o.key) INTO changed_keys
78
+ FROM json_each(row_to_json(OLD)) o
79
+ JOIN json_each(row_to_json(NEW)) n ON o.key = n.key
80
+ WHERE o.value::text <> n.value::text;
81
+
82
+ IF NOT (ARRAY[#{ignore}]::text[] @> changed_keys) THEN
83
+ SELECT ('{' || string_agg('"' || key || '":' || value, ',') || '}')::json INTO changes
84
+ FROM json_each(row_to_json(OLD))
85
+ WHERE (
86
+ key = ANY(changed_keys)
87
+ #{"AND key NOT IN (#{ignore})" if ignore}
88
+ )
89
+ #{"OR key IN (#{incl})" if incl};
90
+
91
+ INSERT INTO audit_table(table_name, changes) VALUES (TG_TABLE_NAME::TEXT, changes);
92
+ END IF;
93
+
94
+ RETURN OLD;
95
+ ELSIF (TG_OP = 'DELETE') THEN
96
+ INSERT INTO audit_table(table_name, changes) VALUES (TG_TABLE_NAME::TEXT, row_to_json(OLD));
97
+ RETURN OLD;
98
+ END IF;
99
+ END
100
+ $body$
101
+ LANGUAGE plpgsql;
102
+
103
+ DROP TRIGGER IF EXISTS pg_triggers_audit_#{table_name} ON #{table_name};
104
+
105
+ CREATE TRIGGER pg_triggers_audit_#{table_name}
106
+ AFTER UPDATE OR DELETE ON #{table_name}
107
+ FOR EACH ROW EXECUTE PROCEDURE pg_triggers_audit_#{table_name}();
108
+ SQL
109
+ end
53
110
  end
54
111
  end
@@ -1,3 +1,3 @@
1
1
  module PgTriggers
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
data/pg_triggers.gemspec CHANGED
@@ -23,4 +23,5 @@ Gem::Specification.new do |spec|
23
23
  spec.add_development_dependency "rspec"
24
24
  spec.add_development_dependency "sequel"
25
25
  spec.add_development_dependency "pg"
26
+ spec.add_development_dependency "pry"
26
27
  end
@@ -0,0 +1,220 @@
1
+ require 'spec_helper'
2
+ require 'json'
3
+
4
+ describe PgTriggers, 'auditing' do
5
+ before do
6
+ DB.drop_table?(:audit_table)
7
+ end
8
+
9
+ describe "create_audit_table" do
10
+ it "should create a table to hold auditing information" do
11
+ DB.run PgTriggers.create_audit_table
12
+ DB.table_exists?(:audit_table).should be true
13
+ end
14
+ end
15
+
16
+ describe "audit_table" do
17
+ before do
18
+ DB.run PgTriggers.create_audit_table
19
+ DB.drop_table?(:audited_table)
20
+ DB.create_table :audited_table do
21
+ primary_key :id
22
+ text :description
23
+ integer :item_count, null: false, default: 0
24
+ end
25
+ end
26
+
27
+ it "should record old versions of rows when they are updated" do
28
+ DB.run PgTriggers.audit_table(:audited_table)
29
+
30
+ id = DB[:audited_table].insert
31
+ DB[:audited_table].where(id: id).update item_count: 1
32
+ DB[:audited_table].where(id: id).update description: 'blah'
33
+ DB[:audited_table].where(id: id).update description: nil
34
+ DB[:audited_table].where(id: id).update item_count: 2
35
+
36
+ DB[:audit_table].count.should == 4
37
+ r1, r2, r3, r4 = DB[:audit_table].order(:id).all
38
+
39
+ r1[:id].should == 1
40
+ r1[:table_name].should == 'audited_table'
41
+ r1[:changed_at].should be_within(3).of Time.now
42
+ JSON.parse(r1[:changes]).should == {'item_count' => 0}
43
+
44
+ r2[:id].should == 2
45
+ r2[:table_name].should == 'audited_table'
46
+ r2[:changed_at].should be_within(3).of Time.now
47
+ JSON.parse(r2[:changes]).should == {'description' => nil}
48
+
49
+ r3[:id].should == 3
50
+ r3[:table_name].should == 'audited_table'
51
+ r3[:changed_at].should be_within(3).of Time.now
52
+ JSON.parse(r3[:changes]).should == {'description' => 'blah'}
53
+
54
+ r4[:id].should == 4
55
+ r4[:table_name].should == 'audited_table'
56
+ r4[:changed_at].should be_within(3).of Time.now
57
+ JSON.parse(r4[:changes]).should == {'item_count' => 1}
58
+ end
59
+
60
+ it "should always record the values of columns in the :always set" do
61
+ DB.run PgTriggers.audit_table(:audited_table, include: [:id, :item_count])
62
+
63
+ id = DB[:audited_table].insert
64
+ DB[:audited_table].where(id: id).update item_count: 1
65
+ DB[:audited_table].where(id: id).update description: 'blah'
66
+
67
+ DB[:audit_table].count.should == 2
68
+ r1, r2 = DB[:audit_table].order(:id).all
69
+
70
+ r1[:id].should == 1
71
+ r1[:table_name].should == 'audited_table'
72
+ r1[:changed_at].should be_within(3).of Time.now
73
+ JSON.parse(r1[:changes]).should == {'id' => 1, 'item_count' => 0}
74
+
75
+ r2[:id].should == 2
76
+ r2[:table_name].should == 'audited_table'
77
+ r2[:changed_at].should be_within(3).of Time.now
78
+ JSON.parse(r2[:changes]).should == {'id' => 1, 'item_count' => 1, 'description' => nil}
79
+ end
80
+
81
+ it "should not record UPDATEs when the only changed columns fall within the :ignore set" do
82
+ DB.run PgTriggers.audit_table(:audited_table, ignore: [:item_count])
83
+
84
+ id = DB[:audited_table].insert
85
+ DB[:audited_table].where(id: id).update item_count: 1
86
+ DB[:audited_table].where(id: id).update description: 'blah'
87
+ DB[:audited_table].where(id: id).update description: nil
88
+ DB[:audited_table].where(id: id).update item_count: 2
89
+
90
+ DB[:audit_table].count.should == 2
91
+ r1, r2 = DB[:audit_table].order(:id).all
92
+
93
+ r1[:id].should == 1
94
+ r1[:table_name].should == 'audited_table'
95
+ r1[:changed_at].should be_within(3).of Time.now
96
+ JSON.parse(r1[:changes]).should == {'description' => nil}
97
+
98
+ r2[:id].should == 2
99
+ r2[:table_name].should == 'audited_table'
100
+ r2[:changed_at].should be_within(3).of Time.now
101
+ JSON.parse(r2[:changes]).should == {'description' => 'blah'}
102
+ end
103
+
104
+ it "should handle columns being in both the :include and :ignore sets properly" do
105
+ DB.run PgTriggers.audit_table(:audited_table, include: [:item_count], ignore: [:item_count])
106
+
107
+ id = DB[:audited_table].insert
108
+ DB[:audited_table].where(id: id).update item_count: 1
109
+ DB[:audited_table].where(id: id).update description: 'blah'
110
+ DB[:audited_table].where(id: id).update description: nil
111
+ DB[:audited_table].where(id: id).update item_count: 2
112
+
113
+ DB[:audit_table].count.should == 2
114
+ r1, r2 = DB[:audit_table].order(:id).all
115
+
116
+ r1[:id].should == 1
117
+ r1[:table_name].should == 'audited_table'
118
+ r1[:changed_at].should be_within(3).of Time.now
119
+ JSON.parse(r1[:changes]).should == {'description' => nil, 'item_count' => 1}
120
+
121
+ r2[:id].should == 2
122
+ r2[:table_name].should == 'audited_table'
123
+ r2[:changed_at].should be_within(3).of Time.now
124
+ JSON.parse(r2[:changes]).should == {'description' => 'blah', 'item_count' => 1}
125
+ end
126
+
127
+ it "should ignore records that are not changed at all" do
128
+ DB.run PgTriggers.audit_table(:audited_table)
129
+
130
+ id = DB[:audited_table].insert
131
+ DB[:audited_table].where(id: id).update item_count: 0
132
+ DB[:audit_table].count.should == 0
133
+ end
134
+
135
+ it "should not include changed columns if they are ignored" do
136
+ DB.run PgTriggers.audit_table(:audited_table, ignore: [:item_count])
137
+
138
+ id = DB[:audited_table].insert
139
+ DB[:audited_table].where(id: id).update description: 'blah', item_count: 1
140
+ DB[:audited_table].where(id: id).update description: 'blah', item_count: 2
141
+ DB[:audited_table].where(id: id).update description: nil, item_count: 3
142
+
143
+ DB[:audit_table].count.should == 2
144
+ r1, r2 = DB[:audit_table].order(:id).all
145
+
146
+ r1[:id].should == 1
147
+ r1[:table_name].should == 'audited_table'
148
+ r1[:changed_at].should be_within(3).of Time.now
149
+ JSON.parse(r1[:changes]).should == {'description' => nil}
150
+
151
+ r2[:id].should == 2
152
+ r2[:table_name].should == 'audited_table'
153
+ r2[:changed_at].should be_within(3).of Time.now
154
+ JSON.parse(r2[:changes]).should == {'description' => 'blah'}
155
+ end
156
+
157
+ it "should record the entirety of the row when it is deleted" do
158
+ DB.run PgTriggers.audit_table(:audited_table)
159
+
160
+ id = DB[:audited_table].insert description: 'Go home and get your shinebox!', item_count: 5
161
+ DB[:audited_table].where(id: id).delete.should == 1
162
+
163
+ DB[:audit_table].count.should == 1
164
+ record = DB[:audit_table].first
165
+ record[:id].should == 1
166
+ record[:table_name].should == 'audited_table'
167
+ record[:changed_at].should be_within(3).of Time.now
168
+ JSON.parse(record[:changes]).should == {'id' => 1, 'description' => 'Go home and get your shinebox!', 'item_count' => 5}
169
+ end
170
+
171
+ it "should seamlessly replace an existing audit trigger on the same table" do
172
+ id = DB[:audited_table].insert description: 'Go home and get your shinebox!', item_count: 5
173
+
174
+ DB.run PgTriggers.audit_table(:audited_table)
175
+ DB[:audited_table].where(id: id).update(item_count: 6)
176
+ DB[:audit_table].count.should == 1
177
+
178
+ DB.run PgTriggers.audit_table(:audited_table, ignore: [:item_count])
179
+ DB[:audited_table].where(id: id).update(item_count: 7)
180
+ DB[:audit_table].count.should == 1
181
+ DB[:audited_table].where(id: id).update(description: 'blah')
182
+ DB[:audit_table].count.should == 2
183
+ end
184
+
185
+ it "should properly handle rows of type JSON" do
186
+ DB.alter_table :audited_table do
187
+ add_column :data, :json, null: false, default: '{}'
188
+ end
189
+
190
+ id = DB[:audited_table].insert data: '{}'
191
+
192
+ DB.run PgTriggers.audit_table(:audited_table)
193
+ DB[:audited_table].where(id: id).update(data: '{"a":1}')
194
+ DB[:audited_table].where(id: id).update(data: '{"a":1,"b":2}')
195
+ DB[:audited_table].where(id: id).update(data: '{"a":1,"b":2}')
196
+ DB[:audited_table].where(id: id).update(data: '{"a":2,"b":2}')
197
+
198
+ DB.run PgTriggers.audit_table(:audited_table, ignore: [:data])
199
+ DB[:audited_table].where(id: id).update(data: '{"a":8}')
200
+
201
+ DB[:audit_table].count.should == 3
202
+ r1, r2, r3 = DB[:audit_table].all
203
+
204
+ r1[:id].should == 1
205
+ r1[:table_name].should == 'audited_table'
206
+ r1[:changed_at].should be_within(3).of Time.now
207
+ JSON.parse(r1[:changes]).should == {'data' => {}}
208
+
209
+ r2[:id].should == 2
210
+ r2[:table_name].should == 'audited_table'
211
+ r2[:changed_at].should be_within(3).of Time.now
212
+ JSON.parse(r2[:changes]).should == {'data' => {'a' => 1}}
213
+
214
+ r3[:id].should == 3
215
+ r3[:table_name].should == 'audited_table'
216
+ r3[:changed_at].should be_within(3).of Time.now
217
+ JSON.parse(r3[:changes]).should == {'data' => {'a' => 1, 'b' => 2}}
218
+ end
219
+ end
220
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'sequel'
2
2
  require 'pg_triggers'
3
+ require 'pry'
3
4
 
4
5
  url = ENV['PG_TRIGGERS_URL'] || 'postgres:///pg_triggers'
5
6
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_triggers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Hanks
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-08-21 00:00:00.000000000 Z
11
+ date: 2014-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - '>='
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  description: Handy helpers to create PostgreSQL Triggers
84
98
  email:
85
99
  - christopher.m.hanks@gmail.com
@@ -96,6 +110,7 @@ files:
96
110
  - lib/pg_triggers.rb
97
111
  - lib/pg_triggers/version.rb
98
112
  - pg_triggers.gemspec
113
+ - spec/audit_table_spec.rb
99
114
  - spec/counter_cache_spec.rb
100
115
  - spec/spec_helper.rb
101
116
  homepage: ''
@@ -123,5 +138,6 @@ signing_key:
123
138
  specification_version: 4
124
139
  summary: Helpers for Postgres Triggers
125
140
  test_files:
141
+ - spec/audit_table_spec.rb
126
142
  - spec/counter_cache_spec.rb
127
143
  - spec/spec_helper.rb