hairtrigger 0.2.9 → 0.2.10

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -22,6 +22,10 @@ class AccountUser < ActiveRecord::Base
22
22
  trigger.after(:insert) do
23
23
  "UPDATE accounts SET user_count = user_count + 1 WHERE id = NEW.account_id;"
24
24
  end
25
+
26
+ trigger.after(:update).of(:name) do
27
+ "INSERT INTO user_changes(id, name) VALUES(NEW.id, NEW.name);"
28
+ end
25
29
  end
26
30
  ```
27
31
 
@@ -39,7 +43,15 @@ CREATE TRIGGER account_users_after_insert_row_tr AFTER INSERT ON account_users
39
43
  FOR EACH ROW
40
44
  BEGIN
41
45
  UPDATE accounts SET user_count = user_count + 1 WHERE id = NEW.account_id;
42
- END
46
+ END;
47
+
48
+ CREATE TRIGGER account_users_after_update_on_name_row_tr AFTER UPDATE ON account_users
49
+ FOR EACH ROW
50
+ BEGIN
51
+ IF NEW.name <> OLD.name OR (NEW.name IS NULL) <> (OLD.name IS NULL) THEN
52
+ INSERT INTO user_changes(id, name) VALUES(NEW.id, NEW.name);
53
+ END IF;
54
+ END;
43
55
  ```
44
56
 
45
57
  Note that these auto-generated `create_trigger` statements in the migration
@@ -74,6 +86,10 @@ Shorthand for `timing(:after).events(*events)`.
74
86
  #### where(conditions)
75
87
  Optional, SQL snippet limiting when the trigger will fire. Supports delayed interpolation of variables.
76
88
 
89
+ #### of(*columns)
90
+
91
+ Only fire the update trigger if at least one of the columns is specified in the statement. Platforms that support it use a native `OF` clause, others will have an inferred `IF ...` statement in the trigger body. Note the former will fire even if the column's value hasn't changed; the latter will not.
92
+
77
93
  #### security(user)
78
94
  Permissions/role to check when calling trigger. PostgreSQL supports `:invoker` (default) and `:definer`, MySQL supports `:definer` (default) and arbitrary users (syntax: `'user'@'host'`).
79
95
 
@@ -84,10 +100,24 @@ Required (but may be satisified by `before`/`after`). Possible values are `:befo
84
100
  Required (but may be satisified by `before`/`after`). Possible values are `:insert`/`:update`/`:delete`/`:truncate`. MySQL/SQLite only support one action per trigger, and don't support `:truncate`.
85
101
 
86
102
  #### nowrap(flag = true)
87
- PostgreSQL specific option to prevent the trigger action from being wrapped in a `CREATE FUNCTION`. This is useful for executing existing triggers/functions directly, but is not compatible with the `security` setting nor can it be used with pre-9.0 PostgreSQL when supplying a `where` condition.
103
+ PostgreSQL-specific option to prevent the trigger action from being wrapped in a `CREATE FUNCTION`. This is useful for executing existing triggers/functions directly, but is not compatible with the `security` setting nor can it be used with pre-9.0 PostgreSQL when supplying a `where` condition.
88
104
 
89
105
  Example: `trigger.after(:update).nowrap { "tsvector_update_trigger(...)" }`
90
106
 
107
+ #### declare
108
+ PostgreSQL-specific option for declaring variables for use in the
109
+ trigger function. Declarations should be separate by semicolons, e.g.
110
+
111
+ ```ruby
112
+ trigger.after(:insert).declare("user_type text; status text") do
113
+ <<-SQL
114
+ IF (NEW.account_id = 1 OR NEW.email LIKE '%company.com') THEN
115
+ user_type := 'employee';
116
+ ELSIF ...
117
+ SQL
118
+ end
119
+ ```
120
+
91
121
  #### all
92
122
  Noop, useful for trigger groups (see below).
93
123
 
@@ -103,10 +133,10 @@ trigger.after(:update) do |t|
103
133
  t.all do # every row
104
134
  # some sql
105
135
  end
106
- t.where("OLD.foo != NEW.foo") do
136
+ t.of("foo") do
107
137
  # some more sql
108
138
  end
109
- t.where("OLD.bar != NEW.bar") do
139
+ t.where("OLD.bar != NEW.bar AND NEW.bar != 'lol'") do
110
140
  # some other sql
111
141
  end
112
142
  end
@@ -77,6 +77,14 @@ module HairTrigger
77
77
  options[:nowrap] = flag
78
78
  end
79
79
 
80
+ def of(*columns)
81
+ options[:of] = columns
82
+ end
83
+
84
+ def declare(declarations)
85
+ options[:declarations] = declarations
86
+ end
87
+
80
88
  # noop, just a way you can pass a block within a trigger group
81
89
  def all
82
90
  end
@@ -150,7 +158,7 @@ module HairTrigger
150
158
  METHOD
151
159
  end
152
160
  end
153
- chainable_methods :name, :on, :for_each, :before, :after, :where, :security, :timing, :events, :all, :nowrap
161
+ chainable_methods :name, :on, :for_each, :before, :after, :where, :security, :timing, :events, :all, :nowrap, :of, :declare
154
162
 
155
163
  def create_grouped_trigger?
156
164
  adapter_name == :mysql
@@ -158,7 +166,7 @@ module HairTrigger
158
166
 
159
167
  def prepare!
160
168
  @triggers.each(&:prepare!) if @triggers
161
- @prepared_where = options[:where] = interpolate(options[:where]) if options[:where]
169
+ prepare_where!
162
170
  if @actions
163
171
  @prepared_actions = @actions.is_a?(Hash) ?
164
172
  @actions.inject({}){ |hash, (key, value)| hash[key] = interpolate(value).rstrip; hash } :
@@ -167,6 +175,20 @@ module HairTrigger
167
175
  all_names # ensure (component) trigger names are all cached
168
176
  end
169
177
 
178
+ def prepare_where!
179
+ parts = []
180
+ parts << @explicit_where = options[:where] = interpolate(options[:where]) if options[:where]
181
+ parts << options[:of].map{ |col| change_clause(col) }.join(" OR ") if options[:of] && !supports_of?
182
+ if parts.present?
183
+ parts.map!{ |part| "(" + part + ")" } if parts.size > 1
184
+ @prepared_where = parts.join(" AND ")
185
+ end
186
+ end
187
+
188
+ def change_clause(column)
189
+ "NEW.#{column} <> OLD.#{column} OR (NEW.#{column} IS NULL) <> (OLD.#{column} IS NULL)"
190
+ end
191
+
170
192
  def validate!(direction = :down)
171
193
  @errors.each do |(error, *adapters)|
172
194
  raise GenerationError, error if adapters.include?(adapter_name)
@@ -259,7 +281,7 @@ module HairTrigger
259
281
  end
260
282
 
261
283
  def components
262
- [self.options, self.prepared_actions, self.prepared_where, self.triggers, @compatibility]
284
+ [@options, @prepared_actions, @explicit_where, @triggers, @compatibility]
263
285
  end
264
286
 
265
287
  def errors
@@ -296,6 +318,7 @@ module HairTrigger
296
318
  end
297
319
 
298
320
  def maybe_execute(&block)
321
+ raise DeclarationError, "of may only be specified on update triggers" if options[:of] && options[:events] != ["UPDATE"]
299
322
  if block.arity > 0 # we're creating a trigger group, so set up some stuff and pass the buck
300
323
  @errors << ["trigger group must specify timing and event(s) for mysql", :mysql] unless options[:timing] && options[:events]
301
324
  @errors << ["nested trigger groups are not supported for mysql", :mysql] if @trigger_group
@@ -340,12 +363,34 @@ module HairTrigger
340
363
  [options[:table],
341
364
  options[:timing],
342
365
  options[:events],
366
+ of_clause(false),
343
367
  options[:for_each],
344
- prepared_where ? 'when_' + prepared_where : nil
368
+ @explicit_where ? 'when_' + @explicit_where : nil
345
369
  ].flatten.compact.
346
370
  join("_").downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_')[0, 60] + "_tr"
347
371
  end
348
372
 
373
+ def of_clause(check_support = true)
374
+ "OF " + options[:of].join(", ") + " " if options[:of] && (!check_support || supports_of?)
375
+ end
376
+
377
+ def declarations
378
+ return unless declarations = options[:declarations]
379
+ declarations = declarations.strip.split(/;/).map(&:strip).join(";\n")
380
+ "\nDECLARE\n" + normalize(declarations.sub(/;?\n?\z/, ';'), 1).rstrip
381
+ end
382
+
383
+ def supports_of?
384
+ case adapter_name
385
+ when :sqlite
386
+ true
387
+ when :postgresql, :postgis
388
+ db_version >= 90000
389
+ else
390
+ false
391
+ end
392
+ end
393
+
349
394
  def generate_drop_trigger
350
395
  case adapter_name
351
396
  when :sqlite, :mysql
@@ -359,7 +404,7 @@ module HairTrigger
359
404
 
360
405
  def generate_trigger_sqlite
361
406
  <<-SQL
362
- CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].first} ON #{options[:table]}
407
+ CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].first} #{of_clause}ON #{options[:table]}
363
408
  FOR EACH #{options[:for_each]}#{prepared_where ? " WHEN " + prepared_where : ''}
364
409
  BEGIN
365
410
  #{normalize(raw_actions, 1).rstrip}
@@ -370,8 +415,10 @@ END;
370
415
  def generate_trigger_postgresql
371
416
  raise GenerationError, "truncate triggers are only supported on postgres 8.4 and greater" if db_version < 80400 && options[:events].include?('TRUNCATE')
372
417
  raise GenerationError, "FOR EACH ROW triggers may not be triggered by truncate events" if options[:for_each] == 'ROW' && options[:events].include?('TRUNCATE')
418
+ raise GenerationError, "declare cannot be used in conjunction with nowrap" if options[:nowrap] && options[:declare]
373
419
  raise GenerationError, "security cannot be used in conjunction with nowrap" if options[:nowrap] && options[:security]
374
420
  raise GenerationError, "where can only be used in conjunction with nowrap on postgres 9.0 and greater" if options[:nowrap] && prepared_where && db_version < 90000
421
+ raise GenerationError, "of can only be used in conjunction with nowrap on postgres 9.1 and greater" if options[:nowrap] && options[:of] && db_version < 91000
375
422
 
376
423
  sql = ''
377
424
 
@@ -381,7 +428,7 @@ END;
381
428
  security = options[:security] if options[:security] && options[:security] != :invoker
382
429
  sql << <<-SQL
383
430
  CREATE FUNCTION #{prepared_name}()
384
- RETURNS TRIGGER AS $$
431
+ RETURNS TRIGGER AS $$#{declarations}
385
432
  BEGIN
386
433
  SQL
387
434
  if prepared_where && db_version < 90000
@@ -410,7 +457,7 @@ $$ LANGUAGE plpgsql#{security ? " SECURITY #{security.to_s.upcase}" : ""};
410
457
  end
411
458
 
412
459
  [sql, <<-SQL]
413
- CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].join(" OR ")} ON #{options[:table]}
460
+ CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].join(" OR ")} #{of_clause}ON #{options[:table]}
414
461
  FOR EACH #{options[:for_each]}#{prepared_where && db_version >= 90000 ? " WHEN (" + prepared_where + ')': ''} EXECUTE PROCEDURE #{trigger_action};
415
462
  SQL
416
463
  end
@@ -1,5 +1,5 @@
1
1
  module HairTrigger
2
- VERSION = "0.2.9"
2
+ VERSION = "0.2.10"
3
3
 
4
4
  def VERSION.<=>(other)
5
5
  split(/\./).map(&:to_i) <=> other.split(/\./).map(&:to_i)
data/spec/builder_spec.rb CHANGED
@@ -60,6 +60,14 @@ describe "builder" do
60
60
  end
61
61
  end
62
62
 
63
+ describe "`of' columns" do
64
+ it "should be disallowed for non-update triggers" do
65
+ lambda {
66
+ builder.on(:foos).after(:insert).of(:bar, :baz){ "BAR" }
67
+ }.should raise_error /of may only be specified on update triggers/
68
+ end
69
+ end
70
+
63
71
  describe "groups" do
64
72
  it "should allow chained methods" do
65
73
  triggers = builder.on(:foos){ |t|
@@ -143,6 +151,16 @@ describe "builder" do
143
151
  grep(/DEFINER = 'user'@'host'/).size.should eql(1)
144
152
  end
145
153
 
154
+ it "should infer `if' conditionals from `of' columns" do
155
+ builder.on(:foos).after(:update).of(:bar){ "BAZ" }.generate.join("\n").
156
+ should include("IF NEW.bar <> OLD.bar OR (NEW.bar IS NULL) <> (OLD.bar IS NULL) THEN")
157
+ end
158
+
159
+ it "should merge `where` and `of` into an `if` conditional" do
160
+ builder.on(:foos).after(:update).of(:bar).where("lol"){ "BAZ" }.generate.join("\n").
161
+ should include("IF (lol) AND (NEW.bar <> OLD.bar OR (NEW.bar IS NULL) <> (OLD.bar IS NULL)) THEN")
162
+ end
163
+
146
164
  it "should reject :invoker security" do
147
165
  lambda {
148
166
  builder.on(:foos).after(:update).security(:invoker){ "FOO" }.generate
@@ -170,7 +188,7 @@ describe "builder" do
170
188
 
171
189
  context "postgresql" do
172
190
  before(:each) do
173
- @adapter = MockAdapter.new("postgresql", :postgresql_version => 90000)
191
+ @adapter = MockAdapter.new("postgresql", :postgresql_version => 94000)
174
192
  end
175
193
 
176
194
  it "should create multiple triggers for a group" do
@@ -201,6 +219,11 @@ describe "builder" do
201
219
  trigger.warnings.first.first.should =~ /trigger group has an explicit name/
202
220
  end
203
221
 
222
+ it "should accept `of' columns" do
223
+ trigger = builder.on(:foos).after(:update).of(:bar, :baz){ "BAR" }
224
+ trigger.generate.grep(/AFTER UPDATE OF bar, baz/).size.should eql(1)
225
+ end
226
+
204
227
  it "should accept security" do
205
228
  builder.on(:foos).after(:update).security(:invoker){ "FOO" }.generate.
206
229
  grep(/SECURITY/).size.should eql(0) # default, so we don't include it
@@ -253,6 +276,11 @@ describe "builder" do
253
276
  }.should raise_error
254
277
  end
255
278
 
279
+ it "should allow variable declarations" do
280
+ builder.on(:foos).after(:insert).declare("foo INT"){ "FOO" }.generate.join("\n").
281
+ should match(/DECLARE\s*foo INT;\s*BEGIN\s*FOO/)
282
+ end
283
+
256
284
  context "legacy" do
257
285
  it "should reject truncate pre-8.4" do
258
286
  @adapter = MockAdapter.new("postgresql", :postgresql_version => 80300)
@@ -273,6 +301,12 @@ describe "builder" do
273
301
  builder.on(:foos).after(:insert).where("BAR").nowrap{ "FOO" }.generate
274
302
  }.should raise_error
275
303
  end
304
+
305
+ it "should infer `if' conditionals from `of' columns on pre-9.0" do
306
+ @adapter = MockAdapter.new("postgresql", :postgresql_version => 80400)
307
+ builder.on(:foos).after(:update).of(:bar){ "BAZ" }.generate.join("\n").
308
+ should include("IF NEW.bar <> OLD.bar OR (NEW.bar IS NULL) <> (OLD.bar IS NULL) THEN")
309
+ end
276
310
  end
277
311
  end
278
312
 
@@ -309,6 +343,11 @@ describe "builder" do
309
343
  trigger.warnings.first.first.should =~ /trigger group has an explicit name/
310
344
  end
311
345
 
346
+ it "should accept `of' columns" do
347
+ trigger = builder.on(:foos).after(:update).of(:bar, :baz){ "BAR" }
348
+ trigger.generate.grep(/AFTER UPDATE OF bar, baz/).size.should eql(1)
349
+ end
350
+
312
351
  it "should reject security" do
313
352
  lambda {
314
353
  builder.on(:foos).after(:update).security(:definer){ "FOO" }.generate
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hairtrigger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.2.10
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-05-21 00:00:00.000000000 Z
12
+ date: 2014-06-24 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord