diff --git a/.travis.yml b/.travis.yml index ff54ca6..9e31b99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: ruby +services: + - mysql + - redis-server rvm: - - 2.3.5 + - 2.5.0 before_script: - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter diff --git a/Gemfile b/Gemfile index 8ea20fa..2be417e 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,7 @@ group :test, :development do end group :test do + gem 'guard' + gem 'guard-rspec' gem 'simplecov', require: false end diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..1614167 --- /dev/null +++ b/Guardfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# More info at https://github.com/guard/guard#readme +guard :rspec, cmd: 'rspec' do + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch(%r{^lib/mysql_framework/(.+)\.rb$}) { |m| "spec/lib/mysql_framework/#{m[1]}_spec.rb" } + watch('spec/spec_helper.rb') { "spec" } +end diff --git a/README.md b/README.md index cd544f8..dce0000 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,265 @@ Welcome to Mysql_Framework, this is a light weight framework that provides managers to help with interacting with mysql +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'mysql_framework' +``` + +## Usage + +### Environment Variables + +* `MYSQL_HOST` - MySQL Host +* `MYSQL_PORT` - MySQL Port +* `MYSQL_DATABASE` - MySQL database name +* `MYSQL_USERNAME` - MySQL username +* `MYSQL_PASSWORD` - MySQL password +* `MYSQL_PARTITIONS` - The number of partitions where a table is partitioned (default: `500`) +* `REDIS_URL` - The URL for redis - used for managing locks for DB migrations + +### Migration Scripts + +Migration scripts need to be in the following format: + +```ruby +class CreateDemoTable < MysqlFramework::Scripts::Base + def initialize + @identifier = 201806021520 # 15:20 02/06/2018 + end + + def apply + mysql_connector.query(<<~SQL) + CREATE TABLE IF NOT EXISTS `#{database_name}`.`demo` ( + `id` CHAR(36) NOT NULL, + `name` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`) + ) + SQL + end + + def rollback + mysql_connector.query(<<~SQL) + DROP TABLE IF EXISTS `#{database_name}`.`demo` + SQL + end + + def tags + [DemoTable::NAME] + end +end +``` + +#### #initialize + +The initialize method should set the `@identifier` value, which should be a timestamp + +```ruby +@identifier = 201806021520 # 15:20 02/06/2018 +``` + +#### #apply + +The `apply` method should action the migration. An instance of the `MysqlFramework::Connector` is +available as `mysql_connector` to use. + +#### #rollback + +The `rollback` method should action the migration. An instance of the `MysqlFramework::Connector` +is available as `mysql_connector` to use. + +#### #tags + +Tags are used for when we want to specify which migrations to run based on a tag. This is useful +for tests where you don't need to run all migrations to assert something is working or not. + +### MysqlFramework::Scripts::Table + +Used to register tables. This is used as part of the `all_tables` method in the script manager for +awareness of tables to drop. + +```ruby +class DemoTable + extend MysqlFramework::Scripts::Table + + NAME = 'demo' + + register_table NAME +end +``` + +### MysqlFramework::Connector + +The connector deals with the connection pooling of `MySQL2::Client` instances, providing a wrapper for queries and transactions. + +```ruby +connector = MysqlFramework::Connector.new +connector.query(<<~SQL) + SELECT * FROM gems +SQL +``` + +Options can be provided to override the defaults as follows: + +```ruby +options = { + host: ENV.fetch('MYSQL_HOST'), + port: ENV.fetch('MYSQL_PORT'), + database: ENV.fetch('MYSQL_DATABASE'), + username: ENV.fetch('MYSQL_USERNAME'), + password: ENV.fetch('MYSQL_PASSWORD'), + reconnect: true +} +MysqlFramework::Connector.new(options) +``` + +#### #check_out + +Check out a client from the connection pool + +```ruby +client = connector.check_out +``` + +#### #check_in + +Check in a client to the connection pool + +```ruby +client = connector.check_out +# ... +connector.check_in(client) +``` + +#### #with_client + +Called with a block. The method checks out a client from the pool and yields it to the block. Finally it ensures that the client is always checked back into the pool. + +```ruby +connector.with_client do |client| + client.query(<<~SQL) + SELECT * FROM gems + SQL +end +``` + +#### #execute + +This method is called when executing a prepared statement where value substitution is required: + +```ruby +insert = MysqlFramework::SqlQuery.new.insert(gems) + .into(gems[:id],gems[:name],gems[:author],gems[:created_at],gems[:updated_at]) + .values(SecureRandom.uuid,'mysql_framework','sage',Time.now,Time.now) + +connector.execute(insert) +``` + +#### #query + +This method is called to execute a query without having to worry about obtaining a client + +```ruby +connector.query(<<~SQL) + SELECT * FROM versions +SQL +``` + +#### #transaction + +This method requires a block and yields a client obtained from the pool. It wraps the yield in a `BEGIN` and `COMMIT` query. If an exception is raised then it will submit a `ROLLBACK` query and re-raise the exception. + +```ruby +insert = MysqlFramework::SqlQuery.new.insert(gems) + .into(gems[:id],gems[:name],gems[:author],gems[:created_at],gems[:updated_at]) + .values(SecureRandom.uuid,'mysql_framework','sage',Time.now,Time.now) + +connector.transaction do |client| + client.query(insert) +end +``` + +#### #default_options + +The default options used to initialise MySQL2::Client instances: + +```ruby +{ + host: ENV.fetch('MYSQL_HOST'), + port: ENV.fetch('MYSQL_PORT'), + database: ENV.fetch('MYSQL_DATABASE'), + username: ENV.fetch('MYSQL_USERNAME'), + password: ENV.fetch('MYSQL_PASSWORD'), + reconnect: true +} +``` + +### MysqlFramework::SqlCondition + +A representation of a MySQL Condition for a column. Created automatically by SqlColumn + +```ruby +# eq condition +SqlCondition.new(column: 'name', comparison: '=', value: 'mysql_framework') +``` + +### MysqlFramework::SqlColumn + +A representation of a MySQL column within a table. Created automatically by SqlTable. + +```ruby +SqlCondition.new(table: 'gems', column: 'name') +``` + +### MysqlFramework::SqlQuery + +A representation of a MySQL Query. + +```ruby +gems = MysqlFramework::SqlTable.new('gems') +guid = SecureRandom.uuid + +# Insert Query +insert = MysqlFramework::SqlQuery.new.insert(gems) + .into(gems[:id],gems[:name],gems[:author],gems[:created_at],gems[:updated_at]) + .values(guid,'mysql_framework','sage',Time.now,Time.now) + +# Update Query +update = MysqlFramework::SqlQuery.new.update(gems) + .set(updated_at: Time.now) + .where(gems[:id].eq(guid)) + +# Delete Query +delete = MysqlFramework::SqlQuery.new.delete + .from(gems) + .where(gems[:id].eq(guid)) +``` + +### MysqlFramework::SqlTable + +A representation of a MySQL table. + +```ruby +MysqlFramework::SqlTable.new('gems') +``` + +### Configuring Logs + +As a default, `MysqlFramework` will log to `STDOUT`. You can provide your own logger using the `set_logger` method: + +```ruby +MysqlFramework.set_logger(Logger.new('development.log')) +``` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/sage/mysql_framework. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. + ## License This gem is available as open source under the terms of the [MIT licence](LICENSE). diff --git a/lib/mysql_framework.rb b/lib/mysql_framework.rb index 1790ba8..453101e 100644 --- a/lib/mysql_framework.rb +++ b/lib/mysql_framework.rb @@ -1,3 +1,13 @@ +require 'mysql2' +require 'redlock' + +require_relative 'mysql_framework/connector' +require_relative 'mysql_framework/logger' +require_relative 'mysql_framework/scripts' +require_relative 'mysql_framework/sql_column' +require_relative 'mysql_framework/sql_condition' +require_relative 'mysql_framework/sql_query' +require_relative 'mysql_framework/sql_table' require_relative 'mysql_framework/version' module MysqlFramework diff --git a/lib/mysql_framework/connector.rb b/lib/mysql_framework/connector.rb new file mode 100644 index 0000000..bbf434f --- /dev/null +++ b/lib/mysql_framework/connector.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module MysqlFramework + class Connector + def initialize(options = {}) + @connection_pool = ::Queue.new + + @options = default_options.merge(options) + + Mysql2::Client.default_query_options.merge!(symbolize_keys: true, cast_booleans: true) + end + + # This method is called to fetch a client from the connection pool or create a new client if no idle clients + # are available. + def check_out + @connection_pool.pop(true) + rescue StandardError + Mysql2::Client.new(@options) + end + + # This method is called to check a client back in to the connection when no longer needed. + def check_in(client) + @connection_pool.push(client) + end + + # This method is called to use a client from the connection pool. + def with_client + client = check_out + yield client + ensure + check_in(client) unless client.nil? + end + + # This method is called to execute a prepared statement + def execute(query) + with_client do |client| + statement = client.prepare(query.sql) + statement.execute(*query.params) + end + end + + # This method is called to execute a query + def query(query_string) + with_client { |client| client.query(query_string) } + end + + # This method is called to execute a query which will return multiple result sets in an array + def query_multiple_results(query_string) + with_client do |client| + result = [] + result << client.query(query_string).to_a + result << client.store_result&.to_a while client.next_result + result.compact + end + end + + # This method is called to use a client within a transaction + def transaction + raise ArgumentError, 'No block was given' unless block_given? + + with_client do |client| + begin + client.query('BEGIN') + yield client + client.query('COMMIT') + rescue StandardError => e + client.query('ROLLBACK') + raise e + end + end + end + + def default_options + { + host: ENV.fetch('MYSQL_HOST'), + port: ENV.fetch('MYSQL_PORT'), + database: ENV.fetch('MYSQL_DATABASE'), + username: ENV.fetch('MYSQL_USERNAME'), + password: ENV.fetch('MYSQL_PASSWORD'), + reconnect: true + } + end + end +end diff --git a/lib/mysql_framework/logger.rb b/lib/mysql_framework/logger.rb new file mode 100644 index 0000000..2383ae8 --- /dev/null +++ b/lib/mysql_framework/logger.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'logger' + +module MysqlFramework + def self.logger + return @@logger + end + + def self.set_logger(logger) + @@logger = logger + end + + MysqlFramework.set_logger(Logger.new(STDOUT)) +end diff --git a/lib/mysql_framework/scripts.rb b/lib/mysql_framework/scripts.rb new file mode 100644 index 0000000..92586fa --- /dev/null +++ b/lib/mysql_framework/scripts.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative 'scripts/base' +require_relative 'scripts/manager' +require_relative 'scripts/table' diff --git a/lib/mysql_framework/scripts/base.rb b/lib/mysql_framework/scripts/base.rb new file mode 100644 index 0000000..d04cd3a --- /dev/null +++ b/lib/mysql_framework/scripts/base.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module MysqlFramework + module Scripts + class Base + def partitions + ENV.fetch('MYSQL_PARTITIONS', '500').to_i + end + + def database_name + @database_name ||= ENV.fetch('MYSQL_DATABASE') + end + + def identifier + raise NotImplementedError if @identifier.nil? + @identifier + end + + def apply + raise NotImplementedError + end + + def rollback + raise NotImplementedError + end + + def generate_partition_sql + (1..partitions).each_with_index.map { |_, i| "PARTITION p#{i} VALUES IN (#{i})" }.join(",\n\t") + end + + def self.descendants + ObjectSpace.each_object(Class).select { |klass| klass < self } + end + + def tags + [] + end + + def update_procedure(proc_name, proc_file) + mysql_connector.transaction do + mysql_connector.query(<<~SQL) + DROP PROCEDURE IF EXISTS #{proc_name}; + SQL + + proc_sql = File.read(proc_file) + + mysql_connector.query(proc_sql) + end + end + + private + + def mysql_connector + @mysql_connector ||= MysqlFramework::Connector.new + end + end + end +end diff --git a/lib/mysql_framework/scripts/manager.rb b/lib/mysql_framework/scripts/manager.rb new file mode 100644 index 0000000..bc5ec4f --- /dev/null +++ b/lib/mysql_framework/scripts/manager.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module MysqlFramework + module Scripts + class Manager + def execute + lock_manager.lock(self.class, 2000) do |locked| + raise unless locked + + initialize_script_history + + last_executed_script = retrieve_last_executed_script + + mysql_connector.transaction do + pending_scripts = calculate_pending_scripts(last_executed_script) + MysqlFramework.logger.info { "[#{self.class}] - #{pending_scripts.length} pending data store scripts found." } + + pending_scripts.each { |script| apply(script) } + end + + MysqlFramework.logger.info { "[#{self.class}] - Migration script execution complete." } + end + end + + def apply_by_tag(tags) + lock_manager.lock(self.class, 2000) do |locked| + raise unless locked + + initialize_script_history + + mysql_connector.transaction do + pending_scripts = calculate_pending_scripts(0) + MysqlFramework.logger.info { "[#{self.class}] - #{pending_scripts.length} pending data store scripts found." } + + pending_scripts.reject { |script| (script.tags & tags).empty? }.sort_by(&:identifier) + .each { |script| apply(script) } + end + + MysqlFramework.logger.info { "[#{self.class}] - Migration script execution complete." } + end + end + + def drop_all_tables + drop_script_history + all_tables.each { |table| drop_table(table) } + end + + def retrieve_last_executed_script + MysqlFramework.logger.info { "[#{self.class}] - Retrieving last executed script from history." } + + result = mysql_connector.query(<<~SQL) + SELECT `identifier` FROM #{migration_table_name} ORDER BY `identifier` DESC + SQL + + if result.each.to_a.length.zero? + 0 + else + Integer(result.first[:identifier]) + end + end + + def initialize_script_history + MysqlFramework.logger.info { "[#{self.class}] - Initializing script history." } + + mysql_connector.query(<<~SQL) + CREATE TABLE IF NOT EXISTS #{migration_table_name} ( + `identifier` CHAR(15) NOT NULL, + `timestamp` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`identifier`), + UNIQUE INDEX `identifier_UNIQUE` (`identifier` ASC) + ) + SQL + end + + def calculate_pending_scripts(last_executed_script) + MysqlFramework.logger.info { "[#{self.class}] - Calculating pending data store scripts." } + + MysqlFramework::Scripts::Base.descendants.map(&:new) + .select { |script| script.identifier > last_executed_script }.sort_by(&:identifier) + end + + def table_exists?(table_name) + result = mysql_connector.query(<<~SQL) + SHOW TABLES LIKE '#{table_name}' + SQL + result.count == 1 + end + + def drop_script_history + drop_table(migration_table_name) + end + + def drop_table(table_name) + mysql_connector.query(<<~SQL) + DROP TABLE IF EXISTS #{table_name} + SQL + end + + def all_tables + self.class.all_tables + end + + def self.all_tables + @all_tables ||= [] + end + + private + + def mysql_connector + @mysql_connector ||= MysqlFramework::Connector.new + end + + def lock_manager + @lock_manager ||= Redlock::Client.new([ENV.fetch('REDIS_URL')]) + end + + def database + @database ||= ENV.fetch('MYSQL_DATABASE') + end + + def migration_table_name + return @migration_table_name if @migration_table_name + + @migration_table_name = "`#{database}`.`migration_script_history`" + end + + def apply(script) + MysqlFramework.logger.info { "[#{self.class}] - Applying script: #{script}." } + + script.apply + mysql_connector.query(<<~SQL) + INSERT INTO #{migration_table_name} (`identifier`, `timestamp`) VALUES ('#{script.identifier}', NOW()) + SQL + end + end + end +end diff --git a/lib/mysql_framework/scripts/table.rb b/lib/mysql_framework/scripts/table.rb new file mode 100644 index 0000000..b9bdd86 --- /dev/null +++ b/lib/mysql_framework/scripts/table.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MysqlFramework + module Scripts + module Table + def register_table(name) + MysqlFramework::Scripts::Manager.all_tables << name + end + end + end +end diff --git a/lib/mysql_framework/sql_column.rb b/lib/mysql_framework/sql_column.rb new file mode 100644 index 0000000..1f06562 --- /dev/null +++ b/lib/mysql_framework/sql_column.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module MysqlFramework + # This class is used to represent a sql column within a table + class SqlColumn + def initialize(table:, column:) + @table = table + @column = column + end + + def to_s + "`#{@table}`.`#{@column}`" + end + + def to_sym + @column.to_sym + end + + # This method is called to create a equals (=) condition for this column. + def eq(value) + SqlCondition.new(column: to_s, comparison: '=', value: value) + end + + # This method is called to create a not equal (<>) condition for this column. + def not_eq(value) + SqlCondition.new(column: to_s, comparison: '<>', value: value) + end + + # This method is called to create a greater than (>) condition for this column. + def gt(value) + SqlCondition.new(column: to_s, comparison: '>', value: value) + end + + # This method is called to create a greater than or equal (>=) condition for this column. + def gte(value) + SqlCondition.new(column: to_s, comparison: '>=', value: value) + end + + # This method is called to create a less than (<) condition for this column. + def lt(value) + SqlCondition.new(column: to_s, comparison: '<', value: value) + end + + # This method is called to create a less than or equal (<=) condition for this column. + def lte(value) + SqlCondition.new(column: to_s, comparison: '<=', value: value) + end + + # This method is called to generate an alias statement for this column. + def as(name) + "#{self} as `#{name}`" + end + end +end diff --git a/lib/mysql_framework/sql_condition.rb b/lib/mysql_framework/sql_condition.rb new file mode 100644 index 0000000..a447d7f --- /dev/null +++ b/lib/mysql_framework/sql_condition.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module MysqlFramework +# This class is used to represent a Sql Condition for a column. + class SqlCondition + # This method is called to get the value of this condition for prepared statements. + attr_reader :value + + def initialize(column:, comparison:, value:) + @column = column + @comparison = comparison + @value = value + end + + # This method is called to get the condition as a string for a sql prepared statement + def to_s + "#{@column} #{@comparison} ?" + end + end +end diff --git a/lib/mysql_framework/sql_query.rb b/lib/mysql_framework/sql_query.rb new file mode 100644 index 0000000..60491b8 --- /dev/null +++ b/lib/mysql_framework/sql_query.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module MysqlFramework + # This class is used to represent and build a sql query + class SqlQuery + # This method is called to get any params required to execute this query as a prepared statement. + attr_reader :params + + def initialize + @sql = '' + @params = [] + end + + # This method is called to access the sql string for this query. + def sql + @sql.strip + end + + # This method is called to start a select query + def select(*columns) + @sql = "select #{columns.join(',')}" + self + end + + # This method is called to start a delete query + def delete + @sql = 'delete' + self + end + + # This method is called to start an update query + def update(table, partition = nil) + @sql = "update #{table}" + @sql += " partition(p#{partition})" unless partition.nil? + self + end + + # This method is called to start an insert query + def insert(table, partition = nil) + @sql += "insert into #{table}" + @sql += " partition(p#{partition})" unless partition.nil? + self + end + + # This method is called to specify the columns to insert into. + def into(*columns) + @sql += " (#{columns.join(',')})" + self + end + + # This method is called to specify the values to insert. + def values(*values) + @sql += " values (#{values.map { |_v| '?' }.join(',')})" + values.each do |v| + @params << v + end + self + end + + # This method is called to specify the columns to update. + def set(values) + @sql += ' set ' + values.each do |k, p| + @sql += "`#{k}` = ?, " + @params << p + end + @sql = @sql[0...-2] + self + end + + # This method is called to specify the table/partition a select/delete query is for. + def from(table, partition = nil) + @sql += " from #{table}" + @sql += " partition(p#{partition})" unless partition.nil? + self + end + + # This method is called to specify a where clause for a query. + def where(*conditions) + @sql += ' where' unless @sql.include?('where') + @sql += " (#{conditions.join(' and ')}) " + conditions.each do |c| + @params << c.value + end + self + end + + # This method is called to add an `and` keyword to a query to provide additional where clauses. + def and + @sql += 'and' + self + end + + # This method is called to add an `or` keyword to a query to provide alternate where clauses. + def or + @sql += 'or' + self + end + + # This method is called to add an `order by` statement to a query + def order(*columns) + @sql += " order by #{columns.join(',')}" + self + end + + # This method is called to add an `order by ... desc` statement to a query + def order_desc(*columns) + order(*columns) + @sql += ' desc' + self + end + + # This method is called to add a limit to a query + def limit(count) + @sql += " limit #{count}" + self + end + + # This method is called to add a join statement to a query. + def join(table) + @sql += " join #{table}" + self + end + + # This method is called to add the `on` detail to a join statement. + def on(column_1, column_2) + @sql += " on #{column_1} = #{column_2}" + self + end + end +end diff --git a/lib/mysql_framework/sql_table.rb b/lib/mysql_framework/sql_table.rb new file mode 100644 index 0000000..d093141 --- /dev/null +++ b/lib/mysql_framework/sql_table.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MysqlFramework + # This class is used to represent a sql table + class SqlTable + def initialize(name) + @name = name + end + + # This method is called to get a sql column for this table + def [](column) + SqlColumn.new(table: @name, column: column) + end + + def to_s + "`#{@name}`" + end + + def to_sym + @name.to_sym + end + end +end diff --git a/mysql_framework.gemspec b/mysql_framework.gemspec index 0273cd3..6468662 100644 --- a/mysql_framework.gemspec +++ b/mysql_framework.gemspec @@ -23,4 +23,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rake', '~> 10.0' spec.add_development_dependency 'rspec' spec.add_development_dependency 'pry' + + spec.add_dependency 'mysql2', '~> 0.4' + spec.add_dependency 'redlock' end diff --git a/spec/lib/mysql_framework/connector_spec.rb b/spec/lib/mysql_framework/connector_spec.rb new file mode 100644 index 0000000..80d4ca0 --- /dev/null +++ b/spec/lib/mysql_framework/connector_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::Connector do + let(:default_options) do + { + host: ENV.fetch('MYSQL_HOST'), + port: ENV.fetch('MYSQL_PORT'), + database: ENV.fetch('MYSQL_DATABASE'), + username: ENV.fetch('MYSQL_USERNAME'), + password: ENV.fetch('MYSQL_PASSWORD'), + reconnect: true + } + end + let(:options) do + { + host: 'host', + port: 'port', + database: 'database', + username: 'username', + password: 'password', + reconnect: true + } + end + let(:client) { double } + let(:gems) { MysqlFramework::SqlTable.new('gems') } + + subject { described_class.new } + + describe '#initialize' do + it 'sets default query options on the Mysql2 client' do + subject + expect(Mysql2::Client.default_query_options[:symbolize_keys]).to eq(true) + expect(Mysql2::Client.default_query_options[:cast_booleans]).to eq(true) + end + + context 'when options are provided' do + subject { described_class.new(options) } + + it 'allows the default options to be overridden' do + expect(subject.instance_variable_get(:@options)).to eq(options) + end + end + end + + describe '#check_out' do + it 'returns a Mysql2::Client instance from the pool' do + expect(Mysql2::Client).to receive(:new).with(default_options).and_return(client) + expect(subject.check_out).to eq(client) + end + + context 'when the connection pool has a client available' do + it 'returns a client instance from the pool' do + subject.instance_variable_get(:@connection_pool).push(client) + expect(subject.check_out).to eq(client) + end + end + end + + describe '#check_in' do + it 'returns the provided client to the connection pool' do + expect(subject.instance_variable_get(:@connection_pool)).to receive(:push).with(client) + subject.check_in(client) + end + end + + describe '#with_client' do + it 'obtains a client from the pool to use' do + allow(subject).to receive(:check_out).and_return(client) + expect { |b| subject.with_client(&b) }.to yield_with_args(client) + end + end + + describe '#execute' do + let(:insert_query) do + MysqlFramework::SqlQuery.new.insert(gems) + .into( + gems[:id], + gems[:name], + gems[:author], + gems[:created_at], + gems[:updated_at] + ) + .values( + SecureRandom.uuid, + 'mysql_framework', + 'sage', + Time.now, + Time.now + ) + end + + it 'executes the query with parameters' do + guid = insert_query.params[0] + subject.execute(insert_query) + + results = subject.query("SELECT * FROM `gems` WHERE id = '#{guid}';").to_a + expect(results.length).to eq(1) + expect(results[0][:id]).to eq(guid) + end + end + + describe '#query' do + before :each do + allow(subject).to receive(:check_out).and_return(client) + end + + it 'retrieves a client and calls query' do + expect(client).to receive(:query).with('SELECT 1') + subject.query('SELECT 1') + end + end + + describe '#query_multiple_results' do + let(:test) { MysqlFramework::SqlTable.new('test') } + let(:manager) { MysqlFramework::Scripts::Manager.new } + let(:connector) { MysqlFramework::Connector.new } + let(:timestamp) { Time.at(628232400) } # 1989-11-28 00:00:00 -0500 + let(:guid) { 'a3ccb138-48ae-437a-be52-f673beb12b51' } + let(:insert) do + MysqlFramework::SqlQuery.new.insert(test) + .into(test[:id],test[:name],test[:action],test[:created_at],test[:updated_at]) + .values(guid,'name','action',timestamp,timestamp) + end + let(:obj) do + { + id: guid, + name: 'name', + action: 'action', + created_at: timestamp, + updated_at: timestamp, + } + end + + before :each do + manager.initialize_script_history + manager.execute + + connector.execute(insert) + end + + after :each do + manager.drop_all_tables + end + + it 'returns the results from the stored procedure' do + query = "call test_procedure" + result = subject.query_multiple_results(query) + expect(result).to be_a(Array) + expect(result.length).to eq(2) + expect(result[0]).to eq([]) + expect(result[1]).to eq([obj]) + end + end + + describe '#transaction' do + before :each do + allow(subject).to receive(:check_out).and_return(client) + end + + it 'wraps the client call with BEGIN and COMMIT statements' do + expect(client).to receive(:query).with('BEGIN') + expect(client).to receive(:query).with('SELECT 1') + expect(client).to receive(:query).with('COMMIT') + + subject.transaction do + subject.query('SELECT 1') + end + end + + context 'when an exception occurs' do + it 'triggers a ROLLBACK' do + expect(client).to receive(:query).with('BEGIN') + expect(client).to receive(:query).with('ROLLBACK') + + begin + subject.transaction do + raise + end + rescue + end + end + end + end + + describe '#default_options' do + it 'returns the default options' do + expect(subject.default_options).to eq(default_options) + end + end +end diff --git a/spec/lib/mysql_framework/logger_spec.rb b/spec/lib/mysql_framework/logger_spec.rb new file mode 100644 index 0000000..d47a42f --- /dev/null +++ b/spec/lib/mysql_framework/logger_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework do + describe 'logger' do + it 'returns the logger' do + expect(subject.logger).to be_a(Logger) + end + end + + describe 'set_logger' do + let(:logger) { Logger.new(STDOUT) } + + it 'sets the logger' do + subject.set_logger(logger) + expect(subject.logger).to eq(logger) + end + end +end diff --git a/spec/lib/mysql_framework/scripts/base_spec.rb b/spec/lib/mysql_framework/scripts/base_spec.rb new file mode 100644 index 0000000..7e33e7f --- /dev/null +++ b/spec/lib/mysql_framework/scripts/base_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::Scripts::Base do + subject { described_class.new } + + describe '#partitions' do + it 'returns the number of paritions' do + expect(subject.partitions).to eq(5) + end + end + + describe '#database_name' do + it 'returns the database name' do + expect(subject.database_name).to eq('test_database') + end + end + + describe '#identifier' do + it 'throws a NotImplementedError' do + expect{ subject.identifier }.to raise_error(NotImplementedError) + end + + context 'when @identifier is set' do + it 'returns the value' do + subject.instance_variable_set(:@identifier, 'foo') + expect(subject.identifier).to eq('foo') + end + end + end + + describe '#apply' do + it 'throws a NotImplementedError' do + expect{ subject.apply }.to raise_error(NotImplementedError) + end + end + + describe '#rollback' do + it 'throws a NotImplementedError' do + expect{ subject.rollback }.to raise_error(NotImplementedError) + end + end + + describe '#generate_partition_sql' do + it 'generates the partition sql statement' do + expected = "PARTITION p0 VALUES IN (0),\n\tPARTITION p1 VALUES IN (1),\n\tPARTITION p2 VALUES IN (2),\n\tPARTITION p3 VALUES IN (3),\n\tPARTITION p4 VALUES IN (4)" + expect(subject.generate_partition_sql).to eq(expected) + end + end + + describe '.descendants' do + it 'returns all descendant classes' do + expect(described_class.descendants.length).to eq(3) + expect(described_class.descendants).to include(MysqlFramework::Support::Scripts::CreateTestTable, + MysqlFramework::Support::Scripts::CreateDemoTable, + MysqlFramework::Support::Scripts::CreateTestProc) + end + end + + describe '#tags' do + it 'returns an array' do + expect(subject.tags).to eq([]) + end + end + + describe '#update_procedure' do + let(:connector) { MysqlFramework::Connector.new } + let(:proc_file_path) { 'spec/support/procedure.sql' } + let(:drop_sql) do + <<~SQL + DROP PROCEDURE IF EXISTS test_procedure; + SQL + end + + before :each do + subject.instance_variable_set(:@mysql_connector, connector) + end + + it 'drops and then creates the named procedure' do + expect(connector).to receive(:query).with(drop_sql).once + expect(connector).to receive(:query).with(File.read(proc_file_path)).once + subject.update_procedure('test_procedure', proc_file_path) + end + + it 'wraps the call in a transaction' do + expect(connector).to receive(:transaction) + subject.update_procedure('test_procedure', proc_file_path) + end + end +end diff --git a/spec/lib/mysql_framework/scripts/manager_spec.rb b/spec/lib/mysql_framework/scripts/manager_spec.rb new file mode 100644 index 0000000..3f5a999 --- /dev/null +++ b/spec/lib/mysql_framework/scripts/manager_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::Scripts::Manager do + let(:connector) { MysqlFramework::Connector.new } + + before :each do + subject.instance_variable_set(:@mysql_connector, connector) + end + + describe '#execute' do + before :each do + subject.initialize_script_history + end + + it 'executes all pending scripts' do + expect(subject.table_exists?('demo')).to eq(false) + expect(subject.table_exists?('test')).to eq(false) + + subject.execute + + expect(subject.table_exists?('demo')).to eq(true) + expect(subject.table_exists?('test')).to eq(true) + end + + after :each do + subject.drop_all_tables + end + end + + describe '#apply_by_tag' do + before :each do + subject.initialize_script_history + end + + it 'executes all pending scripts that match the tag' do + expect(subject.table_exists?('demo')).to eq(false) + expect(subject.table_exists?('test')).to eq(false) + + subject.apply_by_tag([MysqlFramework::Support::Tables::TestTable::NAME]) + + expect(subject.table_exists?('demo')).to eq(false) + expect(subject.table_exists?('test')).to eq(true) + end + + after :each do + subject.drop_all_tables + end + end + + describe '#drop_all_tables' do + it 'drops the script history table and any registered tables' do + expect(subject).to receive(:drop_script_history) + expect(subject).to receive(:drop_table).with('test') + expect(subject).to receive(:drop_table).with('demo') + + subject.drop_all_tables + end + end + + describe '#retrieve_last_executed_script' do + before :each do + subject.initialize_script_history + end + + context 'when no scripts have been executed' do + it 'returns 0' do + expect(subject.retrieve_last_executed_script).to eq(0) + end + end + + context 'when scripts have been executed previously' do + before :each do + subject.apply_by_tag([MysqlFramework::Support::Tables::TestTable::NAME]) + end + + it 'returns the last executed script' do + expect(subject.retrieve_last_executed_script).to eq(201807031200) + end + end + + after :each do + subject.drop_script_history + end + end + + describe '#initialize_script_history' do + it 'creates a migration history table' do + expect(subject.table_exists?('migration_script_history')).to eq(false) + subject.initialize_script_history + expect(subject.table_exists?('migration_script_history')).to eq(true) + end + end + + describe '#calculate_pending_scripts' do + it 'returns any scripts that are newer than the given date in ascending order' do + timestamp = 201701010000 # 00:00 01/01/2017 + results = subject.calculate_pending_scripts(timestamp) + + expect(results.length).to eq(3) + expect(results[0]).to be_a(MysqlFramework::Support::Scripts::CreateTestTable) + expect(results[1]).to be_a(MysqlFramework::Support::Scripts::CreateDemoTable) + end + + context 'when there are scripts older than the given date' do + it 'returns only scripts that are newer than the given date in ascending order' do + timestamp = 201802021010 # 10:10 02/02/2018 + results = subject.calculate_pending_scripts(timestamp) + + expect(results.length).to eq(2) + expect(results[0]).to be_a(MysqlFramework::Support::Scripts::CreateDemoTable) + end + end + end + + describe '#table_exists?' do + context 'when the table exists' do + it 'returns true' do + expect(subject.table_exists?('gems')).to eq(true) + end + end + + context 'when the table does not exist' do + it 'returns false' do + expect(subject.table_exists?('foo')).to eq(false) + end + end + end + + describe '#drop_script_history' do + it 'drops the migration script history table' do + query = <<~SQL + DROP TABLE IF EXISTS `#{ENV.fetch('MYSQL_DATABASE')}`.`migration_script_history` + SQL + expect(connector).to receive(:query).with(query) + subject.drop_script_history + end + end + + describe '#drop_table' do + it 'drops the given table' do + expect(connector).to receive(:query).with(<<~SQL) + DROP TABLE IF EXISTS `some_database`.`some_table` + SQL + subject.drop_table('`some_database`.`some_table`') + end + end + + describe '#all_tables' do + it 'returns all registered tables' do + expect(subject.all_tables).to eq(['test', 'demo']) + end + end + + describe '.all_tables' do + it 'stores a class level array of tables' do + expect(described_class.all_tables).to eq(['test', 'demo']) + end + end +end diff --git a/spec/lib/mysql_framework/sql_column_spec.rb b/spec/lib/mysql_framework/sql_column_spec.rb new file mode 100644 index 0000000..841cba1 --- /dev/null +++ b/spec/lib/mysql_framework/sql_column_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::SqlColumn do + subject { described_class.new(table: 'gems', column: 'version') } + + describe '#to_s' do + it 'returns the prepared sql name with backticks' do + expect(subject.to_s).to eq('`gems`.`version`') + end + end + + describe '#to_sym' do + it 'returns the column name as a symbol' do + expect(subject.to_sym).to eq(:version) + end + end + + describe '#eq' do + it 'returns a SqlCondition for the comparison' do + condition = subject.eq('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` = ?') + end + end + + describe '#not_eq' do + it 'returns a SqlCondition for the comparison' do + condition = subject.not_eq('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` <> ?') + end + end + + describe '#gt' do + it 'returns a SqlCondition for the comparison' do + condition = subject.gt('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` > ?') + end + end + + describe '#gte' do + it 'returns a SqlCondition for the comparison' do + condition = subject.gte('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` >= ?') + end + end + + describe '#lt' do + it 'returns a SqlCondition for the comparison' do + condition = subject.lt('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` < ?') + end + end + + describe '#lte' do + it 'returns a SqlCondition for the comparison' do + condition = subject.lte('2.0.0') + expect(condition).to be_a(MysqlFramework::SqlCondition) + expect(condition.to_s).to eq('`gems`.`version` <= ?') + end + end + + describe '#as' do + it 'returns the column specified as another name' do + expect(subject.as('v')).to eq('`gems`.`version` as `v`') + end + end +end diff --git a/spec/lib/mysql_framework/sql_condition_spec.rb b/spec/lib/mysql_framework/sql_condition_spec.rb new file mode 100644 index 0000000..4757cae --- /dev/null +++ b/spec/lib/mysql_framework/sql_condition_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::SqlCondition do + subject { described_class.new(column: 'version', comparison: '=', value: '1.0.0') } + + describe '#to_s' do + it 'returns the condition as a string for a prepared statement' do + expect(subject.to_s).to eq('version = ?') + end + end +end diff --git a/spec/lib/mysql_framework/sql_query_spec.rb b/spec/lib/mysql_framework/sql_query_spec.rb new file mode 100644 index 0000000..c5a6210 --- /dev/null +++ b/spec/lib/mysql_framework/sql_query_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::SqlQuery do + let(:gems) { MysqlFramework::SqlTable.new('gems') } + let(:versions) { MysqlFramework::SqlTable.new('versions') } + + describe 'building a query' do + it 'builds the insert query as expected' do + subject.insert(gems, 15) + .into( + gems[:name], + gems[:author], + gems[:created_at], + gems[:updated_at] + ) + .values( + 'mysql_framework', + 'sage', + '2018-06-28 10:00:00', + '2018-06-28 10:00:00' + ) + + expect(subject.sql).to eq('insert into `gems` partition(p15) (`gems`.`name`,`gems`.`author`,`gems`.`created_at`,`gems`.`updated_at`) values (?,?,?,?)') + expect(subject.params).to eq(['mysql_framework', 'sage', '2018-06-28 10:00:00', '2018-06-28 10:00:00']) + end + + it 'builds the update query as expected' do + subject.update(gems, 20) + .set( + name: 'mysql_framework', + updated_at: '2018-06-28 13:00:00' + ) + .where( + gems[:id].eq('12345') + ) + + expect(subject.sql).to eq('update `gems` partition(p20) set `name` = ?, `updated_at` = ? where (`gems`.`id` = ?)') + expect(subject.params).to eq(['mysql_framework', '2018-06-28 13:00:00', '12345']) + end + + it 'builds the delete query as expected' do + subject.delete.from(gems, 30) + .where( + gems[:id].eq('45678') + ) + + expect(subject.sql).to eq('delete from `gems` partition(p30) where (`gems`.`id` = ?)') + expect(subject.params).to eq(['45678']) + end + + it 'builds a basic select query as expected' do + subject.select('*') + .from(gems, 40) + .where( + gems[:id].eq('9876') + ) + + expect(subject.sql).to eq('select * from `gems` partition(p40) where (`gems`.`id` = ?)') + expect(subject.params).to eq(['9876']) + end + + it 'builds a joined select query as expected' do + subject.select('*') + .from(gems, 40) + .join(versions).on(versions[:gem_id], gems[:id]) + .where( + gems[:id].eq('9876') + ) + + expect(subject.sql).to eq('select * from `gems` partition(p40) join `versions` on `versions`.`gem_id` = `gems`.`id` where (`gems`.`id` = ?)') + expect(subject.params).to eq(['9876']) + end + end + + describe '#select' do + it 'sets the sql for a select statement' do + subject.select(gems[:id], gems[:name]) + expect(subject.sql).to eq('select `gems`.`id`,`gems`.`name`') + end + end + + describe '#delete' do + it 'sets the sql for a delete statement' do + subject.delete + expect(subject.sql).to eq('delete') + end + end + + describe '#update' do + it 'sets the sql for an update statement' do + subject.update(gems) + expect(subject.sql).to eq('update `gems`') + end + + context 'when a partition is specified' do + it 'sets the sql for an update statement' do + subject.update(gems, 25) + expect(subject.sql).to eq('update `gems` partition(p25)') + end + end + end + + describe '#insert' do + it 'sets the sql for an insert statement' do + subject.insert(gems) + expect(subject.sql).to eq('insert into `gems`') + end + + context 'when a partition is specified' do + it 'sets the sql for an insert statement' do + subject.insert(gems, 35) + expect(subject.sql).to eq('insert into `gems` partition(p35)') + end + end + end + + describe '#into' do + it 'sets the sql for an into statement' do + subject.into(gems[:name], gems[:author], gems[:created_at]) + expect(subject.sql).to eq('(`gems`.`name`,`gems`.`author`,`gems`.`created_at`)') + end + end + + describe '#values' do + it 'sets the sql for the values statement' do + subject.values('mysql_framework', 'sage', '2016-06-28 10:00:00') + expect(subject.sql).to eq('values (?,?,?)') + end + end + + describe '#set' do + it 'sets the sql for the set statement' do + subject.set(name: 'mysql_framework', author: 'sage', created_at: '2016-06-28 10:00:00') + expect(subject.sql).to eq('set `name` = ?, `author` = ?, `created_at` = ?') + end + end + + describe '#from' do + it 'sets the sql for a from statement' do + subject.from(gems) + expect(subject.sql).to eq('from `gems`') + end + + context 'when a partition is specified' do + it 'sets the sql for a from statement' do + subject.from(gems, 45) + expect(subject.sql).to eq('from `gems` partition(p45)') + end + end + end + + describe '#where' do + before :each do + subject.where(gems['author'].eq('sage'), gems['created_at'].gt('2018-01-01 00:00:00')) + end + + it 'appends where to the sql' do + expect(subject.sql).to include('where') + end + + it 'sets the sql for the where statement' do + expect(subject.sql).to eq('where (`gems`.`author` = ? and `gems`.`created_at` > ?)') + end + + context 'when the sql already contains a where' do + it 'does not append an extra where' do + subject.and.where(gems['name'].eq('mysql_framework')) + expect(subject.sql).to eq('where (`gems`.`author` = ? and `gems`.`created_at` > ?) and (`gems`.`name` = ?)') + end + end + end + + describe '#and' do + it 'appends the sql for an and statement' do + subject.and + expect(subject.sql).to eq('and') + end + end + + describe '#or' do + it 'appends the sql for an or statement' do + subject.or + expect(subject.sql).to eq('or') + end + end + + describe '#order' do + it 'appends the sql for an order statement' do + subject.order(gems[:created_at], gems[:updated_at]) + expect(subject.sql).to eq('order by `gems`.`created_at`,`gems`.`updated_at`') + end + end + + describe '#order_desc' do + it 'appends the sql for an order descending statement' do + subject.order_desc(gems[:created_at], gems[:updated_at]) + expect(subject.sql).to eq('order by `gems`.`created_at`,`gems`.`updated_at` desc') + end + end + + describe '#limit' do + it 'appends the sql for a limit statement' do + subject.limit(10) + expect(subject.sql).to eq('limit 10') + end + end + + describe '#join' do + it 'appends the sql for a join statement' do + subject.join(versions) + expect(subject.sql).to eq('join `versions`') + end + end + + describe '#on' do + it 'appends the sql for the on statement' do + subject.on(gems[:id], versions[:gem_id]) + expect(subject.sql).to eq('on `gems`.`id` = `versions`.`gem_id`') + end + end +end diff --git a/spec/lib/mysql_framework/sql_table_spec.rb b/spec/lib/mysql_framework/sql_table_spec.rb new file mode 100644 index 0000000..359993b --- /dev/null +++ b/spec/lib/mysql_framework/sql_table_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MysqlFramework::SqlTable do + let(:table) { described_class.new('gems') } + + describe '#[](column)' do + it 'returns a new SqlColumn class for the specified column' do + expect(table[:version]).to be_a(MysqlFramework::SqlColumn) + expect(table[:version].to_s).to eq('`gems`.`version`') + end + end + + describe '#to_s' do + it 'returns the tablename wrapped in backticks' do + expect(table.to_s).to eq('`gems`') + end + end + + describe '#to_sym' do + it 'returns the table name as a symbol' do + expect(table.to_sym).to eq(:gems) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 655b1c7..fd21cef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,8 +3,23 @@ add_filter 'spec/' end +ENV['MYSQL_DATABASE'] ||= 'test_database' +ENV['MYSQL_HOST'] ||= '127.0.0.1' +ENV['MYSQL_PARTITIONS'] ||= '5' +ENV['MYSQL_PASSWORD'] ||= '' +ENV['MYSQL_PORT'] ||= '3306' +ENV['MYSQL_USERNAME'] ||= 'root' +ENV['REDIS_URL'] ||= 'redis://127.0.0.1:6379' + require 'bundler' require 'mysql_framework' +require 'securerandom' + +require_relative 'support/scripts/create_test_table' +require_relative 'support/scripts/create_demo_table' +require_relative 'support/scripts/create_test_proc' +require_relative 'support/tables/test' +require_relative 'support/tables/demo' RSpec.configure do |config| config.expect_with :rspec do |expectations| @@ -15,3 +30,23 @@ mocks.verify_partial_doubles = true end end + +client = Mysql2::Client.new({ + host: ENV.fetch('MYSQL_HOST'), + port: ENV.fetch('MYSQL_PORT'), + username: ENV.fetch('MYSQL_USERNAME'), + password: ENV.fetch('MYSQL_PASSWORD'), +}) +client.query("DROP DATABASE IF EXISTS `#{ENV.fetch('MYSQL_DATABASE')}`;") +client.query("CREATE DATABASE `#{ENV.fetch('MYSQL_DATABASE')}`;") + +connector = MysqlFramework::Connector.new +connector.query("DROP TABLE IF EXISTS `#{ENV.fetch('MYSQL_DATABASE')}`.`gems`") +connector.query("CREATE TABLE `#{ENV.fetch('MYSQL_DATABASE')}`.`gems` ( + `id` CHAR(36) NOT NULL, + `name` VARCHAR(255) NULL, + `author` VARCHAR(255) NULL, + `created_at` DATETIME, + `updated_at` DATETIME, + PRIMARY KEY (`id`) + )") diff --git a/spec/support/procedure.sql b/spec/support/procedure.sql new file mode 100644 index 0000000..7c5f892 --- /dev/null +++ b/spec/support/procedure.sql @@ -0,0 +1,5 @@ +CREATE PROCEDURE `test_procedure`() +BEGIN + SELECT * FROM demo; + SELECT * FROM test; +END \ No newline at end of file diff --git a/spec/support/scripts/create_demo_table.rb b/spec/support/scripts/create_demo_table.rb new file mode 100644 index 0000000..62129fe --- /dev/null +++ b/spec/support/scripts/create_demo_table.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MysqlFramework + module Support + module Scripts + class CreateDemoTable < MysqlFramework::Scripts::Base + def initialize + @identifier = 201806021520 # 15:20 02/06/2018 + end + + def apply + mysql_connector.query(<<~SQL) + CREATE TABLE IF NOT EXISTS `#{database_name}`.`demo` ( + `id` CHAR(36) NOT NULL, + `name` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL, + + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`) + ) + SQL + end + + def rollback + raise 'Rollback not supported in test.' + end + + def tags + [MysqlFramework::Support::Tables::DemoTable::NAME] + end + end + end + end +end diff --git a/spec/support/scripts/create_test_proc.rb b/spec/support/scripts/create_test_proc.rb new file mode 100644 index 0000000..d8c7e21 --- /dev/null +++ b/spec/support/scripts/create_test_proc.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module MysqlFramework + module Support + module Scripts + class CreateTestProc < MysqlFramework::Scripts::Base + def initialize + @identifier = 201807031200 # 12:90 03/07/2018 + end + + PROC_FILE = 'spec/support/procedure.sql' + + def apply + update_procedure('test_procedure', PROC_FILE) + end + + def rollback + raise 'Rollback not supported in test.' + end + + def tags + [MysqlFramework::Support::Tables::TestTable::NAME, 'TestProc'] + end + end + end + end +end diff --git a/spec/support/scripts/create_test_table.rb b/spec/support/scripts/create_test_table.rb new file mode 100644 index 0000000..e49c1fe --- /dev/null +++ b/spec/support/scripts/create_test_table.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MysqlFramework + module Support + module Scripts + class CreateTestTable < MysqlFramework::Scripts::Base + def initialize + @identifier = 201801011030 # 10:30 01/01/2018 + end + + def apply + mysql_connector.query(<<~SQL) + CREATE TABLE IF NOT EXISTS `#{database_name}`.`test` ( + `id` CHAR(36) NOT NULL, + `name` VARCHAR(255) NULL, + `action` VARCHAR(255) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`) + ) + SQL + end + + def rollback + raise 'Rollback not supported in test.' + end + + def tags + [MysqlFramework::Support::Tables::TestTable::NAME] + end + end + end + end +end diff --git a/spec/support/tables/demo.rb b/spec/support/tables/demo.rb new file mode 100644 index 0000000..564e6ae --- /dev/null +++ b/spec/support/tables/demo.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module MysqlFramework + module Support + module Tables + class DemoTable + extend MysqlFramework::Scripts::Table + + NAME = 'demo' + + register_table NAME + end + end + end +end diff --git a/spec/support/tables/test.rb b/spec/support/tables/test.rb new file mode 100644 index 0000000..3f0990b --- /dev/null +++ b/spec/support/tables/test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module MysqlFramework + module Support + module Tables + class TestTable + extend MysqlFramework::Scripts::Table + + NAME = 'test' + + register_table NAME + end + end + end +end