mysql_framework 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 889fe8ccc0b85df3fe457ceeacaf4ac66a8953fe6d68a62a066d0700c26d05fc
4
+ data.tar.gz: 619ace891bdf1d809774628ab8322298cb019ffa92d260467a13835fff4359d3
5
+ SHA512:
6
+ metadata.gz: a6303e0423b54b92e7bf02dcd651a9da14b6a707b6848e046221ea769428e6633308da95389a83a16c1bc26343fcb106b15cb75cc1d79b2ee29c6c4cfe26c07b
7
+ data.tar.gz: 9f4c4372b49495c9660e269eec2eec9d3df901d15c4566c5694c2f463453eeeeb2a0f1eabd68e145aabf0d007249bdb1e5a1a963ddb942be4742544725a2b7a0
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "dynamodb_framework"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,15 @@
1
+ require 'mysql2'
2
+ require 'redlock'
3
+
4
+ require_relative 'mysql_framework/connector'
5
+ require_relative 'mysql_framework/logger'
6
+ require_relative 'mysql_framework/scripts'
7
+ require_relative 'mysql_framework/sql_column'
8
+ require_relative 'mysql_framework/sql_condition'
9
+ require_relative 'mysql_framework/sql_query'
10
+ require_relative 'mysql_framework/sql_table'
11
+ require_relative 'mysql_framework/version'
12
+
13
+ module MysqlFramework
14
+
15
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ class Connector
5
+ def initialize(options = {})
6
+ @connection_pool = ::Queue.new
7
+
8
+ @options = default_options.merge(options)
9
+
10
+ Mysql2::Client.default_query_options.merge!(symbolize_keys: true, cast_booleans: true)
11
+ end
12
+
13
+ # This method is called to fetch a client from the connection pool or create a new client if no idle clients
14
+ # are available.
15
+ def check_out
16
+ @connection_pool.pop(true)
17
+ rescue StandardError
18
+ Mysql2::Client.new(@options)
19
+ end
20
+
21
+ # This method is called to check a client back in to the connection when no longer needed.
22
+ def check_in(client)
23
+ @connection_pool.push(client)
24
+ end
25
+
26
+ # This method is called to use a client from the connection pool.
27
+ def with_client
28
+ client = check_out
29
+ yield client
30
+ ensure
31
+ check_in(client) unless client.nil?
32
+ end
33
+
34
+ # This method is called to execute a prepared statement
35
+ def execute(query)
36
+ with_client do |client|
37
+ statement = client.prepare(query.sql)
38
+ statement.execute(*query.params)
39
+ end
40
+ end
41
+
42
+ # This method is called to execute a query
43
+ def query(query_string)
44
+ with_client { |client| client.query(query_string) }
45
+ end
46
+
47
+ # This method is called to execute a query which will return multiple result sets in an array
48
+ def query_multiple_results(query_string)
49
+ with_client do |client|
50
+ result = []
51
+ result << client.query(query_string).to_a
52
+ result << client.store_result&.to_a while client.next_result
53
+ result.compact
54
+ end
55
+ end
56
+
57
+ # This method is called to use a client within a transaction
58
+ def transaction
59
+ raise ArgumentError, 'No block was given' unless block_given?
60
+
61
+ with_client do |client|
62
+ begin
63
+ client.query('BEGIN')
64
+ yield client
65
+ client.query('COMMIT')
66
+ rescue StandardError => e
67
+ client.query('ROLLBACK')
68
+ raise e
69
+ end
70
+ end
71
+ end
72
+
73
+ def default_options
74
+ {
75
+ host: ENV.fetch('MYSQL_HOST'),
76
+ port: ENV.fetch('MYSQL_PORT'),
77
+ database: ENV.fetch('MYSQL_DATABASE'),
78
+ username: ENV.fetch('MYSQL_USERNAME'),
79
+ password: ENV.fetch('MYSQL_PASSWORD'),
80
+ reconnect: true
81
+ }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module MysqlFramework
6
+ def self.logger
7
+ return @@logger
8
+ end
9
+
10
+ def self.set_logger(logger)
11
+ @@logger = logger
12
+ end
13
+
14
+ MysqlFramework.set_logger(Logger.new(STDOUT))
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'scripts/base'
4
+ require_relative 'scripts/manager'
5
+ require_relative 'scripts/table'
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ module Scripts
5
+ class Base
6
+ def partitions
7
+ ENV.fetch('MYSQL_PARTITIONS', '500').to_i
8
+ end
9
+
10
+ def database_name
11
+ @database_name ||= ENV.fetch('MYSQL_DATABASE')
12
+ end
13
+
14
+ def identifier
15
+ raise NotImplementedError if @identifier.nil?
16
+ @identifier
17
+ end
18
+
19
+ def apply
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def rollback
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def generate_partition_sql
28
+ (1..partitions).each_with_index.map { |_, i| "PARTITION p#{i} VALUES IN (#{i})" }.join(",\n\t")
29
+ end
30
+
31
+ def self.descendants
32
+ ObjectSpace.each_object(Class).select { |klass| klass < self }
33
+ end
34
+
35
+ def tags
36
+ []
37
+ end
38
+
39
+ def update_procedure(proc_name, proc_file)
40
+ mysql_connector.transaction do
41
+ mysql_connector.query(<<~SQL)
42
+ DROP PROCEDURE IF EXISTS #{proc_name};
43
+ SQL
44
+
45
+ proc_sql = File.read(proc_file)
46
+
47
+ mysql_connector.query(proc_sql)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def mysql_connector
54
+ @mysql_connector ||= MysqlFramework::Connector.new
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ module Scripts
5
+ class Manager
6
+ def execute
7
+ lock_manager.lock(self.class, 2000) do |locked|
8
+ raise unless locked
9
+
10
+ initialize_script_history
11
+
12
+ last_executed_script = retrieve_last_executed_script
13
+
14
+ mysql_connector.transaction do
15
+ pending_scripts = calculate_pending_scripts(last_executed_script)
16
+ MysqlFramework.logger.info { "[#{self.class}] - #{pending_scripts.length} pending data store scripts found." }
17
+
18
+ pending_scripts.each { |script| apply(script) }
19
+ end
20
+
21
+ MysqlFramework.logger.info { "[#{self.class}] - Migration script execution complete." }
22
+ end
23
+ end
24
+
25
+ def apply_by_tag(tags)
26
+ lock_manager.lock(self.class, 2000) do |locked|
27
+ raise unless locked
28
+
29
+ initialize_script_history
30
+
31
+ mysql_connector.transaction do
32
+ pending_scripts = calculate_pending_scripts(0)
33
+ MysqlFramework.logger.info { "[#{self.class}] - #{pending_scripts.length} pending data store scripts found." }
34
+
35
+ pending_scripts.reject { |script| (script.tags & tags).empty? }.sort_by(&:identifier)
36
+ .each { |script| apply(script) }
37
+ end
38
+
39
+ MysqlFramework.logger.info { "[#{self.class}] - Migration script execution complete." }
40
+ end
41
+ end
42
+
43
+ def drop_all_tables
44
+ drop_script_history
45
+ all_tables.each { |table| drop_table(table) }
46
+ end
47
+
48
+ def retrieve_last_executed_script
49
+ MysqlFramework.logger.info { "[#{self.class}] - Retrieving last executed script from history." }
50
+
51
+ result = mysql_connector.query(<<~SQL)
52
+ SELECT `identifier` FROM #{migration_table_name} ORDER BY `identifier` DESC
53
+ SQL
54
+
55
+ if result.each.to_a.length.zero?
56
+ 0
57
+ else
58
+ Integer(result.first[:identifier])
59
+ end
60
+ end
61
+
62
+ def initialize_script_history
63
+ MysqlFramework.logger.info { "[#{self.class}] - Initializing script history." }
64
+
65
+ mysql_connector.query(<<~SQL)
66
+ CREATE TABLE IF NOT EXISTS #{migration_table_name} (
67
+ `identifier` CHAR(15) NOT NULL,
68
+ `timestamp` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
69
+ PRIMARY KEY (`identifier`),
70
+ UNIQUE INDEX `identifier_UNIQUE` (`identifier` ASC)
71
+ )
72
+ SQL
73
+ end
74
+
75
+ def calculate_pending_scripts(last_executed_script)
76
+ MysqlFramework.logger.info { "[#{self.class}] - Calculating pending data store scripts." }
77
+
78
+ MysqlFramework::Scripts::Base.descendants.map(&:new)
79
+ .select { |script| script.identifier > last_executed_script }.sort_by(&:identifier)
80
+ end
81
+
82
+ def table_exists?(table_name)
83
+ result = mysql_connector.query(<<~SQL)
84
+ SHOW TABLES LIKE '#{table_name}'
85
+ SQL
86
+ result.count == 1
87
+ end
88
+
89
+ def drop_script_history
90
+ drop_table(migration_table_name)
91
+ end
92
+
93
+ def drop_table(table_name)
94
+ mysql_connector.query(<<~SQL)
95
+ DROP TABLE IF EXISTS #{table_name}
96
+ SQL
97
+ end
98
+
99
+ def all_tables
100
+ self.class.all_tables
101
+ end
102
+
103
+ def self.all_tables
104
+ @all_tables ||= []
105
+ end
106
+
107
+ private
108
+
109
+ def mysql_connector
110
+ @mysql_connector ||= MysqlFramework::Connector.new
111
+ end
112
+
113
+ def lock_manager
114
+ @lock_manager ||= Redlock::Client.new([ENV.fetch('REDIS_URL')])
115
+ end
116
+
117
+ def database
118
+ @database ||= ENV.fetch('MYSQL_DATABASE')
119
+ end
120
+
121
+ def migration_table_name
122
+ return @migration_table_name if @migration_table_name
123
+
124
+ @migration_table_name = "`#{database}`.`migration_script_history`"
125
+ end
126
+
127
+ def apply(script)
128
+ MysqlFramework.logger.info { "[#{self.class}] - Applying script: #{script}." }
129
+
130
+ script.apply
131
+ mysql_connector.query(<<~SQL)
132
+ INSERT INTO #{migration_table_name} (`identifier`, `timestamp`) VALUES ('#{script.identifier}', NOW())
133
+ SQL
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ module Scripts
5
+ module Table
6
+ def register_table(name)
7
+ MysqlFramework::Scripts::Manager.all_tables << name
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ # This class is used to represent a sql column within a table
5
+ class SqlColumn
6
+ def initialize(table:, column:)
7
+ @table = table
8
+ @column = column
9
+ end
10
+
11
+ def to_s
12
+ "`#{@table}`.`#{@column}`"
13
+ end
14
+
15
+ def to_sym
16
+ @column.to_sym
17
+ end
18
+
19
+ # This method is called to create a equals (=) condition for this column.
20
+ def eq(value)
21
+ SqlCondition.new(column: to_s, comparison: '=', value: value)
22
+ end
23
+
24
+ # This method is called to create a not equal (<>) condition for this column.
25
+ def not_eq(value)
26
+ SqlCondition.new(column: to_s, comparison: '<>', value: value)
27
+ end
28
+
29
+ # This method is called to create a greater than (>) condition for this column.
30
+ def gt(value)
31
+ SqlCondition.new(column: to_s, comparison: '>', value: value)
32
+ end
33
+
34
+ # This method is called to create a greater than or equal (>=) condition for this column.
35
+ def gte(value)
36
+ SqlCondition.new(column: to_s, comparison: '>=', value: value)
37
+ end
38
+
39
+ # This method is called to create a less than (<) condition for this column.
40
+ def lt(value)
41
+ SqlCondition.new(column: to_s, comparison: '<', value: value)
42
+ end
43
+
44
+ # This method is called to create a less than or equal (<=) condition for this column.
45
+ def lte(value)
46
+ SqlCondition.new(column: to_s, comparison: '<=', value: value)
47
+ end
48
+
49
+ # This method is called to generate an alias statement for this column.
50
+ def as(name)
51
+ "#{self} as `#{name}`"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlFramework
4
+ # This class is used to represent a Sql Condition for a column.
5
+ class SqlCondition
6
+ # This method is called to get the value of this condition for prepared statements.
7
+ attr_reader :value
8
+
9
+ def initialize(column:, comparison:, value:)
10
+ @column = column
11
+ @comparison = comparison
12
+ @value = value
13
+ end
14
+
15
+ # This method is called to get the condition as a string for a sql prepared statement
16
+ def to_s
17
+ "#{@column} #{@comparison} ?"
18
+ end
19
+ end
20
+ end