pg_triggers 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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