hairtrigger 0.2.9 → 0.2.10

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/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