sequel_postgresql_triggers 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2008-2009 Jeremy Evans
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to
5
+ deal in the Software without restriction, including without limitation the
6
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ sell copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,60 @@
1
+ = Sequel PostgreSQL Triggers
2
+
3
+ Sequel PostgreSQL Triggers is a small enhancement to Sequel allowing
4
+ a user to easily handle the following types of columns:
5
+
6
+ * Timestamp Columns (Created At/Updated At)
7
+ * Counter/Sum Caches
8
+ * Immutable Columns
9
+
10
+ It handles these internally to the database via triggers, so even if
11
+ other applications access the database (without using Sequel), things
12
+ will still work (unless the database superuser disables triggers).
13
+
14
+ To use any of these methods, you have to add the plpgsql procedural
15
+ language to PostgreSQL, which you can do with:
16
+
17
+ DB.create_language(:plpgsql)
18
+
19
+ == Triggers
20
+
21
+ === Created At Columns - pgt_created_at
22
+
23
+ pgt_created_at takes the table and column given and makes it so that
24
+ upon insertion, the column is set to the CURRENT_TIMESTAMP, and that
25
+ upon update, the column's value is always set to the previous value.
26
+ This is sort of like an immutable column, but it doesn't bring up an
27
+ error if you try to change it, it just ignores it.
28
+
29
+ === Updated At Columns - pgt_updated_at
30
+
31
+ Similar to pgt_created_at, takes a table and column and makes it so
32
+ that upon insertion, the column is set to CURRENT_TIMESTAMP. It
33
+ differs that upon update, the column is also set to CURRENT_TIMESTAMP.
34
+
35
+ === Counter Cache - pgt_counter_cache
36
+
37
+ This takes quite a few arguments (see the RDoc) and sets up a
38
+ counter cache so that when the counted table is inserted to
39
+ or deleted from, records in the main table are updated with the
40
+ count of the corresponding records in the counted table.
41
+
42
+ === Sum Cache - pgt_sum_cache
43
+
44
+ Similar to pgt_counter_cache, except instead of storing a count
45
+ of records in the main table, it stores the sum on one of the
46
+ columns in summed table.
47
+
48
+ === Immutable Columns - pgt_immutable
49
+
50
+ This takes a table name and one or more column names, and adds
51
+ an update trigger that raises an exception if you try to modify
52
+ the value of any of the columns.
53
+
54
+ == License
55
+
56
+ This library is released under the MIT License. See the LICENSE file for details.
57
+
58
+ == Author
59
+
60
+ Jeremy Evans <code@jeremyevans.net>
@@ -0,0 +1,140 @@
1
+ module Sequel
2
+ module Postgres
3
+ # Add the pgt_* methods so that any Sequel database connecting to PostgreSQL
4
+ # can use them. All of these methods require the plpgsql procedural language
5
+ # added to the PostgreSQL database before they can be used. You can do so
6
+ # with:
7
+ #
8
+ # DB.create_language(:plpgsql)
9
+ #
10
+ # All of the public methods take the following options in their opts hash:
11
+ #
12
+ # * :function_name: The name of the function to use. This is important
13
+ # to specify if you want an easy way to drop the function.
14
+ # * :trigger_name: The name of the trigger to use. This is important
15
+ # to specify if you want an easy way to drop the trigger.
16
+ module DatabaseMethods
17
+ # Turns a column in the main table into a counter cache. A counter cache is a
18
+ # column in the main table with the number of rows in the counted table
19
+ # for the matching id. Arguments:
20
+ # * main_table : name of table holding counter cache column
21
+ # * main_table_id_column : column in main table matching counted_table_id_column in counted_table
22
+ # * counter_column : column in main table containing the counter cache
23
+ # * counted_table : name of table being counted
24
+ # * counted_table_id_column : column in counted_table matching main_table_id_column in main_table
25
+ # * opts : option hash, see module documentation
26
+ def pgt_counter_cache(main_table, main_table_id_column, counter_column, counted_table, counted_table_id_column, opts={})
27
+ trigger_name = opts[:trigger_name] || "pgt_cc_#{main_table}__#{main_table_id_column}__#{counter_column}__#{counted_table_id_column}"
28
+ function_name = opts[:function_name] || "pgt_cc_#{main_table}__#{main_table_id_column}__#{counter_column}__#{counted_table}__#{counted_table_id_column}"
29
+ pgt_trigger(counted_table, trigger_name, function_name, [:insert, :delete], <<-SQL)
30
+ BEGIN
31
+ IF (TG_OP = 'DELETE') THEN
32
+ UPDATE #{quote_schema_table(main_table)} SET #{quote_identifier(counter_column)} = #{quote_identifier(counter_column)} - 1 WHERE #{quote_identifier(main_table_id_column)} = OLD.#{counted_table_id_column};
33
+ RETURN OLD;
34
+ ELSIF (TG_OP = 'INSERT') THEN
35
+ UPDATE #{quote_schema_table(main_table)} SET #{quote_identifier(counter_column)} = #{quote_identifier(counter_column)} + 1 WHERE #{quote_identifier(main_table_id_column)} = NEW.#{quote_identifier(counted_table_id_column)};
36
+ RETURN NEW;
37
+ END IF;
38
+ END;
39
+ SQL
40
+ end
41
+
42
+ # Turns a column in the table into a created at timestamp column, which
43
+ # always contains the timestamp the record was inserted into the database.
44
+ # Arguments:
45
+ # * table : name of table
46
+ # * column : column in table that should be a created at timestamp column
47
+ # * opts : option hash, see module documentation
48
+ def pgt_created_at(table, column, opts={})
49
+ trigger_name = opts[:trigger_name] || "pgt_ca_#{column}"
50
+ function_name = opts[:function_name] || "pgt_ca_#{table}__#{column}"
51
+ pgt_trigger(table, trigger_name, function_name, [:insert, :update], <<-SQL)
52
+ BEGIN
53
+ IF (TG_OP = 'UPDATE') THEN
54
+ NEW.#{quote_identifier(column)} := OLD.#{quote_identifier(column)};
55
+ ELSIF (TG_OP = 'INSERT') THEN
56
+ NEW.#{quote_identifier(column)} := CURRENT_TIMESTAMP;
57
+ END IF;
58
+ RETURN NEW;
59
+ END;
60
+ SQL
61
+ end
62
+
63
+ # Makes all given columns in the given table immutable, so an exception
64
+ # is raised if there is an attempt to modify the value when updating the
65
+ # record. Arguments:
66
+ # * table : name of table
67
+ # * columns : All columns in the table that should be immutable. Can end with a hash of options, see module documentation.
68
+ def pgt_immutable(table, *columns)
69
+ opts = columns.last.is_a?(Hash) ? columns.pop : {}
70
+ trigger_name = opts[:trigger_name] || "pgt_im_#{columns.join('__')}"
71
+ function_name = opts[:function_name] || "pgt_im_#{columns.join('__')}"
72
+ ifs = columns.map do |c|
73
+ old = "OLD.#{quote_identifier(c)}"
74
+ new = "NEW.#{quote_identifier(c)}"
75
+ <<-END
76
+ IF #{new} != #{old} THEN
77
+ RAISE EXCEPTION 'Attempted event_id update: Old: %, New: %', #{old}, #{new};
78
+ END IF;
79
+ END
80
+ end.join("\n")
81
+ pgt_trigger(table, trigger_name, function_name, :update, "BEGIN #{ifs} RETURN NEW; END;")
82
+ end
83
+
84
+ # Turns a column in the main table into a sum cache. A sum cache is a
85
+ # column in the main table with the sum of a column in the summed table
86
+ # for the matching id. Arguments:
87
+ # * main_table : name of table holding counter cache column
88
+ # * main_table_id_column : column in main table matching counted_table_id_column in counted_table
89
+ # * sum_column : column in main table containing the sum cache
90
+ # * summed_table : name of table being summed
91
+ # * summed_table_id_column : column in summed_table matching main_table_id_column in main_table
92
+ # * summed_column : column in summed_table being summed
93
+ # * opts : option hash, see module documentation
94
+ def pgt_sum_cache(main_table, main_table_id_column, sum_column, summed_table, summed_table_id_column, summed_column, opts={})
95
+ trigger_name = opts[:trigger_name] || "pgt_sc_#{main_table}__#{main_table_id_column}__#{sum_column}__#{summed_table_id_column}"
96
+ function_name = opts[:function_name] || "pgt_sc_#{main_table}__#{main_table_id_column}__#{sum_column}__#{summed_table}__#{summed_table_id_column}__#{summed_column}"
97
+ pgt_trigger(summed_table, trigger_name, function_name, [:insert, :delete, :update], <<-SQL)
98
+ BEGIN
99
+ IF (TG_OP = 'DELETE') THEN
100
+ UPDATE #{quote_schema_table(main_table)} SET #{quote_identifier(sum_column)} = #{quote_identifier(sum_column)} - OLD.#{quote_identifier(summed_column)} WHERE #{quote_identifier(main_table_id_column)} = OLD.#{summed_table_id_column};
101
+ RETURN OLD;
102
+ ELSIF (TG_OP = 'UPDATE') THEN
103
+ UPDATE #{quote_schema_table(main_table)} SET #{quote_identifier(sum_column)} = #{quote_identifier(sum_column)} + NEW.#{quote_identifier(summed_column)} - OLD.#{quote_identifier(summed_column)} WHERE #{quote_identifier(main_table_id_column)} = NEW.#{quote_identifier(summed_table_id_column)};
104
+ RETURN NEW;
105
+ ELSIF (TG_OP = 'INSERT') THEN
106
+ UPDATE #{quote_schema_table(main_table)} SET #{quote_identifier(sum_column)} = #{quote_identifier(sum_column)} + NEW.#{quote_identifier(summed_column)} WHERE #{quote_identifier(main_table_id_column)} = NEW.#{quote_identifier(summed_table_id_column)};
107
+ RETURN NEW;
108
+ END IF;
109
+ END;
110
+ SQL
111
+ end
112
+
113
+ # Turns a column in the table into a updated at timestamp column, which
114
+ # always contains the timestamp the record was inserted or last updated.
115
+ # Arguments:
116
+ # * table : name of table
117
+ # * column : column in table that should be a updated at timestamp column
118
+ # * opts : option hash, see module documentation
119
+ def pgt_updated_at(table, column, opts={})
120
+ trigger_name = opts[:trigger_name] || "pgt_ua_#{column}"
121
+ function_name = opts[:function_name] || "pgt_ua_#{table}__#{column}"
122
+ pgt_trigger(table, trigger_name, function_name, [:insert, :update], <<-SQL)
123
+ BEGIN
124
+ NEW.#{quote_identifier(column)} := CURRENT_TIMESTAMP;
125
+ RETURN NEW;
126
+ END;
127
+ SQL
128
+ end
129
+
130
+ private
131
+
132
+ # Add or replace a function that returns trigger to handle the action,
133
+ # and add a trigger that calls the function.
134
+ def pgt_trigger(table, trigger_name, function_name, events, definition)
135
+ create_function(function_name, definition, :language=>:plpgsql, :returns=>:trigger, :replace=>true)
136
+ create_trigger(table, trigger_name, function_name, :events=>events, :each_row=>true)
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env spec
2
+ require 'rubygems'
3
+ require 'sequel'
4
+
5
+ DB = Sequel.connect(ENV['PGT_SPEC_DB']||'postgres:///spgt_test?user=_postgresql')
6
+
7
+ $:.unshift(File.join(File.dirname(File.dirname(File.expand_path(__FILE__))), 'lib'))
8
+ require 'sequel_postgresql_triggers'
9
+
10
+ context "PostgreSQL Counter Cache Trigger" do
11
+ before do
12
+ DB.create_language(:plpgsql)
13
+ DB.create_table(:accounts){integer :id; integer :num_entries, :default=>0}
14
+ DB.create_table(:entries){integer :id; integer :account_id}
15
+ DB.pgt_counter_cache(:accounts, :id, :num_entries, :entries, :account_id)
16
+ DB[:accounts] << {:id=>1}
17
+ DB[:accounts] << {:id=>2}
18
+ end
19
+
20
+ after do
21
+ DB.drop_table(:entries, :accounts)
22
+ DB.drop_language(:plpgsql, :cascade=>true)
23
+ end
24
+
25
+ specify "Should modify counter cache when adding or removing records" do
26
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 0
27
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 0
28
+ DB[:entries] << {:id=>1, :account_id=>1}
29
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 1
30
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 0
31
+ DB[:entries] << {:id=>2, :account_id=>1}
32
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 2
33
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 0
34
+ DB[:entries] << {:id=>3, :account_id=>2}
35
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 2
36
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 1
37
+ DB[:entries].filter(:id=>2).delete
38
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 1
39
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 1
40
+ DB[:entries].delete
41
+ DB[:accounts].filter(:id=>1).get(:num_entries).should == 0
42
+ DB[:accounts].filter(:id=>2).get(:num_entries).should == 0
43
+ end
44
+ end
45
+
46
+ context "PostgreSQL Created At Trigger" do
47
+ before do
48
+ DB.create_language(:plpgsql)
49
+ DB.create_table(:accounts){integer :id; timestamp :added_on}
50
+ DB.pgt_created_at(:accounts, :added_on)
51
+ end
52
+
53
+ after do
54
+ DB.drop_table(:accounts)
55
+ DB.drop_language(:plpgsql, :cascade=>true)
56
+ end
57
+
58
+ specify "Should set the column upon insertion and ignore modifications afterward" do
59
+ DB[:accounts] << {:id=>1}
60
+ t = DB[:accounts].get(:added_on)
61
+ t.strftime('%F').should == Date.today.strftime('%F')
62
+ DB[:accounts].update(:added_on=>Date.today - 60)
63
+ DB[:accounts].get(:added_on).should == t
64
+ DB[:accounts] << {:id=>2}
65
+ ds = DB[:accounts].select(:added_on)
66
+ DB[:accounts].select((Sequel::SQL::NumericExpression.new(:NOOP, ds.filter(:id=>2)) > ds.filter(:id=>1)).as(:x)).first[:x].should == true
67
+ DB[:accounts].filter(:id=>1).update(:id=>3)
68
+ DB[:accounts].select((Sequel::SQL::NumericExpression.new(:NOOP, ds.filter(:id=>2)) > ds.filter(:id=>3)).as(:x)).first[:x].should == true
69
+ end
70
+ end
71
+
72
+ context "PostgreSQL Immutable Trigger" do
73
+ before do
74
+ DB.create_language(:plpgsql)
75
+ DB.create_table(:accounts){integer :id; integer :balance, :default=>0}
76
+ DB.pgt_immutable(:accounts, :balance)
77
+ DB[:accounts] << {:id=>1}
78
+ end
79
+
80
+ after do
81
+ DB.drop_table(:accounts)
82
+ DB.drop_language(:plpgsql, :cascade=>true)
83
+ end
84
+
85
+ specify "Should allow updating a record only if the immutable column does not change" do
86
+ DB[:accounts].update(:id=>1)
87
+ DB[:accounts].update(:balance=>0)
88
+ DB[:accounts].update(:balance=>:balance * :balance)
89
+ proc{DB[:accounts].update(:balance=>1)}.should raise_error(Sequel::DatabaseError)
90
+ end
91
+ end
92
+
93
+ context "PostgreSQL Sum Cache Trigger" do
94
+ before do
95
+ DB.create_language(:plpgsql)
96
+ DB.create_table(:accounts){integer :id; integer :balance, :default=>0}
97
+ DB.create_table(:entries){integer :id; integer :account_id; integer :amount}
98
+ DB.pgt_sum_cache(:accounts, :id, :balance, :entries, :account_id, :amount)
99
+ DB[:accounts] << {:id=>1}
100
+ DB[:accounts] << {:id=>2}
101
+ end
102
+
103
+ after do
104
+ DB.drop_table(:entries, :accounts)
105
+ DB.drop_language(:plpgsql, :cascade=>true)
106
+ end
107
+
108
+ specify "Should modify sum cache when adding, updating, or removing records" do
109
+ DB[:accounts].filter(:id=>1).get(:balance).should == 0
110
+ DB[:accounts].filter(:id=>2).get(:balance).should == 0
111
+ DB[:entries] << {:id=>1, :account_id=>1, :amount=>100}
112
+ DB[:accounts].filter(:id=>1).get(:balance).should == 100
113
+ DB[:accounts].filter(:id=>2).get(:balance).should == 0
114
+ DB[:entries] << {:id=>2, :account_id=>1, :amount=>200}
115
+ DB[:accounts].filter(:id=>1).get(:balance).should == 300
116
+ DB[:accounts].filter(:id=>2).get(:balance).should == 0
117
+ DB[:entries] << {:id=>3, :account_id=>2, :amount=>500}
118
+ DB[:accounts].filter(:id=>1).get(:balance).should == 300
119
+ DB[:accounts].filter(:id=>2).get(:balance).should == 500
120
+ DB[:entries].exclude(:id=>2).update(:amount=>:amount * 2)
121
+ DB[:accounts].filter(:id=>1).get(:balance).should == 400
122
+ DB[:accounts].filter(:id=>2).get(:balance).should == 1000
123
+ DB[:entries].filter(:id=>2).delete
124
+ DB[:accounts].filter(:id=>1).get(:balance).should == 200
125
+ DB[:accounts].filter(:id=>2).get(:balance).should == 1000
126
+ DB[:entries].delete
127
+ DB[:accounts].filter(:id=>1).get(:balance).should == 0
128
+ DB[:accounts].filter(:id=>2).get(:balance).should == 0
129
+ end
130
+ end
131
+
132
+ context "PostgreSQL Updated At Trigger" do
133
+ before do
134
+ DB.create_language(:plpgsql)
135
+ DB.create_table(:accounts){integer :id; timestamp :changed_on}
136
+ DB.pgt_updated_at(:accounts, :changed_on)
137
+ end
138
+
139
+ after do
140
+ DB.drop_table(:accounts)
141
+ DB.drop_language(:plpgsql, :cascade=>true)
142
+ end
143
+
144
+ specify "Should set the column always to the current timestamp" do
145
+ DB[:accounts] << {:id=>1}
146
+ t = DB[:accounts].get(:changed_on)
147
+ t.strftime('%F').should == Date.today.strftime('%F')
148
+ DB[:accounts] << {:id=>2}
149
+ ds = DB[:accounts].select(:changed_on)
150
+ DB[:accounts].select((Sequel::SQL::NumericExpression.new(:NOOP, ds.filter(:id=>2)) > ds.filter(:id=>1)).as(:x)).first[:x].should == true
151
+ DB[:accounts].filter(:id=>1).update(:id=>3)
152
+ DB[:accounts].select((Sequel::SQL::NumericExpression.new(:NOOP, ds.filter(:id=>3)) > ds.filter(:id=>2)).as(:x)).first[:x].should == true
153
+ end
154
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sequel_postgresql_triggers
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy Evans
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-06-02 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description:
17
+ email: code@jeremyevans.net
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - README
26
+ - LICENSE
27
+ - lib/sequel_postgresql_triggers.rb
28
+ - spec/sequel_postgresql_triggers_spec.rb
29
+ has_rdoc: true
30
+ homepage:
31
+ post_install_message:
32
+ rdoc_options:
33
+ - --inline-source
34
+ - --line-numbers
35
+ - --title
36
+ - "Sequel PostgreSQL Triggers: Database enforced timestamps, immutable columns, and counter/sum caches"
37
+ - README
38
+ - LICENSE
39
+ - lib
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ version:
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: "0"
53
+ version:
54
+ requirements: []
55
+
56
+ rubyforge_project:
57
+ rubygems_version: 1.3.1
58
+ signing_key:
59
+ specification_version: 2
60
+ summary: Database enforced timestamps, immutable columns, and counter/sum caches
61
+ test_files: []
62
+