hairtrigger 0.1.4 → 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile CHANGED
@@ -1,8 +1,12 @@
1
1
  source "http://rubygems.org"
2
+
2
3
  gem "activerecord", ">=2.3.0", "<3.0"
3
4
  group :development do
4
5
  gem "rspec", "~> 2.3.0"
5
6
  gem "bundler", "~> 1.0.0"
6
7
  gem "jeweler", "~> 1.5.2"
7
8
  gem "rcov", ">= 0"
9
+ gem 'mysql', '>= 2.8.1'
10
+ gem 'pg', '>= 0.10.1'
11
+ gem 'sqlite3-ruby', '>= 1.3.2'
8
12
  end
data/README.rdoc CHANGED
@@ -4,7 +4,7 @@ HairTrigger lets you create and manage database triggers in a concise,
4
4
  db-agnostic, Rails-y way. You declare triggers right in your models in Ruby,
5
5
  and a simple rake task does all the dirty work for you.
6
6
 
7
- == Install
7
+ == Installation
8
8
 
9
9
  === Step 1.
10
10
 
@@ -55,7 +55,8 @@ contain the ":generated => true" option, indicating that they were created
55
55
  from the model definition. This is important, as the rake task will also
56
56
  generate appropriate drop/create statements for any model triggers that get
57
57
  removed or updated. It does this by diffing the current model trigger
58
- declarations and any auto-generated triggers from previous migrations.
58
+ declarations and any auto-generated triggers in schema.rb (and subsequent
59
+ migrations).
59
60
 
60
61
  === Manual Migrations
61
62
 
@@ -81,7 +82,7 @@ Optional, inferred from other calls.
81
82
  Ignored in models, required in migrations.
82
83
 
83
84
  ==== for_each(item)
84
- Defaults to :row, PostgreSQL/MySQL allow :statement.
85
+ Defaults to :row, PostgreSQL allows :statement.
85
86
 
86
87
  ==== before(*events)
87
88
  Shorthand for timing(:before).events(*events).
@@ -128,10 +129,28 @@ For MySQL, this will just create a single trigger with conditional logic
128
129
  distinct triggers. This same notation is also used within trigger migrations.
129
130
  MySQL does not currently support nested trigger groups.
130
131
 
132
+ == rake db:schema:dump
133
+
134
+ HairTrigger hooks into rake db:schema:dump (and rake tasks that call it) to
135
+ make it trigger-aware. A newly generated schema.rb will contain:
136
+
137
+ * create_trigger statements for any database triggers that exactly match a
138
+ create_trigger statement in an applied migration or in the previous
139
+ schema.rb file. this includes both generated and manual create_trigger
140
+ calls.
141
+ * adapter-specific execute('CREATE TRIGGER..') statements for any unmatched
142
+ database triggers.
143
+
144
+ As long as you don't delete old migrations and schema.rb prior to running
145
+ rake db:schema:dump, the result should be what you expect (and portable).
146
+ If you have deleted all trigger migrations, you can regenerate a new
147
+ baseline for model triggers via rake db:generate_trigger_migration.
148
+
131
149
  == Testing
132
150
 
133
151
  To stay on top of things, it's strongly recommended that you add a test or
134
- spec to ensure your migrations match your models. This is as simple as:
152
+ spec to ensure your migrations/schema.rb match your models. This is as simple
153
+ as:
135
154
 
136
155
  assert HairTrigger::migrations_current?
137
156
 
@@ -166,10 +185,8 @@ e.g.
166
185
  HairTrigger does not validate your SQL, so be sure to test it in all databases
167
186
  you want to support.
168
187
 
169
- == Gotchas, Known Issues
188
+ == Gotchas
170
189
 
171
- * HairTrigger does not check config.active_record.timestamped_migrations, it
172
- always assumes it is true. As a workaround, you can rename the migration.
173
190
  * As is the case with ActiveRecord::Base.update_all or any direct SQL you do,
174
191
  be careful to reload updated objects from the database. For example, the
175
192
  following code will display the wrong count since we aren't reloading the
@@ -188,11 +205,13 @@ you want to support.
188
205
 
189
206
  * Rails 2.3.x
190
207
  * Postgres 8.0+
191
- * MySQL 5.0+
192
- * SQLite 3.0+
208
+ * MySQL 5.0.10+
209
+ * SQLite 3.3.8+
193
210
 
194
211
  == Version History
195
212
 
213
+ * 0.1.4 Compatibility tracking, fixed Postgres return bug, ensure last action
214
+ has a semicolon
196
215
  * 0.1.3 Better error handling, Postgres 8.x support, updated docs
197
216
  * 0.1.2 Fixed Builder#security, updated docs
198
217
  * 0.1.1 Fixed bug in HairTrigger.migrations_current?, fixed up Gemfile
data/Rakefile CHANGED
@@ -19,7 +19,7 @@ Jeweler::Tasks.new do |gem|
19
19
  gem.description = %Q{allows you to declare database triggers in ruby in your models, and then generate appropriate migrations as they change}
20
20
  gem.email = "jenseng@gmail.com"
21
21
  gem.authors = ["Jon Jensen"]
22
- gem.add_runtime_dependency "activerecord", ">=2.3.0", "<3.0"
22
+ gem.add_dependency "activerecord", ">=2.3.0", "<3.0"
23
23
  gem.add_development_dependency "rspec", "~> 2.3.0"
24
24
  end
25
25
  Jeweler::RubygemsDotOrgTasks.new
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.4
1
+ 0.1.5
data/lib/hair_trigger.rb CHANGED
@@ -1,13 +1,22 @@
1
+ require 'ostruct'
2
+ require 'hair_trigger/base'
3
+ require 'hair_trigger/builder'
4
+ require 'hair_trigger/migration'
5
+ require 'hair_trigger/adapter'
6
+ require 'hair_trigger/schema_dumper'
7
+ require 'hair_trigger/schema'
8
+
1
9
  module HairTrigger
2
10
  def self.current_triggers
3
11
  # see what the models say there should be
4
12
  canonical_triggers = []
5
- Dir['app/models/*rb'].each do |model|
13
+ Dir[model_path + '/*rb'].each do |model|
6
14
  class_name = model.sub(/\A.*\/(.*?)\.rb\z/, '\1').camelize
7
15
  begin
16
+ require model
8
17
  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[\.\(]/
18
+ rescue LoadError
19
+ raise "unable to load #{class_name} and its trigger(s)" if File.read(model) =~ /^\s*trigger[\.\(]/
11
20
  next
12
21
  end
13
22
  canonical_triggers += klass.triggers if klass < ActiveRecord::Base && klass.triggers
@@ -15,36 +24,163 @@ module HairTrigger
15
24
  canonical_triggers.each(&:prepare!) # interpolates any vars so we match the migrations
16
25
  end
17
26
 
18
- def self.current_migrations
27
+ def self.current_migrations(options = {})
28
+ if options[:in_rake_task]
29
+ options[:include_manual_triggers] = true
30
+ options[:schema_rb_first] = true
31
+ options[:skip_pending_migrations] = true
32
+ end
33
+
34
+ prev_verbose = ActiveRecord::Migration.verbose
19
35
  ActiveRecord::Migration.verbose = false
20
36
  ActiveRecord::Migration.extract_trigger_builders = true
37
+ ActiveRecord::Migration.extract_all_triggers = options[:include_manual_triggers] || false
21
38
 
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|
39
+ # if we're in a db:schema:dump task (explict or kicked off by db:migrate),
40
+ # we evaluate the previous schema.rb (if it exists), and then all applied
41
+ # migrations in order (even ones older than schema.rb). this ensures we
42
+ # handle db:migrate:down scenarios correctly
43
+ #
44
+ # if we're not in such a rake task (i.e. we just want to know what
45
+ # triggers are defined, whether or not they are applied in the db), we
46
+ # evaluate all migrations along with schema.rb, ordered by version
47
+ migrator = ActiveRecord::Migrator.new(:up, migration_path)
48
+ migrated = migrator.migrated rescue []
49
+ migrations = migrator.migrations.select{ |migration|
50
+ (options[:skip_pending_migrations] ? migrated.include?(migration.version) : true)
51
+ }.each{ |migration|
28
52
  migration.migrate(:up)
53
+ }
54
+
55
+ if options.has_key?(:previous_schema)
56
+ eval(options[:previous_schema]) if options[:previous_schema]
57
+ elsif File.exist?(schema_rb_path)
58
+ load(schema_rb_path)
59
+ end
60
+ if ActiveRecord::Schema.info && ActiveRecord::Schema.trigger_builders
61
+ migrations.unshift OpenStruct.new({:version => ActiveRecord::Schema.info[:version], :trigger_builders => ActiveRecord::Schema.trigger_builders})
62
+ end
63
+ migrations = migrations.sort_by(&:version) unless options[:schema_rb_first]
64
+
65
+ all_builders = []
66
+ migrations.each do |migration|
67
+ next unless migration.trigger_builders
29
68
  migration.trigger_builders.each do |new_trigger|
30
69
  # if there is already a trigger with this name, delete it since we are
31
70
  # 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]
71
+ new_trigger.prepare!
72
+ all_builders.delete_if{ |(n, t)| t.prepared_name == new_trigger.prepared_name }
73
+ all_builders << [migration.name, new_trigger] unless new_trigger.options[:drop]
34
74
  end
35
75
  end
36
- migrations.each{ |(n, t)| t.prepare! }
76
+
77
+ all_builders
78
+
37
79
  ensure
38
- ActiveRecord::Migration.verbose = true
80
+ ActiveRecord::Migration.verbose = prev_verbose
39
81
  ActiveRecord::Migration.extract_trigger_builders = false
82
+ ActiveRecord::Migration.extract_all_triggers = false
40
83
  end
41
84
 
42
85
  def self.migrations_current?
43
86
  current_migrations.map(&:last).sort.eql? current_triggers.sort
44
87
  end
88
+
89
+ def self.generate_migration(silent = false)
90
+ begin
91
+ canonical_triggers = current_triggers
92
+ rescue
93
+ $stderr.puts $!
94
+ exit 1
95
+ end
96
+
97
+ migrations = current_migrations
98
+ migration_names = migrations.map(&:first)
99
+ existing_triggers = migrations.map(&:last)
100
+
101
+ up_drop_triggers = []
102
+ up_create_triggers = []
103
+ down_drop_triggers = []
104
+ down_create_triggers = []
105
+
106
+ existing_triggers.each do |existing|
107
+ unless canonical_triggers.any?{ |t| t.prepared_name == existing.prepared_name }
108
+ up_drop_triggers += existing.drop_triggers
109
+ down_create_triggers << existing
110
+ end
111
+ end
112
+
113
+ (canonical_triggers - existing_triggers).each do |new_trigger|
114
+ up_create_triggers << new_trigger
115
+ down_drop_triggers += new_trigger.drop_triggers
116
+ if existing = existing_triggers.detect{ |t| t.prepared_name == new_trigger.prepared_name }
117
+ # it's not sufficient to rely on the new trigger to replace the old
118
+ # one, since we could be dealing with trigger groups and the name
119
+ # alone isn't sufficient to know which component triggers to remove
120
+ up_drop_triggers += existing.drop_triggers
121
+ down_create_triggers << existing
122
+ end
123
+ end
124
+
125
+ unless up_drop_triggers.empty? && up_create_triggers.empty?
126
+ migration_base_name = if up_create_triggers.size > 0
127
+ ("create trigger#{up_create_triggers.size > 1 ? 's' : ''} " +
128
+ up_create_triggers.map{ |t| [t.options[:table], t.options[:events].join(" ")].join(" ") }.join(" and ")
129
+ ).downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_').camelize
130
+ else
131
+ ("drop trigger#{up_drop_triggers.size > 1 ? 's' : ''} " +
132
+ up_drop_triggers.map{ |t| t.options[:table] }.join(" and ")
133
+ ).downcase.gsub(/[^a-z0-9_]/, '_').gsub(/_+/, '_').camelize
134
+ end
135
+
136
+ name_version = nil
137
+ while migration_names.include?("#{migration_base_name}#{name_version}")
138
+ name_version = name_version.to_i + 1
139
+ end
140
+ migration_name = "#{migration_base_name}#{name_version}"
141
+ migration_version = ActiveRecord::Base.timestamped_migrations ?
142
+ Time.now.getutc.strftime("%Y%m%d%H%M%S") :
143
+ Dir.glob(migration_path + '/*rb').map{ |f| f.gsub(/.*\/(\d+)_.*/, '\1').to_i}.inject(0){ |curr, i| i > curr ? i : curr }
144
+ file_name = migration_path + '/' + migration_version + "_" + migration_name.underscore + ".rb"
145
+ File.open(file_name, "w"){ |f| f.write <<-MIGRATION }
146
+ # This migration was auto-generated via `rake db:generate_trigger_migration'.
147
+ # While you can edit this file, any changes you make to the definitions here
148
+ # will be undone by the next auto-generated trigger migration.
149
+
150
+ class #{migration_name} < ActiveRecord::Migration
151
+ def self.up
152
+ #{(up_drop_triggers + up_create_triggers).map{ |t| t.to_ruby(' ') }.join("\n\n").lstrip}
153
+ end
154
+
155
+ def self.down
156
+ #{(down_drop_triggers + down_create_triggers).map{ |t| t.to_ruby(' ') }.join("\n\n").lstrip}
157
+ end
158
+ end
159
+ MIGRATION
160
+ file_name
161
+ end
162
+ end
163
+
164
+ class << self
165
+ attr_writer :model_path, :schema_rb_path, :migration_path
166
+
167
+ def model_path
168
+ @model_path ||= 'app/models'
169
+ end
170
+
171
+ def schema_rb_path
172
+ @schema_rb_path ||= 'db/schema.rb'
173
+ end
174
+
175
+ def migration_path
176
+ @migration_path ||= 'db/migrate'
177
+ end
178
+ end
45
179
  end
46
180
 
47
181
  ActiveRecord::Base.send :extend, HairTrigger::Base
48
182
  ActiveRecord::Migration.send :extend, HairTrigger::Migration
49
183
  ActiveRecord::MigrationProxy.send :delegate, :trigger_builders, :to=>:migration
50
184
  ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval { include HairTrigger::Adapter }
185
+ ActiveRecord::SchemaDumper.class_eval { include HairTrigger::SchemaDumper }
186
+ ActiveRecord::Schema.send :extend, HairTrigger::Schema
@@ -11,5 +11,57 @@ module HairTrigger
11
11
  def drop_trigger(name, table, options = {})
12
12
  ::HairTrigger::Builder.new(name, options.merge(:execute => true, :drop => true, :table => table)){}
13
13
  end
14
+
15
+ def triggers(options = {})
16
+ triggers = {}
17
+ name_clause = options[:only] ? "IN ('" + options[:only].join("', '") + "')" : nil
18
+ case self.adapter_name.downcase.to_sym
19
+ when :sqlite
20
+ select_rows("SELECT name, sql FROM sqlite_master WHERE type = 'trigger' #{name_clause ? " AND name " + name_clause : ""}").each do |(name, definition)|
21
+ triggers[name] = definition + ";\n"
22
+ end
23
+ when :mysql
24
+ select_rows("SHOW TRIGGERS").each do |(name, event, table, actions, timing, created, sql_mode, definer)|
25
+ next if options[:only] && !options[:only].include?(name)
26
+ triggers[name] = <<-SQL
27
+ CREATE #{definer != "#{@config[:username]}@#{@config[:host]}" ? "DEFINER = #{definer} " : ""}TRIGGER #{name} #{timing} #{event} ON #{table}
28
+ FOR EACH ROW
29
+ #{actions}
30
+ SQL
31
+ end
32
+ when :postgresql
33
+ function_conditions = "(SELECT typname FROM pg_type WHERE oid = prorettype) = 'trigger'"
34
+ function_conditions << <<-SQL unless options[:simple_check]
35
+ AND oid IN (
36
+ SELECT tgfoid
37
+ FROM pg_trigger
38
+ WHERE NOT tgisinternal AND tgconstrrelid = 0 AND tgrelid IN (
39
+ SELECT oid FROM pg_class WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
40
+ )
41
+ )
42
+ SQL
43
+ function_conditions =
44
+ sql = <<-SQL
45
+ SELECT tgname::varchar, pg_get_triggerdef(oid, true)
46
+ FROM pg_trigger
47
+ WHERE NOT tgisinternal AND tgconstrrelid = 0 AND tgrelid IN (
48
+ SELECT oid FROM pg_class WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
49
+ )
50
+
51
+ #{name_clause ? " AND tgname::varchar " + name_clause : ""}
52
+ UNION
53
+ SELECT proname || '()', pg_get_functiondef(oid)
54
+ FROM pg_proc
55
+ WHERE #{function_conditions}
56
+ #{name_clause ? " AND (proname || '()')::varchar " + name_clause : ""}
57
+ SQL
58
+ select_rows(sql).each do |(name, definition)|
59
+ triggers[name] = definition
60
+ end
61
+ else
62
+ raise "don't know how to retrieve #{adapter_name} triggers yet"
63
+ end
64
+ triggers
65
+ end
14
66
  end
15
67
  end
@@ -50,7 +50,7 @@ module HairTrigger
50
50
  end
51
51
 
52
52
  def for_each(for_each)
53
- @errors << ["sqlite doesn't support FOR EACH STATEMENT triggers", :sqlite] if for_each == :statement
53
+ @errors << ["sqlite and mysql don't support FOR EACH STATEMENT triggers", :sqlite, :mysql] if for_each == :statement
54
54
  raise DeclarationError, "invalid for_each" unless [:row, :statement].include?(for_each)
55
55
  options[:for_each] = for_each.to_s.upcase
56
56
  end
@@ -170,8 +170,8 @@ module HairTrigger
170
170
  raise GenerationError, "need to specify the event(s) (:insert, :update, :delete)" if !options[:events] || options[:events].empty?
171
171
  raise GenerationError, "need to specify the timing (:before/:after)" unless options[:timing]
172
172
 
173
- ret = [generate_drop_trigger]
174
- ret << case adapter_name
173
+ [generate_drop_trigger] +
174
+ [case adapter_name
175
175
  when :sqlite
176
176
  generate_trigger_sqlite
177
177
  when :mysql
@@ -180,23 +180,23 @@ module HairTrigger
180
180
  generate_trigger_postgresql
181
181
  else
182
182
  raise GenerationError, "don't know how to build #{adapter_name} triggers yet"
183
- end
184
- ret
183
+ end].flatten
185
184
  end
186
185
  end
187
186
 
188
- def to_ruby(indent = '')
187
+ def to_ruby(indent = '', always_generated = true)
189
188
  prepare!
190
189
  if options[:drop]
191
- "#{indent}drop_trigger(#{prepared_name.inspect}, #{options[:table].inspect}, :generated => true)"
190
+ "#{indent}drop_trigger(#{prepared_name.inspect}, #{options[:table].inspect})"
192
191
  else
193
192
  if @trigger_group
194
193
  str = "t." + chained_calls_to_ruby + " do\n"
195
194
  str << actions_to_ruby("#{indent} ") + "\n"
196
195
  str << "#{indent}end"
197
196
  else
198
- str = "#{indent}create_trigger(#{prepared_name.inspect}, :generated => true, :compatibility => #{@compatibility}).\n" +
199
- "#{indent} " + chained_calls_to_ruby(".\n#{indent} ")
197
+ str = "#{indent}create_trigger(#{prepared_name.inspect}"
198
+ str << ", :generated => true, :compatibility => #{@compatibility}" if always_generated || options[:generated]
199
+ str << ").\n#{indent} " + chained_calls_to_ruby(".\n#{indent} ")
200
200
  if @triggers
201
201
  str << " do |t|\n"
202
202
  str << "#{indent} " + @triggers.map{ |t| t.to_ruby("#{indent} ") }.join("\n\n#{indent} ") + "\n"
@@ -337,6 +337,8 @@ BEGIN
337
337
  sql << <<-SQL
338
338
  END;
339
339
  $$ LANGUAGE plpgsql#{security ? " SECURITY #{security.to_s.upcase}" : ""};
340
+ SQL
341
+ [sql, <<-SQL]
340
342
  CREATE TRIGGER #{prepared_name} #{options[:timing]} #{options[:events].join(" OR ")} ON #{options[:table]}
341
343
  FOR EACH #{options[:for_each]}#{prepared_where && db_version >= 90000 ? " WHEN (" + prepared_where + ')': ''} EXECUTE PROCEDURE #{prepared_name}();
342
344
  SQL
@@ -4,13 +4,13 @@ module HairTrigger
4
4
 
5
5
  def method_missing_with_trigger_building(method, *arguments, &block)
6
6
  if extract_trigger_builders
7
- if method.to_sym == :create_trigger && arguments[1].delete(:generated)
7
+ if method.to_sym == :create_trigger && (extract_all_triggers || arguments[1].delete(:generated))
8
8
  arguments.unshift(nil) if arguments.first.is_a?(Hash)
9
9
  arguments[1][:compatibility] ||= 0
10
10
  trigger = ::HairTrigger::Builder.new(*arguments)
11
11
  (@trigger_builders ||= []) << trigger
12
12
  trigger
13
- elsif method.to_sym == :drop_trigger && arguments[2] && arguments[2].delete(:generated)
13
+ elsif method.to_sym == :drop_trigger && (extract_all_triggers || arguments[2] && arguments[2].delete(:generated))
14
14
  trigger = ::HairTrigger::Builder.new(arguments[0], {:table => arguments[1], :drop => true})
15
15
  (@trigger_builders ||= []) << trigger
16
16
  trigger
@@ -28,6 +28,7 @@ module HairTrigger
28
28
  class << self
29
29
  alias_method_chain :method_missing, :trigger_building
30
30
  cattr_accessor :extract_trigger_builders
31
+ cattr_accessor :extract_all_triggers
31
32
  end
32
33
  end
33
34
  end
@@ -0,0 +1,17 @@
1
+ module HairTrigger
2
+ module Schema
3
+ attr_reader :info
4
+ def define_with_no_save(info={}, &block)
5
+ instance_eval(&block)
6
+ @info = info
7
+ end
8
+
9
+ def self.extended(base)
10
+ base.instance_eval do
11
+ class << self
12
+ alias_method_chain :define, :no_save
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,87 @@
1
+ module HairTrigger
2
+ module SchemaDumper
3
+ def trailer_with_triggers(stream)
4
+ triggers(stream)
5
+ trailer_without_triggers(stream)
6
+ end
7
+
8
+ def triggers(stream)
9
+ @connection = ActiveRecord::Base.connection
10
+ @adapter_name = @connection.adapter_name.downcase.to_sym
11
+
12
+ db_triggers = @connection.triggers; nil
13
+ db_trigger_warnings = {}
14
+
15
+ migration_triggers = HairTrigger.current_migrations(:in_rake_task => true, :previous_schema => self.class.previous_schema).map do |(name, builder)|
16
+ definitions = []
17
+ builder.generate.each do |statement|
18
+ if statement =~ /\ACREATE(.*TRIGGER| FUNCTION) ([^ \n]+)/
19
+ definitions << [$2, statement, $1 == ' FUNCTION' ? :function : :trigger]
20
+ end
21
+ end
22
+ {:builder => builder, :definitions => definitions}
23
+ end
24
+
25
+ migration_triggers.each do |migration|
26
+ next unless migration[:definitions].all? do |(name, definition, type)|
27
+ db_triggers[name] && (db_trigger_warnings[name] = true) && db_triggers[name] == normalize_trigger(name, definition, type)
28
+ end
29
+ migration[:definitions].each do |(name, trigger, type)|
30
+ db_triggers.delete(name)
31
+ db_trigger_warnings.delete(name)
32
+ end
33
+
34
+ stream.print migration[:builder].to_ruby(' ', false) + "\n\n"
35
+ end
36
+
37
+ db_triggers.to_a.sort_by{ |t| (t.first + 'a').sub(/\(/, '_') }.each do |(name, definition)|
38
+ if db_trigger_warnings[name]
39
+ stream.puts " # WARNING: generating adapter-specific definition for #{name} due to a mismatch."
40
+ stream.puts " # either there's a bug in hairtrigger or you've messed up your migrations and/or db :-/"
41
+ else
42
+ stream.puts " # no candidate create_trigger statement could be found, creating an adapter-specific one"
43
+ end
44
+ if definition =~ /\n/
45
+ stream.print " execute(<<-TRIGGERSQL)\n#{definition.rstrip}\n TRIGGERSQL\n\n"
46
+ else
47
+ stream.print " execute(#{definition.inspect})\n\n"
48
+ end
49
+ end
50
+ end
51
+
52
+ def normalize_trigger(name, definition, type)
53
+ @connection = ActiveRecord::Base.connection
54
+ @adapter_name = @connection.adapter_name.downcase.to_sym
55
+
56
+ return definition unless @adapter_name == :postgresql
57
+ begin
58
+ # because postgres does not preserve the original CREATE TRIGGER/
59
+ # FUNCTION statements, its decompiled reconstruction will not match
60
+ # ours. we work around it by creating our generated trigger/function,
61
+ # asking postgres for its definition, and then rolling back.
62
+ begin
63
+ @connection.transaction do
64
+ chars = ('a'..'z').to_a + ('0'..'9').to_a + ['_']
65
+ test_name = '_hair_trigger_test_' + (0..43).map{ chars[(rand * chars.size).to_i] }.join
66
+ test_name << (type == :function ? '()' : '')
67
+ @connection.execute(definition.sub(name, test_name))
68
+ definition = @connection.triggers(:only => [test_name], :simple_check => true).values.first
69
+ definition.sub!(test_name, name)
70
+ raise
71
+ end
72
+ rescue
73
+ end
74
+ end
75
+ definition
76
+ end
77
+
78
+ def self.included(base)
79
+ base.class_eval do
80
+ alias_method_chain :trailer, :triggers
81
+ class << self
82
+ attr_accessor :previous_schema
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,77 +1,23 @@
1
1
  namespace :db do
2
2
  desc "Creates a database migration for any newly created/modified/deleted triggers in the models"
3
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\n").lstrip}
65
- end
66
-
67
- def self.down
68
- #{(down_drop_triggers + down_create_triggers).map{ |t| t.to_ruby(' ') }.join("\n\n").lstrip}
69
- end
70
- end
71
- MIGRATION
4
+ if file_name = HairTrigger.generate_migration
72
5
  puts "Generated #{file_name}"
73
6
  else
74
7
  puts "Nothing to do"
75
8
  end
76
9
  end
10
+
11
+ namespace :schema do
12
+ desc "Create a db/schema.rb file that can be portably used against any DB supported by AR"
13
+ task :dump => :environment do
14
+ require 'active_record/schema_dumper'
15
+ filename = ENV['SCHEMA'] || "#{RAILS_ROOT}/db/schema.rb"
16
+ ActiveRecord::SchemaDumper.previous_schema = File.exist?(filename) ? File.read(filename) : nil
17
+ File.open(filename, "w") do |file|
18
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file)
19
+ end
20
+ Rake::Task["db:schema:dump"].reenable
21
+ end
22
+ end
77
23
  end
data/spec/builder_spec.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require 'rspec'
2
- require File.expand_path(File.dirname(__FILE__) + '/../lib/hair_trigger/builder.rb')
2
+ require 'hair_trigger/builder'
3
3
 
4
4
  HairTrigger::Builder.show_warnings = false
5
5
 
@@ -25,6 +25,14 @@ describe "builder" do
25
25
  end
26
26
  end
27
27
 
28
+ context "generation" do
29
+ it "should tack on a semicolon if none is provided" do
30
+ @adapter = MockAdapter.new("mysql")
31
+ builder.on(:foos).after(:update){ "FOO " }.generate.
32
+ grep(/FOO;/).size.should eql(1)
33
+ end
34
+ end
35
+
28
36
  context "comparison" do
29
37
  it "should view identical triggers as identical" do
30
38
  @adapter = MockAdapter.new("mysql")
@@ -78,6 +86,12 @@ describe "builder" do
78
86
  }.should raise_error
79
87
  end
80
88
 
89
+ it "should reject for_each :statement" do
90
+ lambda {
91
+ builder.on(:foos).after(:update).for_each(:statement){ "FOO" }.generate
92
+ }.should raise_error
93
+ end
94
+
81
95
  it "should reject multiple events" do
82
96
  lambda {
83
97
  builder.on(:foos).after(:update, :delete){ "FOO" }.generate
@@ -0,0 +1,17 @@
1
+ class InitialTables < ActiveRecord::Migration
2
+ def self.up
3
+ create_table "users" do |t|
4
+ t.integer "group_id"
5
+ t.string "name"
6
+ end
7
+
8
+ create_table "groups" do |t|
9
+ t.integer "bob_count", :default => 0
10
+ end
11
+ end
12
+
13
+ def self.down
14
+ drop_table "users"
15
+ drop_table "groups"
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # This migration was auto-generated via `rake db:generate_trigger_migration'.
2
+ # While you can edit this file, any changes you make to the definitions here
3
+ # will be undone by the next auto-generated trigger migration.
4
+
5
+ class UserTrigger < ActiveRecord::Migration
6
+ def self.up
7
+ create_trigger("users_after_insert_row_when_new_name_bob__tr", :generated => true, :compatibility => 1).
8
+ on("users").
9
+ after(:insert).
10
+ where("NEW.name = 'bob'") do
11
+ "UPDATE groups SET bob_count = bob_count + 1"
12
+ end
13
+ end
14
+
15
+ def self.down
16
+ drop_trigger("users_after_insert_row_when_new_name_bob__tr", "users")
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ class Group < ActiveRecord::Base
2
+ has_many :users
3
+ end
@@ -0,0 +1,6 @@
1
+ class User < ActiveRecord::Base
2
+ belongs_to :group
3
+ trigger.after(:insert).where("NEW.name = 'bob'") do
4
+ "UPDATE groups SET bob_count = bob_count + 1"
5
+ end
6
+ end
@@ -0,0 +1,99 @@
1
+ require 'active_record'
2
+ require 'active_record/connection_adapters/postgresql_adapter'
3
+ require 'active_record/connection_adapters/mysql_adapter'
4
+ require 'active_record/connection_adapters/sqlite3_adapter'
5
+ require 'rspec'
6
+ require 'hair_trigger'
7
+
8
+ # for this spec to work, you need to have postgres and mysql installed (in
9
+ # addition to the gems), and you should make sure that you have set up
10
+ # hairtrigger_schema_test dbs in mysql and postgres, along with a hairtrigger
11
+ # user (no password) having appropriate privileges (user needs to be able to
12
+ # drop/recreate this db)
13
+
14
+ def reset_tmp
15
+ HairTrigger.model_path = 'tmp/models'
16
+ HairTrigger.migration_path = 'tmp/migrations'
17
+ FileUtils.rm_rf('tmp') if File.directory?('tmp')
18
+ FileUtils.mkdir_p(HairTrigger.model_path)
19
+ FileUtils.mkdir_p(HairTrigger.migration_path)
20
+ FileUtils.cp_r('spec/models', 'tmp')
21
+ FileUtils.cp_r('spec/migrations', 'tmp')
22
+ end
23
+
24
+ def initialize_db(adapter)
25
+ reset_tmp
26
+ config = {:database => 'hairtrigger_schema_test', :username => 'hairtrigger', :adapter => adapter.to_s, :host => 'localhost'}
27
+ case adapter
28
+ when :mysql
29
+ ret = `echo "drop database if exists hairtrigger_schema_test; create database hairtrigger_schema_test;" | mysql hairtrigger_schema_test -u hairtrigger`
30
+ raise "error creating database: #{ret}" unless $?.exitstatus == 0
31
+ when :sqlite3
32
+ config[:database] = 'tmp/hairtrigger_schema_test.sqlite3'
33
+ when :postgresql
34
+ `dropdb -U hairtrigger hairtrigger_schema_test &>/dev/null`
35
+ ret = `createdb -U hairtrigger hairtrigger_schema_test 2>&1`
36
+ raise "error creating database: #{ret}" unless $?.exitstatus == 0
37
+ config[:min_messages] = :error
38
+ end
39
+ ActiveRecord::Base.establish_connection(config)
40
+ ActiveRecord::Base.logger = Logger.new('/dev/null')
41
+ ActiveRecord::Migrator.migrate(HairTrigger.migration_path)
42
+ end
43
+
44
+ describe "schema" do
45
+ [:mysql, :postgresql, :sqlite3].each do |adapter|
46
+ it "should correctly dump #{adapter}" do
47
+ ActiveRecord::Migration.verbose = false
48
+ initialize_db(adapter)
49
+ ActiveRecord::Base.connection.triggers.values.grep(/bob_count \+ 1/).size.should eql(1)
50
+
51
+ # schema dump w/o previous schema.rb
52
+ ActiveRecord::SchemaDumper.previous_schema = nil
53
+ io = StringIO.new
54
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, io)
55
+ io.rewind
56
+ schema_rb = io.read
57
+ schema_rb.should match(/create_trigger\("users_after_insert_row_when_new_name_bob__tr", :generated => true, :compatibility => 1\)/)
58
+
59
+ # schema dump w/ schema.rb
60
+ ActiveRecord::SchemaDumper.previous_schema = schema_rb
61
+ io = StringIO.new
62
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, io)
63
+ io.rewind
64
+ schema_rb2 = io.read
65
+ schema_rb2.should eql(schema_rb)
66
+
67
+ # edit our model trigger, generate and apply a new migration
68
+ user_model = File.read(HairTrigger.model_path + '/user.rb')
69
+ File.open(HairTrigger.model_path + '/user.rb', 'w') { |f|
70
+ f.write user_model.sub('UPDATE groups SET bob_count = bob_count + 1', 'UPDATE groups SET bob_count = bob_count + 2')
71
+ }
72
+ migration = HairTrigger.generate_migration
73
+ ActiveRecord::Migrator.migrate(HairTrigger.migration_path)
74
+ ActiveRecord::Base.connection.triggers.values.grep(/bob_count \+ 1/).size.should eql(0)
75
+ ActiveRecord::Base.connection.triggers.values.grep(/bob_count \+ 2/).size.should eql(1)
76
+
77
+ # schema dump, should have the updated trigger
78
+ ActiveRecord::SchemaDumper.previous_schema = schema_rb2
79
+ io = StringIO.new
80
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, io)
81
+ io.rewind
82
+ schema_rb3 = io.read
83
+ schema_rb3.should_not eql(schema_rb2)
84
+ schema_rb3.should match(/create_trigger\("users_after_insert_row_when_new_name_bob__tr", :generated => true, :compatibility => 1\)/)
85
+ schema_rb3.should match(/UPDATE groups SET bob_count = bob_count \+ 2/)
86
+
87
+ # undo migration, schema dump should be back to previous version
88
+ ActiveRecord::Migrator.rollback(HairTrigger.migration_path)
89
+ ActiveRecord::SchemaDumper.previous_schema = schema_rb3
90
+ io = StringIO.new
91
+ ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, io)
92
+ io.rewind
93
+ schema_rb4 = io.read
94
+ schema_rb4.should_not eql(schema_rb3)
95
+ schema_rb4.should eql(schema_rb2)
96
+ ActiveRecord::Base.connection.triggers.values.grep(/bob_count \+ 1/).size.should eql(1)
97
+ end
98
+ end
99
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hairtrigger
3
3
  version: !ruby/object:Gem::Version
4
- hash: 19
4
+ hash: 17
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
8
  - 1
9
- - 4
10
- version: 0.1.4
9
+ - 5
10
+ version: 0.1.5
11
11
  platform: ruby
12
12
  authors:
13
13
  - Jon Jensen
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-03-25 00:00:00 -06:00
18
+ date: 2011-03-31 00:00:00 -06:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -105,6 +105,54 @@ dependencies:
105
105
  type: :development
106
106
  - !ruby/object:Gem::Dependency
107
107
  requirement: &id006 !ruby/object:Gem::Requirement
108
+ none: false
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ hash: 45
113
+ segments:
114
+ - 2
115
+ - 8
116
+ - 1
117
+ version: 2.8.1
118
+ version_requirements: *id006
119
+ name: mysql
120
+ prerelease: false
121
+ type: :development
122
+ - !ruby/object:Gem::Dependency
123
+ requirement: &id007 !ruby/object:Gem::Requirement
124
+ none: false
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ hash: 53
129
+ segments:
130
+ - 0
131
+ - 10
132
+ - 1
133
+ version: 0.10.1
134
+ version_requirements: *id007
135
+ name: pg
136
+ prerelease: false
137
+ type: :development
138
+ - !ruby/object:Gem::Dependency
139
+ requirement: &id008 !ruby/object:Gem::Requirement
140
+ none: false
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ hash: 31
145
+ segments:
146
+ - 1
147
+ - 3
148
+ - 2
149
+ version: 1.3.2
150
+ version_requirements: *id008
151
+ name: sqlite3-ruby
152
+ prerelease: false
153
+ type: :development
154
+ - !ruby/object:Gem::Dependency
155
+ requirement: &id009 !ruby/object:Gem::Requirement
108
156
  none: false
109
157
  requirements:
110
158
  - - <
@@ -122,12 +170,12 @@ dependencies:
122
170
  - 3
123
171
  - 0
124
172
  version: 2.3.0
125
- version_requirements: *id006
173
+ version_requirements: *id009
126
174
  name: activerecord
127
175
  prerelease: false
128
176
  type: :runtime
129
177
  - !ruby/object:Gem::Dependency
130
- requirement: &id007 !ruby/object:Gem::Requirement
178
+ requirement: &id010 !ruby/object:Gem::Requirement
131
179
  none: false
132
180
  requirements:
133
181
  - - ~>
@@ -138,7 +186,7 @@ dependencies:
138
186
  - 3
139
187
  - 0
140
188
  version: 2.3.0
141
- version_requirements: *id007
189
+ version_requirements: *id010
142
190
  name: rspec
143
191
  prerelease: false
144
192
  type: :development
@@ -165,10 +213,16 @@ files:
165
213
  - lib/hair_trigger/base.rb
166
214
  - lib/hair_trigger/builder.rb
167
215
  - lib/hair_trigger/migration.rb
216
+ - lib/hair_trigger/schema.rb
217
+ - lib/hair_trigger/schema_dumper.rb
168
218
  - lib/tasks/hair_trigger.rake
169
219
  - rails/init.rb
170
220
  - spec/builder_spec.rb
171
- - spec/spec_helper.rb
221
+ - spec/migrations/20110331212003_initial_tables.rb
222
+ - spec/migrations/20110331212631_user_trigger.rb
223
+ - spec/models/group.rb
224
+ - spec/models/user.rb
225
+ - spec/schema_dumper_spec.rb
172
226
  has_rdoc: true
173
227
  homepage: http://github.com/jenseng/hair_trigger
174
228
  licenses:
@@ -205,4 +259,8 @@ specification_version: 3
205
259
  summary: easy database triggers for active record
206
260
  test_files:
207
261
  - spec/builder_spec.rb
208
- - spec/spec_helper.rb
262
+ - spec/migrations/20110331212003_initial_tables.rb
263
+ - spec/migrations/20110331212631_user_trigger.rb
264
+ - spec/models/group.rb
265
+ - spec/models/user.rb
266
+ - spec/schema_dumper_spec.rb
data/spec/spec_helper.rb DELETED
@@ -1,12 +0,0 @@
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