mysql_genius-core 0.4.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.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Queries information_schema.tables for data/index/fragmentation metrics
7
+ # per BASE TABLE in the current database, plus an exact SELECT COUNT(*)
8
+ # for each table. Returns an array of hashes suitable for JSON rendering.
9
+ #
10
+ # Takes a Core::Connection. No configuration required — the current
11
+ # database is read from connection.current_database.
12
+ class TableSizes
13
+ def initialize(connection)
14
+ @connection = connection
15
+ end
16
+
17
+ def call
18
+ db_name = @connection.current_database
19
+
20
+ result = @connection.exec_query(<<~SQL)
21
+ SELECT
22
+ table_name,
23
+ engine,
24
+ table_collation,
25
+ auto_increment,
26
+ update_time,
27
+ ROUND(data_length / 1024 / 1024, 2) AS data_mb,
28
+ ROUND(index_length / 1024 / 1024, 2) AS index_mb,
29
+ ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
30
+ ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
31
+ FROM information_schema.tables
32
+ WHERE table_schema = #{@connection.quote(db_name)}
33
+ AND table_type = 'BASE TABLE'
34
+ ORDER BY (data_length + index_length) DESC
35
+ SQL
36
+
37
+ result.to_hashes.map do |row|
38
+ table_name = row["table_name"] || row["TABLE_NAME"]
39
+ row_count = begin
40
+ @connection.select_value("SELECT COUNT(*) FROM #{@connection.quote_table_name(table_name)}")
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ total_mb = (row["total_mb"] || 0).to_f
46
+ fragmented_mb = (row["fragmented_mb"] || 0).to_f
47
+
48
+ {
49
+ table: table_name,
50
+ rows: row_count,
51
+ engine: row["engine"] || row["ENGINE"],
52
+ collation: row["table_collation"] || row["TABLE_COLLATION"],
53
+ auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
54
+ updated_at: row["update_time"] || row["UPDATE_TIME"],
55
+ data_mb: (row["data_mb"] || 0).to_f,
56
+ index_mb: (row["index_mb"] || 0).to_f,
57
+ total_mb: total_mb,
58
+ fragmented_mb: fragmented_mb,
59
+ needs_optimize: total_mb.positive? && fragmented_mb > (total_mb * 0.1),
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Analysis
6
+ # Queries performance_schema.table_io_waits_summary_by_index_usage
7
+ # joined with information_schema.tables to find indexes with zero
8
+ # reads but non-zero row counts in their parent table. Returns hashes
9
+ # with a ready-to-run DROP INDEX statement per result.
10
+ #
11
+ # Skips the PRIMARY index (should never be dropped) and anonymous
12
+ # rows (where INDEX_NAME IS NULL). Raises if performance_schema is
13
+ # unavailable.
14
+ class UnusedIndexes
15
+ def initialize(connection)
16
+ @connection = connection
17
+ end
18
+
19
+ def call
20
+ db_name = @connection.current_database
21
+
22
+ result = @connection.exec_query(<<~SQL)
23
+ SELECT
24
+ s.OBJECT_SCHEMA AS table_schema,
25
+ s.OBJECT_NAME AS table_name,
26
+ s.INDEX_NAME AS index_name,
27
+ s.COUNT_READ AS `reads`,
28
+ s.COUNT_WRITE AS `writes`,
29
+ t.TABLE_ROWS AS table_rows
30
+ FROM performance_schema.table_io_waits_summary_by_index_usage s
31
+ JOIN information_schema.tables t
32
+ ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
33
+ WHERE s.OBJECT_SCHEMA = #{@connection.quote(db_name)}
34
+ AND s.INDEX_NAME IS NOT NULL
35
+ AND s.INDEX_NAME != 'PRIMARY'
36
+ AND s.COUNT_READ = 0
37
+ AND t.TABLE_ROWS > 0
38
+ ORDER BY s.COUNT_WRITE DESC
39
+ SQL
40
+
41
+ result.to_hashes.map do |row|
42
+ table = row["table_name"] || row["TABLE_NAME"]
43
+ index_name = row["index_name"] || row["INDEX_NAME"]
44
+ {
45
+ table: table,
46
+ index_name: index_name,
47
+ reads: (row["reads"] || row["READS"] || 0).to_i,
48
+ writes: (row["writes"] || row["WRITES"] || 0).to_i,
49
+ table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
50
+ drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;",
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Column metadata as returned by Core::Connection#columns_for. Mirrors
6
+ # the subset of ActiveRecord::ConnectionAdapters::Column that the
7
+ # analyses and AI services rely on.
8
+ class ColumnDefinition
9
+ attr_reader :name, :type, :sql_type, :null, :default, :primary_key
10
+
11
+ def initialize(name:, type:, sql_type:, null:, default:, primary_key:)
12
+ @name = name
13
+ @type = type
14
+ @sql_type = sql_type
15
+ @null = null
16
+ @default = default
17
+ @primary_key = primary_key
18
+ freeze
19
+ end
20
+
21
+ def null?
22
+ @null
23
+ end
24
+
25
+ def primary_key?
26
+ @primary_key
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Connection
6
+ # In-memory fake connection used by core specs. Supports stubbing
7
+ # queries by regex and returning canned Core::Result values, plus
8
+ # stubbing metadata methods. See spec/mysql_genius/core/connection/
9
+ # fake_adapter_spec.rb for the full surface.
10
+ class FakeAdapter
11
+ class NoStubError < StandardError; end
12
+
13
+ def initialize
14
+ @stubs = []
15
+ @tables = []
16
+ @columns_for = {}
17
+ @indexes_for = {}
18
+ @primary_keys = {}
19
+ @server_version = "8.0.35"
20
+ @current_database = "test_db"
21
+ end
22
+
23
+ # ----- stub registration -----
24
+
25
+ def stub_query(pattern, columns: [], rows: [], raises: nil)
26
+ @stubs << { pattern: pattern, columns: columns, rows: rows, raises: raises }
27
+ end
28
+
29
+ def stub_server_version(version)
30
+ @server_version = version
31
+ end
32
+
33
+ def stub_current_database(name)
34
+ @current_database = name
35
+ end
36
+
37
+ def stub_tables(list)
38
+ @tables = list
39
+ end
40
+
41
+ def stub_columns_for(table, columns)
42
+ @columns_for[table] = columns
43
+ end
44
+
45
+ def stub_indexes_for(table, indexes)
46
+ @indexes_for[table] = indexes
47
+ end
48
+
49
+ def stub_primary_key(table, name)
50
+ @primary_keys[table] = name
51
+ end
52
+
53
+ # ----- contract -----
54
+
55
+ def exec_query(sql, binds: [])
56
+ _ = binds
57
+ stub = @stubs.find { |s| s[:pattern] =~ sql }
58
+ raise NoStubError, "No stub matched SQL: #{sql}" unless stub
59
+ raise stub[:raises] if stub[:raises]
60
+
61
+ Result.new(columns: stub[:columns], rows: stub[:rows])
62
+ end
63
+
64
+ def select_value(sql)
65
+ result = exec_query(sql)
66
+ return if result.empty?
67
+
68
+ result.rows.first&.first
69
+ end
70
+
71
+ def server_version
72
+ ServerInfo.parse(@server_version)
73
+ end
74
+
75
+ attr_reader :current_database
76
+
77
+ def quote(value)
78
+ case value
79
+ when nil then "NULL"
80
+ when Integer, Float then value.to_s
81
+ when String then "'#{value.gsub("'", "''")}'"
82
+ else "'#{value.to_s.gsub("'", "''")}'"
83
+ end
84
+ end
85
+
86
+ def quote_table_name(name)
87
+ "`#{name}`"
88
+ end
89
+
90
+ attr_reader :tables
91
+
92
+ def columns_for(table)
93
+ @columns_for.fetch(table, [])
94
+ end
95
+
96
+ def indexes_for(table)
97
+ @indexes_for.fetch(table, [])
98
+ end
99
+
100
+ def primary_key(table)
101
+ @primary_keys[table]
102
+ end
103
+
104
+ def close
105
+ nil
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Connection abstraction. This module is a namespace for concrete
6
+ # adapters plus documentation of the contract every adapter must
7
+ # satisfy. It is NOT meant to be included as a mixin; Ruby has no
8
+ # interface enforcement. Tests exercise the contract via duck-typing
9
+ # against the real adapters and the FakeAdapter test helper.
10
+ #
11
+ # Implementing adapters:
12
+ # MysqlGenius::Core::Connection::FakeAdapter — in this gem, for tests
13
+ # MysqlGenius::Core::Connection::ActiveRecordAdapter — in mysql_genius (Rails adapter)
14
+ # MysqlGenius::Core::Connection::TrilogyAdapter — in mysql_genius-desktop (Phase 2)
15
+ #
16
+ # Contract (every adapter must implement):
17
+ #
18
+ # #exec_query(sql) -> Core::Result
19
+ # #select_value(sql) -> Object (first column of first row, or nil)
20
+ # #server_version -> Core::ServerInfo
21
+ # #current_database -> String
22
+ # #quote(value) -> String (SQL-escaped value)
23
+ # #quote_table_name(name) -> String (backtick-quoted identifier)
24
+ # #tables -> Array<String>
25
+ # #columns_for(table) -> Array<Core::ColumnDefinition>
26
+ # #indexes_for(table) -> Array<Core::IndexDefinition>
27
+ # #primary_key(table) -> String or nil
28
+ # #close -> nil
29
+ #
30
+ # Adapters may implement additional methods for efficiency, but any
31
+ # core code that depends on the connection must only call methods
32
+ # defined in this contract.
33
+ module Connection
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Immutable frozen value object returned from Core::QueryRunner#run.
6
+ # Contains the executed columns and (possibly masked) rows plus runtime
7
+ # metrics: row count, wall-clock execution time in milliseconds, and a
8
+ # truncated flag indicating whether the row count reached the applied
9
+ # LIMIT.
10
+ #
11
+ # This is distinct from Core::Result (which models a plain query result
12
+ # shape) because QueryRunner returns runtime metadata that plain results
13
+ # don't carry.
14
+ class ExecutionResult
15
+ attr_reader :columns, :rows, :row_count, :execution_time_ms, :truncated
16
+
17
+ def initialize(columns:, rows:, execution_time_ms:, truncated:)
18
+ @columns = columns.freeze
19
+ @rows = rows.freeze
20
+ @row_count = rows.length
21
+ @execution_time_ms = execution_time_ms
22
+ @truncated = truncated
23
+ freeze
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Index metadata as returned by Core::Connection#indexes_for. Mirrors
6
+ # the subset of ActiveRecord::ConnectionAdapters::IndexDefinition that
7
+ # the analyses rely on.
8
+ class IndexDefinition
9
+ attr_reader :name, :columns, :unique
10
+
11
+ def initialize(name:, columns:, unique:)
12
+ @name = name
13
+ @columns = columns.freeze
14
+ @unique = unique
15
+ freeze
16
+ end
17
+
18
+ def unique?
19
+ @unique
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Runs EXPLAIN against a SELECT query via a Core::Connection. Optionally
6
+ # skips SQL validation (used for explaining captured slow queries from
7
+ # mysql's own logs where the exact text may include references to
8
+ # otherwise-blocked tables).
9
+ #
10
+ # Rejects obviously-truncated SQL — captured slow queries from the
11
+ # slow query log are capped at ~2000 characters, so if the last
12
+ # character doesn't look like a valid terminator we refuse to try.
13
+ # This avoids confusing EXPLAIN errors from partial statements.
14
+ #
15
+ # Reuses Core::QueryRunner::Rejected for validation failures so
16
+ # callers can rescue one error type for both runners.
17
+ class QueryExplainer
18
+ class Truncated < Core::Error; end
19
+
20
+ def initialize(connection, config)
21
+ @connection = connection
22
+ @config = config
23
+ end
24
+
25
+ def explain(sql, skip_validation: false)
26
+ unless skip_validation
27
+ error = SqlValidator.validate(
28
+ sql,
29
+ blocked_tables: @config.blocked_tables,
30
+ connection: @connection,
31
+ )
32
+ raise QueryRunner::Rejected, error if error
33
+ end
34
+
35
+ clean_sql = sql.gsub(/;\s*\z/, "")
36
+
37
+ unless looks_complete?(clean_sql)
38
+ raise Truncated, "This query appears to be truncated and cannot be explained."
39
+ end
40
+
41
+ @connection.exec_query("EXPLAIN #{clean_sql}")
42
+ end
43
+
44
+ private
45
+
46
+ # Heuristic: SQL ends with a value-like token (identifier, number, closing
47
+ # paren/bracket, or closing quote). A trailing SQL keyword such as WHERE,
48
+ # AND, OR, ON, JOIN, SET, HAVING, or a comma/operator means the statement
49
+ # was cut before its next token.
50
+ TRAILING_KEYWORD_PATTERN = /\b(WHERE|AND|OR|ON|JOIN|INNER|OUTER|LEFT|RIGHT|CROSS|HAVING|SET|BETWEEN|LIKE|IN|NOT|IS|FROM|SELECT|GROUP|ORDER|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT)\s*$/i
51
+
52
+ def looks_complete?(sql)
53
+ return false if sql.match?(TRAILING_KEYWORD_PATTERN)
54
+ return false if sql.match?(%r{[,=<>!(+\-*/]\s*$})
55
+
56
+ sql.match?(/[\w'"`)\]]\s*$/)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Forward declaration so Config can be namespaced under QueryRunner.
6
+ # The full QueryRunner class is defined in query_runner.rb.
7
+ class QueryRunner
8
+ Config = Struct.new(
9
+ :blocked_tables,
10
+ :masked_column_patterns,
11
+ :query_timeout_ms,
12
+ keyword_init: true,
13
+ ) do
14
+ def initialize(*)
15
+ super
16
+ freeze
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module MysqlGenius
6
+ module Core
7
+ # Runs SELECT queries against a Core::Connection with SQL validation,
8
+ # row-limit application, timeout hints (MySQL or MariaDB flavor), and
9
+ # column masking. Returns a Core::ExecutionResult on success or raises
10
+ # a specific error class on failure.
11
+ #
12
+ # Does NOT handle audit logging — the caller (Rails concern or future
13
+ # desktop sidecar) is responsible for recording successful queries and
14
+ # errors using whatever logger it owns.
15
+ class QueryRunner
16
+ class Rejected < Core::Error; end
17
+ class Timeout < Core::Error; end
18
+
19
+ TIMEOUT_PATTERNS = [
20
+ "max_statement_time",
21
+ "max_execution_time",
22
+ "Query execution was interrupted",
23
+ ].freeze
24
+
25
+ def initialize(connection, config)
26
+ @connection = connection
27
+ @config = config
28
+ end
29
+
30
+ def run(sql, row_limit:)
31
+ validation_error = SqlValidator.validate(
32
+ sql,
33
+ blocked_tables: @config.blocked_tables,
34
+ connection: @connection,
35
+ )
36
+ raise Rejected, validation_error if validation_error
37
+
38
+ limited = SqlValidator.apply_row_limit(sql, row_limit)
39
+ timed = apply_timeout_hint(limited)
40
+
41
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ result = begin
43
+ @connection.exec_query(timed)
44
+ rescue StandardError => e
45
+ raise Timeout, e.message if timeout_error?(e)
46
+
47
+ raise
48
+ end
49
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
50
+
51
+ masked_rows = mask_rows(result)
52
+
53
+ ExecutionResult.new(
54
+ columns: result.columns,
55
+ rows: masked_rows,
56
+ execution_time_ms: duration_ms,
57
+ truncated: masked_rows.length >= row_limit,
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ def apply_timeout_hint(sql)
64
+ if mariadb?
65
+ timeout_seconds = @config.query_timeout_ms / 1000
66
+ "SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
67
+ else
68
+ sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{@config.query_timeout_ms}) */")
69
+ end
70
+ end
71
+
72
+ def mariadb?
73
+ @connection.server_version.mariadb?
74
+ end
75
+
76
+ def mask_rows(result)
77
+ mask_indices = result.columns.each_with_index.select do |name, _i|
78
+ SqlValidator.masked_column?(name, @config.masked_column_patterns)
79
+ end.map { |(_name, i)| i }.to_set
80
+
81
+ return result.rows if mask_indices.empty?
82
+
83
+ result.rows.map do |row|
84
+ row.each_with_index.map { |value, i| mask_indices.include?(i) ? "[REDACTED]" : value }
85
+ end
86
+ end
87
+
88
+ def timeout_error?(exception)
89
+ msg = exception.message
90
+ TIMEOUT_PATTERNS.any? { |pattern| msg.include?(pattern) }
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Immutable value object representing the result of a query.
6
+ # Adapters translate their native result types into this shape.
7
+ class Result
8
+ include Enumerable
9
+
10
+ attr_reader :columns, :rows
11
+
12
+ def initialize(columns:, rows:)
13
+ @columns = columns.freeze
14
+ @rows = rows.freeze
15
+ freeze
16
+ end
17
+
18
+ def each(&block)
19
+ return @rows.each unless block
20
+
21
+ @rows.each(&block)
22
+ end
23
+
24
+ def to_a
25
+ @rows.dup
26
+ end
27
+
28
+ def count
29
+ @rows.length
30
+ end
31
+
32
+ def empty?
33
+ @rows.empty?
34
+ end
35
+
36
+ # Returns rows as an array of hashes keyed by column name. Mirrors
37
+ # ActiveRecord::Result#to_a's hashification behavior.
38
+ def to_hashes
39
+ @rows.map { |row| @columns.zip(row).to_h }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ # Identifies the database vendor and version. Adapters construct one
6
+ # from the server's VERSION() output.
7
+ class ServerInfo
8
+ attr_reader :vendor, :version
9
+
10
+ class << self
11
+ def parse(version_string)
12
+ vendor = version_string.to_s.downcase.include?("mariadb") ? :mariadb : :mysql
13
+ new(vendor: vendor, version: version_string)
14
+ end
15
+ end
16
+
17
+ # vendor must be :mysql or :mariadb
18
+ def initialize(vendor:, version:)
19
+ @vendor = vendor
20
+ @version = version
21
+ freeze
22
+ end
23
+
24
+ def mariadb?
25
+ @vendor == :mariadb
26
+ end
27
+
28
+ def mysql?
29
+ @vendor == :mysql
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module SqlValidator
6
+ FORBIDDEN_KEYWORDS = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "GRANT", "REVOKE"].freeze
7
+
8
+ extend self
9
+
10
+ def validate(sql, blocked_tables:, connection:)
11
+ return "Please enter a query." if sql.nil? || sql.strip.empty?
12
+
13
+ normalized = sql.gsub(/--.*$/, "").gsub(%r{/\*.*?\*/}m, "").strip
14
+
15
+ unless normalized.match?(/\ASELECT\b/i) || normalized.match?(/\AWITH\b/i)
16
+ return "Only SELECT queries are allowed."
17
+ end
18
+
19
+ return "Access to system schemas is not allowed." if normalized.match?(/\b(information_schema|mysql|performance_schema|sys)\b/i)
20
+
21
+ FORBIDDEN_KEYWORDS.each do |keyword|
22
+ return "#{keyword} statements are not allowed." if normalized.match?(/\b#{keyword}\b/i)
23
+ end
24
+
25
+ tables_in_query = extract_table_references(normalized, connection)
26
+ blocked = tables_in_query & blocked_tables
27
+ if blocked.any?
28
+ return "Access denied for table(s): #{blocked.join(", ")}."
29
+ end
30
+
31
+ nil
32
+ end
33
+
34
+ def extract_table_references(sql, connection)
35
+ tables = []
36
+ sql.scan(/\bFROM\s+((?:`?\w+`?(?:\s*,\s*`?\w+`?)*)+)/i) { |m| m[0].scan(/`?(\w+)`?/) { |t| tables << t[0] } }
37
+ sql.scan(/\bJOIN\s+`?(\w+)`?/i) { |m| tables << m[0] }
38
+ sql.scan(/\b(?:INTO|UPDATE)\s+`?(\w+)`?/i) { |m| tables << m[0] }
39
+ tables.uniq.map(&:downcase) & connection.tables
40
+ end
41
+
42
+ def apply_row_limit(sql, limit)
43
+ if sql.match?(/\bLIMIT\s+\d+\s*,\s*\d+/i)
44
+ sql.gsub(/\bLIMIT\s+(\d+)\s*,\s*(\d+)/i) do
45
+ "LIMIT #{::Regexp.last_match(1).to_i}, #{[::Regexp.last_match(2).to_i, limit].min}"
46
+ end
47
+ elsif sql.match?(/\bLIMIT\s+\d+/i)
48
+ sql.gsub(/\bLIMIT\s+(\d+)/i) { "LIMIT #{[::Regexp.last_match(1).to_i, limit].min}" }
49
+ else
50
+ "#{sql.gsub(/;\s*\z/, "")} LIMIT #{limit}"
51
+ end
52
+ end
53
+
54
+ def masked_column?(column_name, patterns)
55
+ patterns.any? { |pattern| column_name.downcase.include?(pattern) }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ VERSION = "0.4.0"
6
+ end
7
+ end