schema_plus_foreign_keys 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.travis.yml +21 -0
  4. data/Gemfile +5 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +200 -0
  7. data/Rakefile +9 -0
  8. data/gemfiles/Gemfile.base +4 -0
  9. data/gemfiles/activerecord-4.2.0/Gemfile.base +3 -0
  10. data/gemfiles/activerecord-4.2.0/Gemfile.mysql2 +10 -0
  11. data/gemfiles/activerecord-4.2.0/Gemfile.postgresql +10 -0
  12. data/gemfiles/activerecord-4.2.0/Gemfile.sqlite3 +10 -0
  13. data/gemfiles/activerecord-4.2.1/Gemfile.base +3 -0
  14. data/gemfiles/activerecord-4.2.1/Gemfile.mysql2 +10 -0
  15. data/gemfiles/activerecord-4.2.1/Gemfile.postgresql +10 -0
  16. data/gemfiles/activerecord-4.2.1/Gemfile.sqlite3 +10 -0
  17. data/lib/schema_plus/foreign_keys.rb +78 -0
  18. data/lib/schema_plus/foreign_keys/active_record/base.rb +33 -0
  19. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/abstract_adapter.rb +168 -0
  20. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/foreign_key_definition.rb +137 -0
  21. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/mysql2_adapter.rb +126 -0
  22. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/postgresql_adapter.rb +89 -0
  23. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/sqlite3_adapter.rb +77 -0
  24. data/lib/schema_plus/foreign_keys/active_record/connection_adapters/table_definition.rb +108 -0
  25. data/lib/schema_plus/foreign_keys/active_record/migration/command_recorder.rb +29 -0
  26. data/lib/schema_plus/foreign_keys/middleware/dumper.rb +88 -0
  27. data/lib/schema_plus/foreign_keys/middleware/migration.rb +147 -0
  28. data/lib/schema_plus/foreign_keys/middleware/model.rb +15 -0
  29. data/lib/schema_plus/foreign_keys/middleware/mysql.rb +20 -0
  30. data/lib/schema_plus/foreign_keys/middleware/sql.rb +27 -0
  31. data/lib/schema_plus/foreign_keys/version.rb +5 -0
  32. data/lib/schema_plus_foreign_keys.rb +1 -0
  33. data/schema_dev.yml +9 -0
  34. data/schema_plus_foreign_keys.gemspec +31 -0
  35. data/spec/deprecation_spec.rb +161 -0
  36. data/spec/foreign_key_definition_spec.rb +34 -0
  37. data/spec/foreign_key_spec.rb +207 -0
  38. data/spec/migration_spec.rb +570 -0
  39. data/spec/named_schemas_spec.rb +136 -0
  40. data/spec/schema_dumper_spec.rb +257 -0
  41. data/spec/spec_helper.rb +60 -0
  42. data/spec/support/reference.rb +79 -0
  43. metadata +221 -0
@@ -0,0 +1,33 @@
1
+ module SchemaPlus::ForeignKeys
2
+ module ActiveRecord
3
+
4
+ #
5
+ # SchemaPlus::ForeignKeys adds several methods to ActiveRecord::Base
6
+ #
7
+ module Base
8
+ module ClassMethods #:nodoc:
9
+
10
+ public
11
+
12
+ # Returns a list of ForeignKeyDefinition objects, for each foreign
13
+ # key constraint defined in this model's table
14
+ #
15
+ # (memoized result gets reset in Middleware::Model::ResetColumnInformation)
16
+ def foreign_keys
17
+ @foreign_keys ||= connection.foreign_keys(table_name, "#{name} Foreign Keys")
18
+ end
19
+
20
+ def reset_foreign_key_information
21
+ @foreign_keys = @reverse_foreign_keys = nil
22
+ end
23
+
24
+ # Returns a list of ForeignKeyDefinition objects, for each foreign
25
+ # key constraint of other tables that refer to this model's table
26
+ def reverse_foreign_keys
27
+ @reverse_foreign_keys ||= connection.reverse_foreign_keys(table_name, "#{name} Reverse Foreign Keys")
28
+ end
29
+
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,168 @@
1
+ module SchemaPlus::ForeignKeys
2
+ module ActiveRecord
3
+ # SchemaPlus::ForeignKeys adds several methods to the connection adapter (as returned by ActiveRecordBase#connection). See AbstractAdapter for details.
4
+ module ConnectionAdapters
5
+
6
+ #
7
+ # SchemaPlus::ForeignKeys adds several methods to
8
+ # ActiveRecord::ConnectionAdapters::AbstractAdapter. In most cases
9
+ # you don't call these directly, but rather the methods that define
10
+ # things are called by schema statements, and methods that query
11
+ # things are called by ActiveRecord::Base.
12
+ #
13
+ module AbstractAdapter
14
+
15
+ # Define a foreign key constraint. Valid options are :on_update,
16
+ # :on_delete, and :deferrable, with values as described at
17
+ # ConnectionAdapters::ForeignKeyDefinition
18
+ #
19
+ # (NOTE: Sqlite3 does not support altering a table to add foreign-key
20
+ # constraints; they must be included in the table specification when
21
+ # it's created. If you're using Sqlite3, this method will raise an
22
+ # error.)
23
+ def add_foreign_key(*args) # (table_name, column, to_table, primary_key, options = {})
24
+ options = args.extract_options!
25
+ case args.length
26
+ when 2
27
+ from_table, to_table = args
28
+ when 4
29
+ ActiveSupport::Deprecation.warn "4-argument form of add_foreign_key is deprecated. use add_foreign_key(from_table, to_table, options)"
30
+ (from_table, column, to_table, primary_key) = args
31
+ options.merge!(column: column, primary_key: primary_key)
32
+ end
33
+
34
+ options = options.dup
35
+ options[:column] ||= foreign_key_column_for(to_table)
36
+ options[:name] ||= ForeignKeyDefinition.default_name(from_table, options[:column])
37
+
38
+ foreign_key_sql = add_foreign_key_sql(from_table, to_table, options)
39
+ execute "ALTER TABLE #{quote_table_name(from_table)} #{foreign_key_sql}"
40
+ end
41
+
42
+ # called directly by AT's bulk_change_table, for migration
43
+ # change_table :name, :bulk => true { ... }
44
+ def add_foreign_key_sql(from_table, to_table, options = {}) #:nodoc:
45
+ foreign_key = ::ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(from_table, AbstractAdapter.proper_table_name(to_table), options)
46
+ "ADD #{foreign_key.to_sql}"
47
+ end
48
+
49
+ def _build_foreign_key(from_table, to_table, options = {}) #:nodoc:
50
+ ::ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(from_table, AbstractAdapter.proper_table_name(to_table), options)
51
+ end
52
+
53
+ def self.proper_table_name(name)
54
+ proper_name = ::ActiveRecord::Migration.new.proper_table_name(name)
55
+ end
56
+
57
+ # Remove a foreign key constraint
58
+ #
59
+ # Arguments are the same as for add_foreign_key, or by name:
60
+ #
61
+ # remove_foreign_key table_name, to_table, options
62
+ # remove_foreign_key table_name, name: constraint_name
63
+ #
64
+ # (NOTE: Sqlite3 does not support altering a table to remove
65
+ # foreign-key constraints. If you're using Sqlite3, this method will
66
+ # raise an error.)
67
+ def remove_foreign_key(*args)
68
+ from_table, to_table, options = normalize_remove_foreign_key_args(*args)
69
+ options[:column] ||= foreign_key_column_for(to_table)
70
+ if sql = remove_foreign_key_sql(from_table, to_table, options)
71
+ execute "ALTER TABLE #{quote_table_name(from_table)} #{sql}"
72
+ end
73
+ end
74
+
75
+ def normalize_remove_foreign_key_args(*args)
76
+ options = args.extract_options!
77
+ if options.has_key? :column_names
78
+ ActiveSupport::Deprecation.warn ":column_names option is deprecated, use :column"
79
+ options[:column] = options.delete(:column_names)
80
+ end
81
+ if options.has_key? :references_column_names
82
+ ActiveSupport::Deprecation.warn ":references_column_names option is deprecated, use :primary_key"
83
+ options[:primary_key] = options.delete(:references_column_names)
84
+ end
85
+ if options.has_key? :references_table_name
86
+ ActiveSupport::Deprecation.warn ":references_table_name option is deprecated, use :to_table"
87
+ options[:to_table] = options.delete(:references_table_name)
88
+ end
89
+ case args.length
90
+ when 1
91
+ from_table = args[0]
92
+ when 2
93
+ from_table, to_table = args
94
+ when 3, 4
95
+ ActiveSupport::Deprecation.warn "3- and 4-argument forms of remove_foreign_key are deprecated. use add_foreign_key(from_table, to_table, options)"
96
+ (from_table, column, to_table, primary_key) = args
97
+ options.merge!(column: column, primary_key: primary_key)
98
+ else
99
+ raise ArgumentError, "Wrong number of arguments(#{args.length}) to remove_foreign_key"
100
+ end
101
+ to_table ||= options.delete(:to_table)
102
+ [from_table, to_table, options]
103
+ end
104
+
105
+ def get_foreign_key_name(from_table, to_table, options)
106
+ return options[:name] if options[:name]
107
+
108
+ fks = foreign_keys(from_table)
109
+ if fks.detect(&its.name == to_table)
110
+ ActiveSupport::Deprecation.warn "remove_foreign_key(table, name) is deprecated. use remove_foreign_key(table, name: name)"
111
+ return to_table
112
+ end
113
+ test_fk = _build_foreign_key(from_table, to_table, options)
114
+ if fk = fks.detect { |fk| fk.match(test_fk) }
115
+ fk.name
116
+ else
117
+ raise "SchemaPlus::ForeignKeys: no foreign key constraint found on #{from_table.inspect} matching #{[to_table, options].inspect}" unless options[:if_exists]
118
+ nil
119
+ end
120
+ end
121
+
122
+ def remove_foreign_key_sql(from_table, to_table, options)
123
+ if foreign_key_name = get_foreign_key_name(from_table, to_table, options)
124
+ "DROP CONSTRAINT #{options[:if_exists] ? "IF EXISTS" : ""} #{foreign_key_name}"
125
+ end
126
+ end
127
+
128
+
129
+ # called from individual adpaters, after renaming table from old
130
+ # name to
131
+ def rename_foreign_keys(oldname, newname) #:nodoc:
132
+ foreign_keys(newname).each do |fk|
133
+ index = indexes(newname).find{|index| index.name == ForeignKeyDefinition.auto_index_name(oldname, fk.column)}
134
+ begin
135
+ remove_foreign_key(newname, name: fk.name)
136
+ rescue NotImplementedError
137
+ # sqlite3 can't remove foreign keys, so just skip it
138
+ end
139
+ # rename the index only when the fk constraint doesn't exist.
140
+ # mysql doesn't allow the rename (which is a delete & add)
141
+ # if the index is on a foreign key constraint
142
+ rename_index(newname, index.name, ForeignKeyDefinition.auto_index_name(newname, index.columns)) if index
143
+ begin
144
+ add_foreign_key(newname, fk.to_table, :column => fk.column, :primary_key => fk.primary_key, :name => fk.name.sub(/#{oldname}/, newname), :on_update => fk.on_update, :on_delete => fk.on_delete, :deferrable => fk.deferrable)
145
+ rescue NotImplementedError
146
+ # sqlite3 can't add foreign keys, so just skip it
147
+ end
148
+ end
149
+ end
150
+
151
+
152
+ #####################################################################
153
+ #
154
+ # The functions below here are abstract; each subclass should
155
+ # define them all. Defining them here only for reference.
156
+ #
157
+
158
+ # (abstract) Return the ForeignKeyDefinition objects for foreign key
159
+ # constraints defined on this table
160
+ def foreign_keys(table_name, name = nil) raise "Internal Error: Connection adapter didn't override abstract function"; [] end
161
+
162
+ # (abstract) Return the ForeignKeyDefinition objects for foreign key
163
+ # constraints defined on other tables that reference this table
164
+ def reverse_foreign_keys(table_name, name = nil) raise "Internal Error: Connection adapter didn't override abstract function"; [] end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,137 @@
1
+ require 'active_record/connection_adapters/abstract/schema_definitions'
2
+
3
+ module SchemaPlus::ForeignKeys
4
+ module ActiveRecord
5
+ module ConnectionAdapters
6
+ # Instances of this class are returned by the queries ActiveRecord::Base#foreign_keys and ActiveRecord::Base#reverse_foreign_keys (via AbstractAdapter#foreign_keys and AbstractAdapter#reverse_foreign_keys)
7
+ #
8
+ # The on_update and on_delete attributes can take on the following values:
9
+ # :cascade
10
+ # :restrict
11
+ # :nullify
12
+ # :set_default
13
+ # :no_action
14
+ #
15
+ # The deferrable attribute can take on the following values:
16
+ # true
17
+ # :initially_deferred
18
+ module ForeignKeyDefinition
19
+
20
+ def column_names
21
+ ActiveSupport::Deprecation.warn "ForeignKeyDefinition#column_names is deprecated, use Array.wrap(column)"
22
+ Array.wrap(column)
23
+ end
24
+
25
+ def references_column_names
26
+ ActiveSupport::Deprecation.warn "ForeignKeyDefinition#references_column_names is deprecated, use Array.wrap(primary_key)"
27
+ Array.wrap(primary_key)
28
+ end
29
+
30
+ def references_table_name
31
+ ActiveSupport::Deprecation.warn "ForeignKeyDefinition#references_table_name is deprecated, use #to_table"
32
+ to_table
33
+ end
34
+
35
+ def table_name
36
+ ActiveSupport::Deprecation.warn "ForeignKeyDefinition#table_name is deprecated, use #from_table"
37
+ from_table
38
+ end
39
+
40
+ ACTIONS = { :cascade => "CASCADE", :restrict => "RESTRICT", :nullify => "SET NULL", :set_default => "SET DEFAULT", :no_action => "NO ACTION" }.freeze
41
+ ACTION_LOOKUP = ACTIONS.invert.freeze
42
+
43
+ def initialize(from_table, to_table, options={})
44
+ [:on_update, :on_delete].each do |key|
45
+ if options[key] == :set_null
46
+ ActiveSupport::Deprecation.warn ":set_null value for #{key} is deprecated. use :nullify instead"
47
+ options[key] = :nullify
48
+ end
49
+ end
50
+
51
+ super from_table, to_table, options
52
+
53
+ if column.is_a?(Array) and column.length == 1
54
+ options[:column] = column[0]
55
+ end
56
+ if primary_key.is_a?(Array) and primary_key.length == 1
57
+ options[:primary_key] = primary_key[0]
58
+ end
59
+
60
+ ACTIONS.has_key?(on_update) or raise(ArgumentError, "invalid :on_update action: #{on_update.inspect}") if on_update
61
+ ACTIONS.has_key?(on_delete) or raise(ArgumentError, "invalid :on_delete action: #{on_delete.inspect}") if on_delete
62
+ if ::ActiveRecord::Base.connection.adapter_name =~ /^mysql/i
63
+ raise(NotImplementedError, "MySQL does not support ON UPDATE SET DEFAULT") if on_update == :set_default
64
+ raise(NotImplementedError, "MySQL does not support ON DELETE SET DEFAULT") if on_delete == :set_default
65
+ end
66
+ end
67
+
68
+ # Truthy if the constraint is deferrable
69
+ def deferrable
70
+ options[:deferrable]
71
+ end
72
+
73
+ # Dumps a definition of foreign key.
74
+ def to_dump(column: nil)
75
+ dump = case
76
+ when column then "foreign_key: {references:"
77
+ else "add_foreign_key #{from_table.inspect},"
78
+ end
79
+ dump << " #{to_table.inspect}"
80
+
81
+ val_or_array = -> val { val.is_a?(Array) ? "[#{val.map(&:inspect).join(', ')}]" : val.inspect }
82
+
83
+ dump << ", column: #{val_or_array.call self.column}" unless column
84
+ dump << ", primary_key: #{val_or_array.call self.primary_key}" if custom_primary_key?
85
+ dump << ", name: #{name.inspect}" if name
86
+ dump << ", on_update: #{on_update.inspect}" if on_update
87
+ dump << ", on_delete: #{on_delete.inspect}" if on_delete
88
+ dump << ", deferrable: #{deferrable.inspect}" if deferrable
89
+ dump << "}" if column
90
+ dump
91
+ end
92
+
93
+ def to_sql
94
+ sql = name ? "CONSTRAINT #{name} " : ""
95
+ sql << "FOREIGN KEY (#{quoted_column_names.join(", ")}) REFERENCES #{quoted_to_table} (#{quoted_primary_keys.join(", ")})"
96
+ sql << " ON UPDATE #{ACTIONS[on_update]}" if on_update
97
+ sql << " ON DELETE #{ACTIONS[on_delete]}" if on_delete
98
+ sql << " DEFERRABLE" if deferrable
99
+ sql << " INITIALLY DEFERRED" if deferrable == :initially_deferred
100
+ sql
101
+ end
102
+
103
+ def quoted_column_names
104
+ Array(column).map { |name| ::ActiveRecord::Base.connection.quote_column_name(name) }
105
+ end
106
+
107
+ def quoted_primary_keys
108
+ Array(primary_key).map { |name| ::ActiveRecord::Base.connection.quote_column_name(name) }
109
+ end
110
+
111
+ def quoted_to_table
112
+ ::ActiveRecord::Base.connection.quote_table_name(to_table)
113
+ end
114
+
115
+ def self.default_name(from_table, column)
116
+ "fk_#{fixup_schema_name(from_table)}_#{Array.wrap(column).join('_and_')}"
117
+ end
118
+
119
+ def self.auto_index_name(from_table, column_name)
120
+ "fk__#{fixup_schema_name(from_table)}_#{Array.wrap(column_name).join('_and_')}"
121
+ end
122
+
123
+ def self.fixup_schema_name(table_name)
124
+ # replace . with _
125
+ table_name.to_s.gsub(/[.]/, '_')
126
+ end
127
+
128
+ def match(test)
129
+ return false unless from_table == test.from_table
130
+ [:to_table, :column].reject{ |attr| test.send(attr).blank? }.all? { |attr|
131
+ test.send(attr).to_s == self.send(attr).to_s
132
+ }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,126 @@
1
+ module SchemaPlus::ForeignKeys
2
+ module ActiveRecord
3
+ module ConnectionAdapters
4
+ # SchemaPlus::ForeignKeys includes a MySQL implementation of the AbstractAdapter
5
+ # extensions.
6
+ module Mysql2Adapter
7
+
8
+ #:enddoc:
9
+
10
+ def remove_column(table_name, column_name, type=nil, options={})
11
+ foreign_keys(table_name).select { |foreign_key| Array.wrap(foreign_key.column).include?(column_name.to_s) }.each do |foreign_key|
12
+ remove_foreign_key(table_name, name: foreign_key.name)
13
+ end
14
+ super table_name, column_name, type, options
15
+ end
16
+
17
+ def rename_table(oldname, newname)
18
+ super
19
+ rename_foreign_keys(oldname, newname)
20
+ end
21
+
22
+ def remove_foreign_key(*args)
23
+ from_table, to_table, options = normalize_remove_foreign_key_args(*args)
24
+ if options[:if_exists]
25
+ foreign_key_name = get_foreign_key_name(from_table, to_table, options)
26
+ return if !foreign_key_name or not foreign_keys(from_table).detect{|fk| fk.name == foreign_key_name}
27
+ end
28
+ options.delete(:if_exists)
29
+ super from_table, to_table, options
30
+ end
31
+
32
+ def remove_foreign_key_sql(*args)
33
+ super.tap { |ret|
34
+ ret.sub!(/DROP CONSTRAINT/, 'DROP FOREIGN KEY') if ret
35
+ }
36
+ end
37
+
38
+ def foreign_keys(table_name, name = nil)
39
+ results = select_all("SHOW CREATE TABLE #{quote_table_name(table_name)}", name)
40
+
41
+ table_name = table_name.to_s
42
+ namespace_prefix = table_namespace_prefix(table_name)
43
+
44
+ foreign_keys = []
45
+
46
+ results.each do |result|
47
+ create_table_sql = result["Create Table"]
48
+ create_table_sql.lines.each do |line|
49
+ if line =~ /^ CONSTRAINT [`"](.+?)[`"] FOREIGN KEY \([`"](.+?)[`"]\) REFERENCES [`"](.+?)[`"] \((.+?)\)( ON DELETE (.+?))?( ON UPDATE (.+?))?,?$/
50
+ name = $1
51
+ columns = $2
52
+ to_table = $3
53
+ to_table = namespace_prefix + to_table if table_namespace_prefix(to_table).blank?
54
+ primary_keys = $4
55
+ on_update = $8
56
+ on_delete = $6
57
+ on_update = ForeignKeyDefinition::ACTION_LOOKUP[on_update] || :restrict
58
+ on_delete = ForeignKeyDefinition::ACTION_LOOKUP[on_delete] || :restrict
59
+
60
+ options = { :name => name,
61
+ :on_delete => on_delete,
62
+ :on_update => on_update,
63
+ :column => columns.gsub('`', '').split(', '),
64
+ :primary_key => primary_keys.gsub('`', '').split(', ')
65
+ }
66
+
67
+ foreign_keys << ::ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
68
+ namespace_prefix + table_name,
69
+ to_table,
70
+ options)
71
+ end
72
+ end
73
+ end
74
+
75
+ foreign_keys
76
+ end
77
+
78
+ def reverse_foreign_keys(table_name, name = nil)
79
+ results = select_all(<<-SQL, name)
80
+ SELECT constraint_name, table_name, column_name, referenced_table_name, referenced_column_name
81
+ FROM information_schema.key_column_usage
82
+ WHERE table_schema = #{table_schema_sql(table_name)}
83
+ AND referenced_table_schema = table_schema
84
+ ORDER BY constraint_name, ordinal_position;
85
+ SQL
86
+
87
+ constraints = results.to_a.group_by do |r|
88
+ r.values_at('constraint_name', 'table_name', 'referenced_table_name')
89
+ end
90
+
91
+ from_table_constraints = constraints.select do |(_, _, to_table), _|
92
+ table_name_without_namespace(table_name).casecmp(to_table) == 0
93
+ end
94
+
95
+ from_table_constraints.map do |(constraint_name, from_table, to_table), columns|
96
+ from_table = table_namespace_prefix(from_table) + from_table
97
+ to_table = table_namespace_prefix(to_table) + to_table
98
+
99
+ options = {
100
+ :name => constraint_name,
101
+ :column => columns.map { |row| row['column_name'] },
102
+ :primary_key => columns.map { |row| row['referenced_column_name'] }
103
+ }
104
+
105
+ ::ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(from_table, to_table, options)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def table_namespace_prefix(table_name)
112
+ table_name.to_s =~ /(.*[.])/ ? $1 : ""
113
+ end
114
+
115
+ def table_schema_sql(table_name)
116
+ table_name.to_s =~ /(.*)[.]/ ? "'#{$1}'" : "SCHEMA()"
117
+ end
118
+
119
+ def table_name_without_namespace(table_name)
120
+ table_name.to_s.sub /.*[.]/, ''
121
+ end
122
+
123
+ end
124
+ end
125
+ end
126
+ end