hairtrigger 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.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ lib/**/*.rb
2
+ bin/*
3
+ -
4
+ features/**/*.feature
5
+ LICENSE.txt
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source "http://rubygems.org"
2
+ gem "activerecord", ">=2.3.0", "<3.0"
3
+ group :development do
4
+ gem "rspec", ">= 2.3.0"
5
+ gem "bundler", "~> 1.0.0"
6
+ gem "jeweler", "~> 1.5.2"
7
+ gem "rcov", ">= 0"
8
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Jon Jensen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,134 @@
1
+ = HairTrigger
2
+
3
+ HairTrigger lets you create and manage database triggers in a concise,
4
+ db-agnostic, Rails-y way.
5
+
6
+ == Install
7
+
8
+ Assuming you're using bundler:
9
+
10
+ 1. install the gem
11
+ 2. let rails know about it (via Gemfile, environment.rb, whatever)
12
+ 3. create lib/tasks/hair_trigger.rake with the following:
13
+
14
+ $VERBOSE = nil
15
+ Dir["#{Gem.searcher.find('hair_trigger').full_gem_path}/lib/tasks/*.rake"].each { |ext| load ext }
16
+
17
+ An alternative to steps 2 and 3 is to unpack it in vendor/plugins and delete
18
+ its Gemfile.
19
+
20
+ == Usage
21
+
22
+ Declare triggers in your models and use a rake task to auto-generate the
23
+ appropriate migration. For example:
24
+
25
+ class AccountUser < ActiveRecord::Base
26
+ trigger.after(:insert) do
27
+ "UPDATE accounts SET user_count = user_count + 1 WHERE id = NEW.account_id;"
28
+ end
29
+ end
30
+
31
+ and then:
32
+
33
+ rake db:generate_trigger_migration
34
+
35
+ This will create a db-agnostic migration for the trigger that mirrors the
36
+ model declaration. You can also manage triggers manually in your migrations
37
+ (via create_trigger/drop_trigger), though that ends up being more work.
38
+
39
+ Note that these auto-generated create_trigger statements contain the
40
+ ":generated => true" option, indicating that they were created from the
41
+ model definition. This is important, as the rake task will also generate
42
+ appropriate drop/create statements for any model triggers that get removed
43
+ or updated. It does this by diffing the current model trigger declarations
44
+ and any auto-generated triggers from previous migrations.
45
+
46
+ == Chainable Methods
47
+
48
+ Triggers are built by chaining several methods together, ending in a block
49
+ that specifies the SQL to be run when the trigger fires. Supported methods
50
+ include:
51
+
52
+ * name(trigger_name): Optional, inferred from other calls.
53
+ * on(table_name): Ignored in models, required in migrations.
54
+ * for_each(item): Defaults to :row, PostgreSQL/MySQL allow :statement.
55
+ * before(*events): Shorthand for timing(:before).events(*events).
56
+ * after(*events): Shorthand for timing(:after).events(*events).
57
+ * where(conditions): Optional, limits when the trigger will fire.
58
+ * security(user): Permissions/role to check when calling trigger, defaults to :invoker. PostgreSQL supports :definer, MySQL supports :definer and arbitrary users (syntax: 'user'@'host').
59
+ * timing(timing): Required (but may be satisified by before/after). Possible values are :before/:after.
60
+ * events(*events): Required (but may be satisified by before/after). MySQL/SQLite only support one action.
61
+ * all: Noop, useful for trigger groups (see below).
62
+
63
+ == Trigger Groups
64
+
65
+ Trigger groups allow you to use a slightly more concise notation if you have
66
+ several triggers that fire on a given model. This is also important for MySQL,
67
+ since it does not support multiple triggers on a table for the same action
68
+ and timing. For example:
69
+
70
+ trigger.after(:update) do |t|
71
+ t.all do # every row
72
+ # some sql
73
+ end
74
+ t.where("OLD.foo <> NEW.foo") do
75
+ # some more sql
76
+ end
77
+ t.where("OLD.bar <> NEW.bar") do
78
+ # some other sql
79
+ end
80
+ end
81
+
82
+ For MySQL, this will just create a single trigger with conditional logic
83
+ (since it doesn't support multiple triggers). PostgreSQL and SQLite will have
84
+ distinct triggers. This same notation is also used within trigger migrations.
85
+ MySQL does not currently support nested trigger groups.
86
+
87
+ == Testing
88
+
89
+ To stay on top of things, it's strongly recommended that you add a test or
90
+ spec to ensure your migrations match your models. This is as simple as:
91
+
92
+ assert HairTrigger::migrations_current?
93
+
94
+ This way you'll know if there are any outstanding migrations you need to
95
+ create.
96
+
97
+ == Warnings and Errors
98
+
99
+ If you try something your adapter doesn't support (e.g. multiple triggering
100
+ events for MySQL), you will get an error. If your adapter does support it, you
101
+ will just get a warning to let you know your trigger is not portable. You can
102
+ silence warnings via `HairTrigger::Builder.show_warnings = false`
103
+
104
+ HairTrigger does not validate your SQL, so be sure to test it in all database
105
+ you want to support.
106
+
107
+ == Gotchas, Known Issues
108
+
109
+ * HairTrigger does not check config.active_record.timestamped_migrations, it
110
+ always assumes it is true. As a workaround, you can rename the migration.
111
+ * As is the case with ActiveRecord::Base#update_all or any direct SQL you do,
112
+ be careful to reload updated objects from the database. For example, the
113
+ following code will display the wrong count since we aren't reloading the
114
+ account:
115
+ a = Account.find(123)
116
+ a.account_users.create(:name => 'bob')
117
+ puts "count is now #{a.user_count}"
118
+ * For repeated chained calls, the last one wins, there is currently no
119
+ merging.
120
+ * If you want your code to be portable, the trigger actions should be
121
+ limited to INSERT/UPDATE/DELETE/SELECT, and conditional logic should be
122
+ handled through the :where option/method. Otherwise you'll likely run into
123
+ trouble due to differences in syntax and supported features.
124
+
125
+ == Compatibility
126
+
127
+ * Rails 2.3.x
128
+ * Postgres 9.0+
129
+ * MySQL 5.0+
130
+ * SQLite 3.0+
131
+
132
+ == Copyright
133
+
134
+ Copyright (c) 2011 Jon Jensen. See LICENSE.txt for further details.
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ begin
4
+ Bundler.setup(:default, :development)
5
+ rescue Bundler::BundlerError => e
6
+ $stderr.puts e.message
7
+ $stderr.puts "Run `bundle install` to install missing gems"
8
+ exit e.status_code
9
+ end
10
+ require 'rake'
11
+
12
+ require 'jeweler'
13
+ Jeweler::Tasks.new do |gem|
14
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
15
+ gem.name = "hairtrigger"
16
+ gem.homepage = "http://github.com/jenseng/hairtrigger"
17
+ gem.license = "MIT"
18
+ gem.summary = %Q{easy database triggers for active record}
19
+ gem.description = %Q{}
20
+ gem.email = "jenseng@gmail.com"
21
+ gem.authors = ["Jon Jensen"]
22
+ gem.add_runtime_dependency "activerecord", ">=2.3.0", "<3.0"
23
+ gem.add_development_dependency "rspec", "~> 2.3.0"
24
+ end
25
+ Jeweler::RubygemsDotOrgTasks.new
26
+
27
+ require 'rspec/core'
28
+ require 'rspec/core/rake_task'
29
+ RSpec::Core::RakeTask.new(:spec) do |spec|
30
+ spec.pattern = FileList['spec/**/*_spec.rb']
31
+ end
32
+
33
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
34
+ spec.pattern = 'spec/**/*_spec.rb'
35
+ spec.rcov = true
36
+ end
37
+
38
+ task :default => :spec
39
+
40
+ require 'rake/rdoctask'
41
+ Rake::RDocTask.new do |rdoc|
42
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
43
+
44
+ rdoc.rdoc_dir = 'rdoc'
45
+ rdoc.title = "hairtrigger #{version}"
46
+ rdoc.rdoc_files.include('README*')
47
+ rdoc.rdoc_files.include('lib/**/*.rb')
48
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'hair_trigger'
@@ -0,0 +1,50 @@
1
+ module HairTrigger
2
+ def self.current_triggers
3
+ # see what the models say there should be
4
+ canonical_triggers = []
5
+ Dir['app/models/*rb'].each do |model|
6
+ class_name = model.sub(/\A.*\/(.*?)\.rb\z/, '\1').camelize
7
+ begin
8
+ klass = Kernel.const_get(class_name)
9
+ rescue
10
+ raise "unable to load #{class_name} and its trigger(s)" if File.read(model) =~ /^\s*trigger[\.\(]/
11
+ next
12
+ end
13
+ canonical_triggers += klass.triggers if klass < ActiveRecord::Base && klass.triggers
14
+ end
15
+ canonical_triggers.each(&:prepare!) # interpolates any vars so we match the migrations
16
+ end
17
+
18
+ def self.current_migrations
19
+ ActiveRecord::Migration.verbose = false
20
+ ActiveRecord::Migration.extract_trigger_builders = true
21
+
22
+ # see what generated triggers are defined by the migrations
23
+ migrations = []
24
+ migrator = ActiveRecord::Migrator.new(:up, 'db/migrate')
25
+ migrator.migrations.select{ |migration|
26
+ File.read(migration.filename) =~ /(create|drop)_trigger.*:generated *=> *true/
27
+ }.each do |migration|
28
+ migration.migrate(:up)
29
+ migration.trigger_builders.each do |new_trigger|
30
+ # if there is already a trigger with this name, delete it since we are
31
+ # either dropping it or replacing it
32
+ migrations.delete_if{ |(n, t)| t.prepared_name == new_trigger.prepared_name }
33
+ migrations << [migration.name, new_trigger] unless new_trigger.options[:drop]
34
+ end
35
+ end
36
+ migrations.each{ |(n, t)| t.prepare! }
37
+ ensure
38
+ ActiveRecord::Migration.verbose = true
39
+ ActiveRecord::Migration.extract_trigger_builders = false
40
+ end
41
+
42
+ def self.migrations_current?
43
+ current_migrations.map(&:last).sort == current_triggers.sort
44
+ end
45
+ end
46
+
47
+ ActiveRecord::Base.send :extend, HairTrigger::Base
48
+ ActiveRecord::Migration.send :extend, HairTrigger::Migration
49
+ ActiveRecord::MigrationProxy.send :delegate, :trigger_builders, :to=>:migration
50
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval { include HairTrigger::Adapter }
@@ -0,0 +1,15 @@
1
+ module HairTrigger
2
+ module Adapter
3
+ def create_trigger(name = nil, options = {})
4
+ if name.is_a?(Hash)
5
+ options = name
6
+ name = nil
7
+ end
8
+ ::HairTrigger::Builder.new(name, options.merge(:execute => true))
9
+ end
10
+
11
+ def drop_trigger(name, table, options = {})
12
+ ::HairTrigger::Builder.new(name, options.merge(:execute => true, :drop => true, :table => table)){}
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ module HairTrigger
2
+ module Base
3
+ attr_reader :triggers
4
+
5
+ def trigger(name = nil, options = {})
6
+ if name.is_a?(Hash)
7
+ options = name
8
+ name = nil
9
+ end
10
+ @triggers ||= []
11
+ trigger = ::HairTrigger::Builder.new(name, options)
12
+ @triggers << trigger
13
+ trigger.on(table_name)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,348 @@
1
+ module HairTrigger
2
+ class Builder
3
+ attr_accessor :options
4
+ attr_reader :triggers # nil unless this is a trigger group
5
+ attr_reader :prepared_actions, :prepared_where # after delayed interpolation
6
+
7
+ def initialize(name = nil, options = {})
8
+ @adapter = options[:adapter] || ActiveRecord::Base.connection rescue nil
9
+ @options = {}
10
+ @chained_calls = []
11
+ set_name(name) if name
12
+ {:timing => :after, :for_each => :row}.update(options).each do |key, value|
13
+ if respond_to?("set_#{key}")
14
+ send("set_#{key}", *Array[value])
15
+ else
16
+ @options[key] = value
17
+ end
18
+ end
19
+ end
20
+
21
+ def initialize_copy(other)
22
+ @trigger_group = other
23
+ @triggers = nil
24
+ @chained_calls = []
25
+ @options = @options.dup
26
+ @options.delete(:name) # this will be inferred (or set further down the line)
27
+ @options.each do |key, value|
28
+ @options[key] = value.dup rescue value
29
+ end
30
+ end
31
+
32
+ def drop_triggers
33
+ all_names.map{ |name| self.class.new(name, {:table => options[:table], :drop => true}) }
34
+ end
35
+
36
+ def name(name)
37
+ raise_or_warn "trigger name cannot exceed 63 for postgres", :postgresql if name.to_s.size > 63
38
+ options[:name] = name.to_s
39
+ end
40
+
41
+ def on(table)
42
+ raise "table has already been specified" if options[:table]
43
+ options[:table] = table.to_s
44
+ end
45
+
46
+ def for_each(for_each)
47
+ raise_or_warn "sqlite doesn't support FOR EACH STATEMENT triggers", :sqlite if for_each == :statement
48
+ raise "invalid for_each" unless [:row, :statement].include?(for_each)
49
+ options[:for_each] = for_each.to_s.upcase
50
+ end
51
+
52
+ def before(*events)
53
+ set_timing(:before)
54
+ set_events(*events)
55
+ end
56
+
57
+ def after(*events)
58
+ set_timing(:after)
59
+ set_events(*events)
60
+ end
61
+
62
+ def where(where)
63
+ options[:where] = where
64
+ end
65
+
66
+ # noop, just a way you can pass a block within a trigger group
67
+ def all
68
+ end
69
+
70
+ def security(user)
71
+ return if user == :invoker # default behavior
72
+ raise_or_warn "sqlite doesn't support trigger security clauses", :sqlite
73
+ raise_or_warn "postgresql doesn't support arbitrary users for security clauses", :postgresql unless user == :definer
74
+ options[:security] = user
75
+ end
76
+
77
+ def timing(timing)
78
+ raise "invalid timing" unless [:before, :after].include?(timing)
79
+ options[:timing] = timing.to_s.upcase
80
+ end
81
+
82
+ def events(*events)
83
+ events << :insert if events.delete(:create)
84
+ events << :delete if events.delete(:destroy)
85
+ raise "invalid events" unless events & [:insert, :update, :delete] == events
86
+ raise_or_warn "sqlite and mysql triggers may not be shared by multiple actions", :mysql, :sqlite if events.size > 1
87
+ options[:events] = events.map{ |e| e.to_s.upcase }
88
+ end
89
+
90
+ def prepared_name
91
+ @prepared_name ||= options[:name] ||= infer_name
92
+ end
93
+
94
+ def all_names
95
+ [prepared_name] + (@triggers ? @triggers.map(&:prepared_name) : [])
96
+ end
97
+
98
+ def self.chainable_methods(*methods)
99
+ methods.each do |method|
100
+ class_eval <<-METHOD
101
+ alias #{method}_orig #{method}
102
+ def #{method}(*args)
103
+ @chained_calls << :#{method}
104
+ if @triggers || @trigger_group
105
+ raise_or_warn "mysql doesn't support #{method} within a trigger group", :mysql unless [:name, :where, :all].include?(:#{method})
106
+ end
107
+ set_#{method}(*args, &(block_given? ? Proc.new : nil))
108
+ end
109
+ def set_#{method}(*args)
110
+ if @triggers # i.e. each time we say t.something within a trigger group block
111
+ @chained_calls.pop # the subtrigger will get this, we don't need it
112
+ @chained_calls = @chained_calls.uniq
113
+ @triggers << trigger = clone
114
+ trigger.#{method}(*args, &Proc.new)
115
+ else
116
+ #{method}_orig(*args)
117
+ maybe_execute(&Proc.new) if block_given?
118
+ self
119
+ end
120
+ end
121
+ METHOD
122
+ end
123
+ end
124
+ chainable_methods :name, :on, :for_each, :before, :after, :where, :security, :timing, :events, :all
125
+
126
+ def create_grouped_trigger?
127
+ adapter_name == :mysql
128
+ end
129
+
130
+ def prepare!
131
+ @triggers.each(&:prepare!) if @triggers
132
+ @prepared_where = options[:where] = interpolate(options[:where]) if options[:where]
133
+ @prepared_actions = interpolate(@actions).rstrip if @actions
134
+ end
135
+
136
+ def generate
137
+ return @triggers.map(&:generate).flatten if @triggers && !create_grouped_trigger?
138
+ prepare!
139
+ raise "need to specify the table" unless options[:table]
140
+ if options[:drop]
141
+ generate_drop_trigger
142
+ else
143
+ raise "no actions specified" if @triggers && create_grouped_trigger? ? @triggers.any?{ |t| t.prepared_actions.nil? } : prepared_actions.nil?
144
+ raise "need to specify the event(s) (:insert, :update, :delete)" if !options[:events] || options[:events].empty?
145
+ raise "need to specify the timing (:before/:after)" unless options[:timing]
146
+
147
+ ret = [generate_drop_trigger]
148
+ ret << case adapter_name
149
+ when :sqlite
150
+ generate_trigger_sqlite
151
+ when :mysql
152
+ generate_trigger_mysql
153
+ when :postgresql
154
+ generate_trigger_postgresql
155
+ else
156
+ raise "don't know how to build #{adapter_name} triggers yet"
157
+ end
158
+ ret
159
+ end
160
+ end
161
+
162
+ def to_ruby(indent = '')
163
+ prepare!
164
+ if options[:drop]
165
+ "#{indent}drop_trigger(#{prepared_name.inspect}, #{options[:table].inspect}, :generated => true)"
166
+ else
167
+ if @trigger_group
168
+ str = "t." + chained_calls_to_ruby + " do\n"
169
+ str << actions_to_ruby("#{indent} ") + "\n"
170
+ str << "#{indent}end"
171
+ else
172
+ str = "#{indent}create_trigger(#{prepared_name.inspect}, :generated => true).\n" +
173
+ "#{indent} " + chained_calls_to_ruby(".\n#{indent} ")
174
+ if @triggers
175
+ str << " do |t|\n"
176
+ str << "#{indent} " + @triggers.map{ |t| t.to_ruby("#{indent} ") }.join("\n\n#{indent} ") + "\n"
177
+ else
178
+ str << " do\n"
179
+ str << actions_to_ruby("#{indent} ") + "\n"
180
+ end
181
+ str << "#{indent}end"
182
+ end
183
+ end
184
+ end
185
+
186
+ def <=>(other)
187
+ ret = prepared_name <=> other.prepared_name
188
+ ret == 0 ? hash <=> other.hash : ret
189
+ end
190
+
191
+ def eql?(other)
192
+ return false unless other.is_a?(HairTrigger::Builder)
193
+ hash == other.hash
194
+ end
195
+
196
+ def hash
197
+ prepare!
198
+ [self.options.hash, self.prepared_actions.hash, self.prepared_where.hash, self.triggers.hash].hash
199
+ end
200
+
201
+ private
202
+
203
+ def chained_calls_to_ruby(join_str = '.')
204
+ @chained_calls.map { |c|
205
+ case c
206
+ when :before, :after, :events
207
+ "#{c}(#{options[:events].map{|c|c.downcase.to_sym.inspect}.join(', ')})"
208
+ when :on
209
+ "on(#{options[:table].inspect})"
210
+ when :where
211
+ "where(#{prepared_where.inspect})"
212
+ else
213
+ "#{c}(#{options[c].inspect})"
214
+ end
215
+ }.join(join_str)
216
+ end
217
+
218
+ def actions_to_ruby(indent = '')
219
+ if prepared_actions =~ /\n/
220
+ "#{indent}<<-SQL_ACTIONS\n#{prepared_actions}\n#{indent}SQL_ACTIONS"
221
+ else
222
+ indent + prepared_actions.inspect
223
+ end
224
+ end
225
+
226
+ def maybe_execute(&block)
227
+ if block.arity > 0 # we're creating a trigger group, so set up some stuff and pass the buck
228
+ raise_or_warn "trigger group must specify timing and event(s)", :mysql unless options[:timing] && options[:events]
229
+ raise_or_warn "nested trigger groups are not supported for mysql", :mysql if create_grouped_trigger? && @trigger_group
230
+ @triggers = []
231
+ block.call(self)
232
+ raise "trigger group did not define any triggers" if @triggers.empty?
233
+ else
234
+ @actions = block.call
235
+ end
236
+ # only the top-most block actually executes
237
+ Array(generate).each{ |action| @adapter.execute(action)} if options[:execute] && !@trigger_group
238
+ self
239
+ end
240
+
241
+ def adapter_name
242
+ @adapter_name ||= @adapter.adapter_name.downcase.to_sym
243
+ end
244
+
245
+ def infer_name
246
+ [options[:table],
247
+ options[:timing],
248
+ options[:events],
249
+ options[:for_each],
250
+ prepared_where ? 'when_' + prepared_where : nil
251
+ ].flatten.compact.
252
+ join("_").downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_')[0, 60] + "_tr"
253
+ end
254
+
255
+ def generate_drop_trigger
256
+ case adapter_name
257
+ when :sqlite, :mysql
258
+ "DROP TRIGGER IF EXISTS #{prepared_name};\n"
259
+ when :postgresql
260
+ "DROP TRIGGER IF EXISTS #{prepared_name} ON #{options[:table]};\nDROP FUNCTION IF EXISTS #{prepared_name}();\n"
261
+ else
262
+ raise "don't know how to drop #{adapter_name} triggers yet"
263
+ end
264
+ end
265
+
266
+ def generate_trigger_sqlite
267
+ <<-SQL
268
+ CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events]} ON #{options[:table]}
269
+ FOR EACH #{options[:for_each]}#{prepared_where ? " WHEN " + prepared_where : ''}
270
+ BEGIN
271
+ #{normalize(prepared_actions, 1).rstrip}
272
+ END;
273
+ SQL
274
+ end
275
+
276
+ def generate_trigger_postgresql
277
+ <<-SQL
278
+ CREATE FUNCTION #{prepared_name}()
279
+ RETURNS TRIGGER AS $$
280
+ BEGIN
281
+ #{normalize(prepared_actions, 1).rstrip}
282
+ END;
283
+ $$ LANGUAGE plpgsql#{options[:security] ? " SECURITY #{options[:security].to_s.upcase}" : ""};
284
+ CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].join(" OR ")} ON #{options[:table]}
285
+ FOR EACH #{options[:for_each]}#{prepared_where ? " WHEN (" + prepared_where + ')': ''} EXECUTE PROCEDURE #{prepared_name}();
286
+ SQL
287
+ end
288
+
289
+ def generate_trigger_mysql
290
+ security = options[:security]
291
+ if security == :definer
292
+ config = @adapter.instance_variable_get(:@config)
293
+ security = "'#{config[:username]}'@'#{config[:host]}'"
294
+ end
295
+ sql = <<-SQL
296
+ CREATE #{security ? "DEFINER = #{security} " : ""}TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].first} ON #{options[:table]}
297
+ FOR EACH #{options[:for_each]}
298
+ BEGIN
299
+ SQL
300
+ (@triggers ? @triggers : [self]).each do |trigger|
301
+ if trigger.prepared_where
302
+ sql << normalize("IF #{trigger.prepared_where} THEN", 1)
303
+ sql << normalize(trigger.prepared_actions, 2)
304
+ sql << normalize("END IF;", 1)
305
+ else
306
+ sql << normalize(trigger.prepared_actions, 1)
307
+ end
308
+ end
309
+ sql << "END\n";
310
+ end
311
+
312
+ def interpolate(str)
313
+ eval("%@#{str.gsub('@', '\@')}@")
314
+ end
315
+
316
+ def normalize(text, level = 0)
317
+ indent = level * self.class.tab_spacing
318
+ text.gsub!(/\t/, ' ' * self.class.tab_spacing)
319
+ existing = text.split(/\n/).map{ |line| line.sub(/[^ ].*/, '').size }.min
320
+ if existing > indent
321
+ text.gsub!(/^ {#{existing - indent}}/, '')
322
+ elsif indent > existing
323
+ text.gsub!(/^/, ' ' * (indent - existing))
324
+ end
325
+ text.rstrip + "\n"
326
+ end
327
+
328
+ def raise_or_warn(message, *adapters)
329
+ if adapters.include?(adapter_name)
330
+ raise message
331
+ else
332
+ $stderr.puts "WARNING: " + message if self.class.show_warnings
333
+ end
334
+ end
335
+
336
+ class << self
337
+ attr_writer :tab_spacing
338
+ attr_writer :show_warnings
339
+ def tab_spacing
340
+ @tab_spacing ||= 4
341
+ end
342
+ def show_warnings
343
+ @show_warnings = true if @show_warnings.nil?
344
+ @show_warnings
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,34 @@
1
+ module HairTrigger
2
+ module Migration
3
+ attr_reader :trigger_builders
4
+
5
+ def method_missing_with_trigger_building(method, *arguments, &block)
6
+ if extract_trigger_builders
7
+ if method.to_sym == :create_trigger
8
+ arguments.unshift(nil) if arguments.first.is_a?(Hash)
9
+ trigger = ::HairTrigger::Builder.new(*arguments) if arguments[1].delete(:generated)
10
+ (@trigger_builders ||= []) << trigger
11
+ trigger
12
+ elsif method.to_sym == :drop_trigger
13
+ trigger = ::HairTrigger::Builder.new(arguments[0], {:table => arguments[1], :drop => true}) if arguments[2] && arguments[2].delete(:generated)
14
+ (@trigger_builders ||= []) << trigger
15
+ trigger
16
+ end
17
+ # normally we would fall through to the connection for everything
18
+ # else, but we don't want to do that since we are not actually
19
+ # running the migration
20
+ else
21
+ method_missing_without_trigger_building(method, *arguments, &block)
22
+ end
23
+ end
24
+
25
+ def self.extended(base)
26
+ base.class_eval do
27
+ class << self
28
+ alias_method_chain :method_missing, :trigger_building
29
+ cattr_accessor :extract_trigger_builders
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,77 @@
1
+ namespace :db do
2
+ desc "Creates a database migration for any newly created/modified/deleted triggers in the models"
3
+ task :generate_trigger_migration => :environment do
4
+
5
+ begin
6
+ canonical_triggers = HairTrigger::current_triggers
7
+ rescue
8
+ $stderr.puts $!
9
+ exit 1
10
+ end
11
+
12
+ migrations = HairTrigger::current_migrations
13
+ migration_names = migrations.map(&:first)
14
+ existing_triggers = migrations.map(&:last)
15
+
16
+ up_drop_triggers = []
17
+ up_create_triggers = []
18
+ down_drop_triggers = []
19
+ down_create_triggers = []
20
+
21
+ existing_triggers.each do |existing|
22
+ unless canonical_triggers.any?{ |t| t.prepared_name == existing.prepared_name }
23
+ up_drop_triggers += existing.drop_triggers
24
+ down_create_triggers << existing
25
+ end
26
+ end
27
+
28
+ (canonical_triggers - existing_triggers).each do |new_trigger|
29
+ up_create_triggers << new_trigger
30
+ down_drop_triggers += new_trigger.drop_triggers
31
+ if existing = existing_triggers.detect{ |t| t.prepared_name == new_trigger.prepared_name }
32
+ # it's not sufficient to rely on the new trigger to replace the old
33
+ # one, since we could be dealing with trigger groups and the name
34
+ # alone isn't sufficient to know which component triggers to remove
35
+ up_drop_triggers += existing.drop_triggers
36
+ down_create_triggers << existing
37
+ end
38
+ end
39
+
40
+ unless up_drop_triggers.empty? && up_create_triggers.empty?
41
+ migration_base_name = if up_create_triggers.size > 0
42
+ ("create trigger#{up_create_triggers.size > 1 ? 's' : ''} " +
43
+ up_create_triggers.map{ |t| [t.options[:table], t.options[:events].join(" ")].join(" ") }.join(" and ")
44
+ ).downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_').camelize
45
+ else
46
+ ("drop trigger#{up_drop_triggers.size > 1 ? 's' : ''} " +
47
+ up_drop_triggers.map{ |t| t.options[:table] }.join(" and ")
48
+ ).downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_').camelize
49
+ end
50
+
51
+ name_version = nil
52
+ while migration_names.include?("#{migration_base_name}#{name_version}")
53
+ name_version = name_version.to_i + 1
54
+ end
55
+ migration_name = "#{migration_base_name}#{name_version}"
56
+ file_name = 'db/migrate/' + Time.now.getutc.strftime("%Y%m%d%H%M%S") + "_" + migration_name.underscore + ".rb"
57
+ File.open(file_name, "w"){ |f| f.write <<-MIGRATION }
58
+ # This migration was auto-generated via `rake db:generate_trigger_migration'.
59
+ # While you can edit this file, any changes you make to the definitions here
60
+ # will be undone by the next auto-generated trigger migration.
61
+
62
+ class #{migration_name} < ActiveRecord::Migration
63
+ def self.up
64
+ #{(up_drop_triggers + up_create_triggers).map{ |t| t.to_ruby(' ') }.join("\n").lstrip}
65
+ end
66
+
67
+ def self.down
68
+ #{(down_drop_triggers + down_create_triggers).map{ |t| t.to_ruby(' ') }.join("\n").lstrip}
69
+ end
70
+ end
71
+ MIGRATION
72
+ puts "Generated #{file_name}"
73
+ else
74
+ puts "Nothing to do"
75
+ end
76
+ end
77
+ end
data/rails/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'hair_trigger'
@@ -0,0 +1,140 @@
1
+ require 'rspec'
2
+ require File.expand_path(File.dirname(__FILE__) + '/../lib/hair_trigger/builder.rb')
3
+
4
+ HairTrigger::Builder.show_warnings = false
5
+
6
+ class MockAdapter
7
+ attr_reader :adapter_name, :config
8
+ def initialize(type, user = nil, host = nil)
9
+ @adapter_name = type
10
+ @config = {:username => user, :host => host}
11
+ end
12
+ end
13
+
14
+ def builder
15
+ HairTrigger::Builder.new(nil, :adapter => @adapter)
16
+ end
17
+
18
+ describe "builder" do
19
+ context "chaining" do
20
+ it "should use the last redundant chained call" do
21
+ @adapter = MockAdapter.new("mysql", "user", "host")
22
+ builder.where(:foo).where(:bar).options[:where].should be(:bar)
23
+ end
24
+ end
25
+
26
+ context "mysql" do
27
+ before(:each) do
28
+ @adapter = MockAdapter.new("mysql", "user", "host")
29
+ end
30
+
31
+ it "should create a single trigger for a group" do
32
+ trigger = builder.on(:foos).after(:update){ |t|
33
+ t.where('BAR'){ 'BAR' }
34
+ t.where('BAZ'){ 'BAZ' }
35
+ }
36
+ trigger.generate.grep(/CREATE.*TRIGGER/).size.should eql(1)
37
+ end
38
+
39
+ it "should disallow nested groups" do
40
+ lambda {
41
+ builder.on(:foos){ |t|
42
+ t.after(:update){ |t|
43
+ t.where('BAR'){ 'BAR' }
44
+ t.where('BAZ'){ 'BAZ' }
45
+ }
46
+ }
47
+ }.should raise_error
48
+ end
49
+
50
+ it "should accept security" do
51
+ builder.on(:foos).after(:update).security(:definer){ "FOO" }.generate.
52
+ grep(/DEFINER = 'user'@'host'/).size.should eql(1)
53
+ end
54
+
55
+ it "should reject multiple timings" do
56
+ lambda { builder.on(:foos).after(:update, :delete){ "FOO" } }.
57
+ should raise_error
58
+ end
59
+ end
60
+
61
+ context "postgresql" do
62
+ before(:each) do
63
+ @adapter = MockAdapter.new("postgresql")
64
+ end
65
+
66
+ it "should create multiple triggers for a group" do
67
+ trigger = builder.on(:foos).after(:update){ |t|
68
+ t.where('BAR'){ 'BAR' }
69
+ t.where('BAZ'){ 'BAZ' }
70
+ }
71
+ trigger.generate.grep(/CREATE.*TRIGGER/).size.should eql(2)
72
+ end
73
+
74
+ it "should allow nested groups" do
75
+ trigger = builder.on(:foos){ |t|
76
+ t.after(:update){ |t|
77
+ t.where('BAR'){ 'BAR' }
78
+ t.where('BAZ'){ 'BAZ' }
79
+ }
80
+ t.after(:insert){ 'BAZ' }
81
+ }
82
+ trigger.generate.grep(/CREATE.*TRIGGER/).size.should eql(3)
83
+ end
84
+
85
+ it "should accept security" do
86
+ builder.on(:foos).after(:update).security(:definer){ "FOO" }.generate.
87
+ grep(/SECURITY DEFINER/).size.should eql(1)
88
+ end
89
+
90
+ it "should accept multiple timings" do
91
+ builder.on(:foos).after(:update, :delete){ "FOO" }.generate.
92
+ grep(/UPDATE OR DELETE/).size.should eql(1)
93
+ end
94
+
95
+ it "should reject long names" do
96
+ lambda { builder.name('A'*65).on(:foos).after(:update){ "FOO" }}.
97
+ should raise_error
98
+ end
99
+ end
100
+
101
+ context "sqlite" do
102
+ before(:each) do
103
+ @adapter = MockAdapter.new("sqlite")
104
+ end
105
+
106
+ it "should create multiple triggers for a group" do
107
+ trigger = builder.on(:foos).after(:update){ |t|
108
+ t.where('BAR'){ 'BAR' }
109
+ t.where('BAZ'){ 'BAZ' }
110
+ }
111
+ trigger.generate.grep(/CREATE.*TRIGGER/).size.should eql(2)
112
+ end
113
+
114
+ it "should allow nested groups" do
115
+ trigger = builder.on(:foos){ |t|
116
+ t.after(:update){ |t|
117
+ t.where('BAR'){ 'BAR' }
118
+ t.where('BAZ'){ 'BAZ' }
119
+ }
120
+ t.after(:insert){ 'BAZ' }
121
+ }
122
+ trigger.generate.grep(/CREATE.*TRIGGER/).size.should eql(3)
123
+ end
124
+
125
+ it "should reject security" do
126
+ lambda { builder.on(:foos).after(:update).security(:definer){ "FOO" } }.
127
+ should raise_error
128
+ end
129
+
130
+ it "should reject for_each :statement" do
131
+ lambda { builder.on(:foos).after(:update).for_each(:statement){ "FOO" } }.
132
+ should raise_error
133
+ end
134
+
135
+ it "should reject multiple timings" do
136
+ lambda { builder.on(:foos).after(:update, :delete){ "FOO" } }.
137
+ should raise_error
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,12 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+ require 'rspec'
4
+ require 'hairtrigger'
5
+
6
+ # Requires supporting files with custom matchers and macros, etc,
7
+ # in ./support/ and its subdirectories.
8
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
9
+
10
+ RSpec.configure do |config|
11
+
12
+ end
metadata ADDED
@@ -0,0 +1,208 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hairtrigger
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Jon Jensen
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-03-21 00:00:00 -06:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ requirement: &id001 !ruby/object:Gem::Requirement
23
+ none: false
24
+ requirements:
25
+ - - <
26
+ - !ruby/object:Gem::Version
27
+ hash: 7
28
+ segments:
29
+ - 3
30
+ - 0
31
+ version: "3.0"
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ hash: 3
35
+ segments:
36
+ - 2
37
+ - 3
38
+ - 0
39
+ version: 2.3.0
40
+ version_requirements: *id001
41
+ name: activerecord
42
+ prerelease: false
43
+ type: :runtime
44
+ - !ruby/object:Gem::Dependency
45
+ requirement: &id002 !ruby/object:Gem::Requirement
46
+ none: false
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ hash: 3
51
+ segments:
52
+ - 2
53
+ - 3
54
+ - 0
55
+ version: 2.3.0
56
+ version_requirements: *id002
57
+ name: rspec
58
+ prerelease: false
59
+ type: :development
60
+ - !ruby/object:Gem::Dependency
61
+ requirement: &id003 !ruby/object:Gem::Requirement
62
+ none: false
63
+ requirements:
64
+ - - ~>
65
+ - !ruby/object:Gem::Version
66
+ hash: 23
67
+ segments:
68
+ - 1
69
+ - 0
70
+ - 0
71
+ version: 1.0.0
72
+ version_requirements: *id003
73
+ name: bundler
74
+ prerelease: false
75
+ type: :development
76
+ - !ruby/object:Gem::Dependency
77
+ requirement: &id004 !ruby/object:Gem::Requirement
78
+ none: false
79
+ requirements:
80
+ - - ~>
81
+ - !ruby/object:Gem::Version
82
+ hash: 7
83
+ segments:
84
+ - 1
85
+ - 5
86
+ - 2
87
+ version: 1.5.2
88
+ version_requirements: *id004
89
+ name: jeweler
90
+ prerelease: false
91
+ type: :development
92
+ - !ruby/object:Gem::Dependency
93
+ requirement: &id005 !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ hash: 3
99
+ segments:
100
+ - 0
101
+ version: "0"
102
+ version_requirements: *id005
103
+ name: rcov
104
+ prerelease: false
105
+ type: :development
106
+ - !ruby/object:Gem::Dependency
107
+ requirement: &id006 !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - <
111
+ - !ruby/object:Gem::Version
112
+ hash: 7
113
+ segments:
114
+ - 3
115
+ - 0
116
+ version: "3.0"
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ hash: 3
120
+ segments:
121
+ - 2
122
+ - 3
123
+ - 0
124
+ version: 2.3.0
125
+ version_requirements: *id006
126
+ name: activerecord
127
+ prerelease: false
128
+ type: :runtime
129
+ - !ruby/object:Gem::Dependency
130
+ requirement: &id007 !ruby/object:Gem::Requirement
131
+ none: false
132
+ requirements:
133
+ - - ~>
134
+ - !ruby/object:Gem::Version
135
+ hash: 3
136
+ segments:
137
+ - 2
138
+ - 3
139
+ - 0
140
+ version: 2.3.0
141
+ version_requirements: *id007
142
+ name: rspec
143
+ prerelease: false
144
+ type: :development
145
+ description: ""
146
+ email: jenseng@gmail.com
147
+ executables: []
148
+
149
+ extensions: []
150
+
151
+ extra_rdoc_files:
152
+ - LICENSE.txt
153
+ - README.rdoc
154
+ files:
155
+ - .document
156
+ - .rspec
157
+ - Gemfile
158
+ - LICENSE.txt
159
+ - README.rdoc
160
+ - Rakefile
161
+ - VERSION
162
+ - init.rb
163
+ - lib/hair_trigger.rb
164
+ - lib/hair_trigger/adapter.rb
165
+ - lib/hair_trigger/base.rb
166
+ - lib/hair_trigger/builder.rb
167
+ - lib/hair_trigger/migration.rb
168
+ - lib/tasks/hair_trigger.rake
169
+ - rails/init.rb
170
+ - spec/builder_spec.rb
171
+ - spec/spec_helper.rb
172
+ has_rdoc: true
173
+ homepage: http://github.com/jenseng/hairtrigger
174
+ licenses:
175
+ - MIT
176
+ post_install_message:
177
+ rdoc_options: []
178
+
179
+ require_paths:
180
+ - lib
181
+ required_ruby_version: !ruby/object:Gem::Requirement
182
+ none: false
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ hash: 3
187
+ segments:
188
+ - 0
189
+ version: "0"
190
+ required_rubygems_version: !ruby/object:Gem::Requirement
191
+ none: false
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ hash: 3
196
+ segments:
197
+ - 0
198
+ version: "0"
199
+ requirements: []
200
+
201
+ rubyforge_project:
202
+ rubygems_version: 1.6.0
203
+ signing_key:
204
+ specification_version: 3
205
+ summary: easy database triggers for active record
206
+ test_files:
207
+ - spec/builder_spec.rb
208
+ - spec/spec_helper.rb