schema_plus_foreign_keys 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.
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