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 +34 -4
- data/lib/hair_trigger/builder.rb +54 -7
- data/lib/hair_trigger/version.rb +1 -1
- data/spec/builder_spec.rb +40 -1
- metadata +2 -2
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
|
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.
|
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
|
data/lib/hair_trigger/builder.rb
CHANGED
@@ -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
|
-
|
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
|
-
[
|
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
|
-
|
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
|
data/lib/hair_trigger/version.rb
CHANGED
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 =>
|
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.
|
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-
|
12
|
+
date: 2014-06-24 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activerecord
|