sequel_postgresql_triggers 1.0.2

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.
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
+