From 491d5634bb32ca2f9471ea31e0f23ea178c6a8f3 Mon Sep 17 00:00:00 2001 From: Romain Pomier Date: Thu, 19 Mar 2015 22:19:35 +0100 Subject: [PATCH] Add support for tables with multiple outbound foreign key constraints --- lib/lhm/migrator.rb | 4 +- lib/lhm/table.rb | 91 +++++++++++++++++++++++- spec/fixtures/fk_example.ddl | 8 +++ spec/fixtures/fk_example_second_pass.ddl | 8 +++ spec/integration/foreign_keys_spec.rb | 89 +++++++++++++++++++++++ spec/integration/integration_helper.rb | 5 ++ spec/integration/table_spec.rb | 17 +++++ spec/unit/table_spec.rb | 19 ++++- 8 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 spec/fixtures/fk_example.ddl create mode 100644 spec/fixtures/fk_example_second_pass.ddl create mode 100644 spec/integration/foreign_keys_spec.rb diff --git a/lib/lhm/migrator.rb b/lib/lhm/migrator.rb index b0ee6608..bb19d4a2 100644 --- a/lib/lhm/migrator.rb +++ b/lib/lhm/migrator.rb @@ -207,9 +207,7 @@ def execute end def destination_create - original = %{CREATE TABLE `#{ @origin.name }`} - replacement = %{CREATE TABLE `#{ @origin.destination_name }`} - stmt = @origin.ddl.gsub(original, replacement) + stmt = @origin.destination_ddl @connection.execute(tagged(stmt)) end diff --git a/lib/lhm/table.rb b/lib/lhm/table.rb index 7ff30fd2..032843fb 100644 --- a/lib/lhm/table.rb +++ b/lib/lhm/table.rb @@ -5,12 +5,14 @@ module Lhm class Table - attr_reader :name, :columns, :indices, :pk, :ddl + attr_reader :schema, :name, :columns, :indices, :constraints, :pk, :ddl - def initialize(name, pk = 'id', ddl = nil) + def initialize(name, schema = 'default', pk = 'id', ddl = nil) @name = name + @schema = schema @columns = {} @indices = {} + @constraints = {} @pk = pk @ddl = ddl end @@ -28,6 +30,32 @@ def self.parse(table_name, connection) Parser.new(table_name, connection).parse end + def destination_ddl + original = %r{CREATE TABLE ("|`)#{ name }\1} + repl = '\1' + replacement = %Q{CREATE TABLE #{ repl }#{ destination_name }#{ repl }} + + dest = ddl + dest.gsub!(original, replacement) + + foreign_keys = constraints.select { |col, c| !c[:referenced_column].nil? } + + foreign_keys.keys.each_with_index do |key, i| + original = foreign_keys[key][:name] + replacement = replacement_constraint(original) + dest.gsub!(original, replacement) + end + + dest + end + + @@schema_constraints = {} + + def self.schema_constraints(schema, value = nil) + @@schema_constraints[schema] = value if value + @@schema_constraints[schema] + end + class Parser include SqlHelper @@ -47,7 +75,7 @@ def ddl def parse schema = read_information_schema - Table.new(@table_name, extract_primary_key(schema), ddl).tap do |table| + Table.new(@table_name, @schema_name, extract_primary_key(schema), ddl).tap do |table| schema.each do |defn| column_name = struct_key(defn, 'COLUMN_NAME') column_type = struct_key(defn, 'COLUMN_TYPE') @@ -64,6 +92,15 @@ def parse extract_indices(read_indices).each do |idx, columns| table.indices[idx] = columns end + + constraints = {} + extract_constraints(read_constraints(nil)).each do |data| + if data[:schema] == @schema_name && data[:table] == @table_name + table.constraints[data[:column]] = data + end + constraints[data[:name]] = data + end + Table.schema_constraints(@schema_name, constraints) end end @@ -98,6 +135,47 @@ def extract_indices(indices) end end + def read_constraints(table = @table_name) + query = %Q{ + select * + from information_schema.key_column_usage + where table_schema = '#{ @schema_name }' + and referenced_column_name is not null + } + query += %Q{ + and table_name = '#{ @table_name }' + } if table + + @connection.select_all(query) + end + + def extract_constraints(constraints) + columns = %w{ + CONSTRAINT_NAME + TABLE_SCHEMA + TABLE_NAME + COLUMN_NAME + ORDINAL_POSITION + POSITION_IN_UNIQUE_CONSTRAINT + REFERENCED_TABLE_SCHEMA + REFERENCED_TABLE_NAME + REFERENCED_COLUMN_NAME + } + + constraints.map do |row| + result = {} + columns.each do |c| + sym = c.dup + # The order of these substitutions is important + sym.gsub!(/CONSTRAINT_/, '') + sym.gsub!(/_NAME/, '') + sym.gsub!(/TABLE_/, '') + result[sym.downcase.to_sym] = row[struct_key(row, c)] + end + result + end + end + def extract_primary_key(schema) cols = schema.select do |defn| column_key = struct_key(defn, 'COLUMN_KEY') @@ -112,5 +190,12 @@ def extract_primary_key(schema) keys.length == 1 ? keys.first : keys end end + + private + + def replacement_constraint(name) + (name =~ /_lhmn$/).nil? ? "#{name}_lhmn" : name.gsub(/_lhmn$/, '') + end + end end diff --git a/spec/fixtures/fk_example.ddl b/spec/fixtures/fk_example.ddl new file mode 100644 index 00000000..db28119f --- /dev/null +++ b/spec/fixtures/fk_example.ddl @@ -0,0 +1,8 @@ +CREATE TABLE `fk_example` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `master_id` int(11) NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_example_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `fk_example_ibfk_2` FOREIGN KEY (`master_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 diff --git a/spec/fixtures/fk_example_second_pass.ddl b/spec/fixtures/fk_example_second_pass.ddl new file mode 100644 index 00000000..189d0157 --- /dev/null +++ b/spec/fixtures/fk_example_second_pass.ddl @@ -0,0 +1,8 @@ +CREATE TABLE `fk_example_second_pass` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `master_id` int(11) NOT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_example_ibfk_1_lhmn` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), + CONSTRAINT `fk_example_ibfk_2_lhmn` FOREIGN KEY (`master_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 diff --git a/spec/integration/foreign_keys_spec.rb b/spec/integration/foreign_keys_spec.rb new file mode 100644 index 00000000..f9ecafc9 --- /dev/null +++ b/spec/integration/foreign_keys_spec.rb @@ -0,0 +1,89 @@ +# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias +# Schmidt + +require File.expand_path(File.dirname(__FILE__)) + '/integration_helper' + +require 'lhm' + +describe Lhm do + include IntegrationHelper + + before(:each) do + connect_master! + Lhm.cleanup(true) + %w(fk_example fk_example_second_pass).each do |table| + execute "drop table if exists #{table}" + end + table_create(:users) + end + + describe 'the simplest case' do + before(:each) do + table_create(:fk_example) + end + + after(:each) do + execute 'drop table if exists fk_example' + Lhm.cleanup(true) + end + + it 'should handle tables with foreign keys by appending the suffix' do + Lhm.change_table(:fk_example) do |t| + t.add_column(:new_column, "INT(12) DEFAULT '0'") + end + + slave do + actual = table_read(:fk_example).constraints['user_id'] + expected = { + name: 'fk_example_ibfk_1_lhmn', + referenced_table: 'users', + referenced_column: 'id', + } + hash_slice(actual, expected.keys).must_equal(expected) + + actual = table_read(:fk_example).constraints['master_id'] + expected = { + name: 'fk_example_ibfk_2_lhmn', + referenced_table: 'users', + referenced_column: 'id', + } + hash_slice(actual, expected.keys).must_equal(expected) + end + end + end + + describe 'manage a new migration by removing the suffix' do + before(:each) do + table_create(:fk_example_second_pass) + end + + after(:each) do + execute 'drop table if exists fk_example_second_pass' + Lhm.cleanup(true) + end + + it 'should be able to create this table' do + Lhm.change_table(:fk_example_second_pass) do |t| + t.add_column(:new_column, "INT(12) DEFAULT '0'") + end + + slave do + actual = table_read(:fk_example_second_pass).constraints['user_id'] + expected = { + name: 'fk_example_ibfk_1', + referenced_table: 'users', + referenced_column: 'id', + } + hash_slice(actual, expected.keys).must_equal(expected) + + actual = table_read(:fk_example_second_pass).constraints['master_id'] + expected = { + name: 'fk_example_ibfk_2', + referenced_table: 'users', + referenced_column: 'id', + } + hash_slice(actual, expected.keys).must_equal(expected) + end + end + end +end diff --git a/spec/integration/integration_helper.rb b/spec/integration/integration_helper.rb index 3fe2569f..102ee374 100644 --- a/spec/integration/integration_helper.rb +++ b/spec/integration/integration_helper.rb @@ -125,6 +125,11 @@ def table_exists?(table) connection.table_exists?(table.name) end + def hash_slice(hash, keys) + return hash.slice(*keys) if hash.respond_to?(:slice) + keys.each { |k| [k, hash[k]] }.to_h + end + # # Database Helpers # diff --git a/spec/integration/table_spec.rb b/spec/integration/table_spec.rb index ef3276dc..3d88b51b 100644 --- a/spec/integration/table_spec.rb +++ b/spec/integration/table_spec.rb @@ -86,6 +86,23 @@ indices['index_users_on_reference']. must_equal(['reference']) end + + it 'should parse constraints' do + begin + @table = table_create(:fk_example) + @table.constraints.keys.must_equal %w(user_id master_id) + + expected = { + name: 'fk_example_ibfk_1', + referenced_table: 'users', + referenced_column: 'id' + } + + hash_slice(@table.constraints['user_id'], expected.keys).must_equal expected + ensure + execute 'drop table if exists fk_example' + end + end end end end diff --git a/spec/unit/table_spec.rb b/spec/unit/table_spec.rb index c75181f4..01816e73 100644 --- a/spec/unit/table_spec.rb +++ b/spec/unit/table_spec.rb @@ -15,25 +15,38 @@ end end + describe 'ddl' do + it 'should build the destination table' do + table = 'users' + schema = 'default' + + @table = Lhm::Table.new(table, schema, 'id', %Q{CREATE TABLE `#{table}` (random_constraint)}) + @table.constraints['user_id'] = {:name => 'random_constraint', :referenced_column => true} + Lhm::Table.schema_constraints(schema, {'random_constraint_lhmn' => true}) + + @table.destination_ddl.must_equal %Q{CREATE TABLE `#{@table.destination_name}` (random_constraint_lhmn)} + end + end + describe 'constraints' do def set_columns(table, columns) table.instance_variable_set('@columns', columns) end it 'should be satisfied with a single column primary key called id' do - @table = Lhm::Table.new('table', 'id') + @table = Lhm::Table.new('table', 'default', 'id') set_columns(@table, { 'id' => { :type => 'int(1)' } }) @table.satisfies_id_column_requirement?.must_equal true end it 'should be satisfied with a primary key not called id, as long as there is still an id' do - @table = Lhm::Table.new('table', 'uuid') + @table = Lhm::Table.new('table', 'default', 'uuid') set_columns(@table, { 'id' => { :type => 'int(1)' } }) @table.satisfies_id_column_requirement?.must_equal true end it 'should not be satisfied if id is not numeric' do - @table = Lhm::Table.new('table', 'id') + @table = Lhm::Table.new('table', 'default', 'id') set_columns(@table, { 'id' => { :type => 'varchar(255)' } }) @table.satisfies_id_column_requirement?.must_equal false end