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 +5 -0
- data/.rspec +1 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +134 -0
- data/Rakefile +48 -0
- data/VERSION +1 -0
- data/init.rb +1 -0
- data/lib/hair_trigger.rb +50 -0
- data/lib/hair_trigger/adapter.rb +15 -0
- data/lib/hair_trigger/base.rb +16 -0
- data/lib/hair_trigger/builder.rb +348 -0
- data/lib/hair_trigger/migration.rb +34 -0
- data/lib/tasks/hair_trigger.rake +77 -0
- data/rails/init.rb +1 -0
- data/spec/builder_spec.rb +140 -0
- data/spec/spec_helper.rb +12 -0
- metadata +208 -0
data/.document
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/Gemfile
ADDED
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'
|
data/lib/hair_trigger.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|