exwiw 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module Adapter
5
+ class Sqlite3Adapter < Base
6
+ def execute(query_ast)
7
+ sql = compile_ast(query_ast)
8
+
9
+ @logger.debug(" Executing SQL: \n#{sql}")
10
+ connection.execute(sql)
11
+ end
12
+
13
+ def to_bulk_insert(results, table)
14
+ table_name = table.name
15
+
16
+ value_list = results.map do |row|
17
+ quoted_values = row.map do |value|
18
+ escape_value(value)
19
+ end
20
+ "(" + quoted_values.join(', ') + ")"
21
+ end
22
+ values = value_list.join(",\n")
23
+
24
+ column_names = table.columns.map(&:name).join(', ')
25
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
26
+ end
27
+
28
+ def to_bulk_delete(select_query_ast, table)
29
+ raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
30
+
31
+ sql = "DELETE FROM #{select_query_ast.from_table_name}"
32
+
33
+ if select_query_ast.join_clauses.empty?
34
+ # Ignore filter option, because bulk delete is for cleaning before import,
35
+ # so it should delete all records to avoid foreign key violation & data consistancy.
36
+ compiled_where_conditions = select_query_ast.
37
+ where_clauses.
38
+ select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
39
+ map do |where|
40
+ compile_where_condition(where, select_query_ast.from_table_name)
41
+ end
42
+
43
+ if compiled_where_conditions.size > 0
44
+ sql += "\nWHERE "
45
+ sql += compiled_where_conditions.join(' AND ')
46
+ end
47
+ sql += ";"
48
+
49
+ return sql
50
+ end
51
+
52
+ subquery_ast = Exwiw::QueryAst::Select.new
53
+ first_join = select_query_ast.join_clauses.first.clone
54
+
55
+ subquery_ast.from(first_join.join_table_name)
56
+ primay_key_col = table.columns.find { |col| col.name == table.primary_key }
57
+ subquery_ast.select([primay_key_col])
58
+ select_query_ast.join_clauses[1..].each do |join|
59
+ subquery_ast.join(join)
60
+ end
61
+ first_join.where_clauses.each do |where|
62
+ # Ignore filter option, because bulk delete is for cleaning before import,
63
+ # so it should delete all records to avoid foreign key violation & data consistancy.
64
+ subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
65
+ end
66
+
67
+ foreign_key = first_join.foreign_key
68
+ subquery_sql = compile_ast(subquery_ast)
69
+ sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql});"
70
+
71
+ sql
72
+ end
73
+
74
+ def compile_ast(query_ast)
75
+ raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
76
+
77
+ sql = "SELECT "
78
+ sql += query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
79
+ sql += " FROM #{query_ast.from_table_name}"
80
+
81
+ query_ast.join_clauses.each do |join|
82
+ sql += " JOIN #{join.join_table_name} ON #{query_ast.from_table_name}.#{join.foreign_key} = #{join.join_table_name}.#{join.primary_key}"
83
+
84
+ join.where_clauses.each do |where|
85
+ compiled_where_condition = compile_where_condition(where, join.join_table_name)
86
+ sql += " AND #{compiled_where_condition}"
87
+ end
88
+ end
89
+
90
+ if query_ast.where_clauses.any?
91
+ sql += " WHERE "
92
+ sql += query_ast.where_clauses.map { |where| compile_where_condition(where, query_ast.from_table_name) }.join(' AND ')
93
+ end
94
+
95
+ sql
96
+ end
97
+
98
+ private def compile_where_condition(where_clause, table_name)
99
+ # Use as it is if it's a raw query
100
+ return where_clause if where_clause.is_a?(String)
101
+
102
+ key = "#{table_name}.#{where_clause.column_name}"
103
+
104
+ if where_clause.operator == :eq
105
+ values = where_clause.value.map { |v| escape_value(v) }
106
+
107
+ if values.size == 1
108
+ "#{key} = #{values.first}"
109
+ else
110
+ "#{key} IN (#{values.join(', ')})"
111
+ end
112
+ else
113
+ raise "Unsupported operator: #{where_clause.operator}"
114
+ end
115
+ end
116
+
117
+ private def escape_value(value)
118
+ case value
119
+ when nil
120
+ "NULL"
121
+ when String
122
+ qv = escape_single_quote(value)
123
+ "'#{qv}'"
124
+ else
125
+ value
126
+ end
127
+ end
128
+
129
+ private def escape_single_quote(value)
130
+ value.gsub("'", "''")
131
+ end
132
+
133
+ private def compile_column_name(ast, column)
134
+ case column
135
+ when Exwiw::QueryAst::ColumnValue::Plain
136
+ "#{ast.from_table_name}.#{column.name}"
137
+ when Exwiw::QueryAst::ColumnValue::RawSql
138
+ column.value
139
+ when Exwiw::QueryAst::ColumnValue::ReplaceWith
140
+ parts = column.value.scan(/[^{}]+|\{[^{}]*\}/).map do |part|
141
+ if part.start_with?('{')
142
+ name = part[1..-2]
143
+ "#{ast.from_table_name}.#{name}"
144
+ else
145
+ "'#{part}'"
146
+ end
147
+ end
148
+
149
+ replaced = parts.join(" || ")
150
+ "(#{replaced})"
151
+ else
152
+ raise "Unreachable case: #{column.inspect}"
153
+ end
154
+ end
155
+
156
+ private def connection
157
+ @connection ||=
158
+ begin
159
+ require 'sqlite3'
160
+ SQLite3::Database.new(@connection_config.database_name)
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module Adapter
5
+ class Base
6
+ attr_reader :connection_config
7
+
8
+ def initialize(connection_config, logger)
9
+ @connection_config = connection_config
10
+ @logger = logger
11
+ end
12
+ end
13
+
14
+ # @params [Exwiw::QueryAst] query_ast
15
+ def execute(query_ast)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # @params [Array<Array<String>>] array of rows
20
+ # @params [Exwiw::TableConfig] table
21
+ def to_bulk_insert(results, table)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # @params [Exwiw::QueryAst] select_query_ast
26
+ # @params [Exwiw::TableConfig] table
27
+ def to_bulk_delete(select_query_ast, table)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def self.build(connection_config, logger)
32
+ case connection_config.adapter
33
+ when 'sqlite3'
34
+ Adapter::Sqlite3Adapter.new(connection_config, logger)
35
+ when 'mysql2'
36
+ Adapter::Mysql2Adapter.new(connection_config, logger)
37
+ when 'postgresql'
38
+ Adapter::PostgresqlAdapter.new(connection_config, logger)
39
+ else
40
+ raise 'Unsupported adapter'
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class BelongsTo
5
+ include Serdes
6
+
7
+ attribute :foreign_key, String
8
+ attribute :table_name, String
9
+
10
+ def self.from_symbol_keys(hash)
11
+ from(hash.transform_keys(&:to_s))
12
+ end
13
+ end
14
+ end
data/lib/exwiw/cli.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'optparse'
5
+ require 'pathname'
6
+
7
+ require 'json'
8
+
9
+ require 'exwiw'
10
+
11
+ module Exwiw
12
+ class CLI
13
+ def self.start(argv)
14
+ new(argv).run
15
+ end
16
+
17
+ def initialize(argv)
18
+ @argv = argv.dup
19
+ @help = argv.empty?
20
+
21
+ @database_host = nil
22
+ @database_port = nil
23
+ @database_user = nil
24
+ @database_password = ENV["DATABASE_PASSWORD"]
25
+ @output_dir = "dump"
26
+ @config_dir = nil
27
+ @database_adapter = nil
28
+ @database_name = nil
29
+ @target_table_name = nil
30
+ @ids = []
31
+ @log_level = :info
32
+
33
+ parser.parse!(@argv)
34
+ end
35
+
36
+ def run
37
+ if @help
38
+ puts parser.help
39
+ else
40
+ validate_options!
41
+
42
+ connection_config = ConnectionConfig.new(
43
+ adapter: @database_adapter,
44
+ host: @database_host,
45
+ port: @database_port,
46
+ user: @database_user,
47
+ password: @database_password,
48
+ database_name: @database_name,
49
+ )
50
+
51
+ dump_target = DumpTarget.new(
52
+ table_name: @target_table_name,
53
+ ids: @ids,
54
+ )
55
+
56
+ logger = build_logger
57
+
58
+ Runner.new(
59
+ connection_config: connection_config,
60
+ output_dir: @output_dir,
61
+ config_dir: @config_dir,
62
+ dump_target: dump_target,
63
+ logger: logger,
64
+ ).run
65
+ end
66
+ end
67
+
68
+ private def validate_options!
69
+ if @database_adapter != "sqlite3"
70
+ {
71
+ "Target database host" => @database_host,
72
+ "Target database port" => @database_port,
73
+ "Database user" => @database_user,
74
+ "Target database name" => @database_name,
75
+ }.each do |k, v|
76
+ if v.nil?
77
+ $stderr.puts "#{k} is required"
78
+ exit 1
79
+ end
80
+ end
81
+
82
+ if @config_dir.nil?
83
+ $stderr.puts "Config dir is required"
84
+ end
85
+
86
+ if @database_password.nil? || @database_password.empty?
87
+ $stderr.puts "environment variable 'DATABASE_PASSWORD' is required"
88
+ exit 1
89
+ end
90
+ end
91
+
92
+ valid_adapters = ["mysql2", "postgresql", "sqlite3"]
93
+ unless valid_adapters.include?(@database_adapter)
94
+ $stderr.puts "Invalid adapter. Available options are: #{valid_adapters.join(', ')}"
95
+ exit 1
96
+ end
97
+
98
+ if @target_table_name.nil? || @target_table_name.empty?
99
+ $stderr.puts "Target table is required"
100
+ exit 1
101
+ end
102
+
103
+ if @ids.empty?
104
+ $stderr.puts "At least one ID is required"
105
+ exit 1
106
+ end
107
+ end
108
+
109
+ private def build_logger
110
+ formatter = proc do |severity, timestamp, progname, msg|
111
+ formatted_ts = timestamp.strftime("%Y-%m-%d %H:%M:%S")
112
+ "#{formatted_ts} [#{progname}]: #{msg}\n"
113
+ end
114
+
115
+ Logger.new(
116
+ STDOUT,
117
+ level: @log_level,
118
+ datetime_format: "%Y-%m-%d %H:%M:%S",
119
+ progname: "exwiw",
120
+ formatter: formatter,
121
+ )
122
+ end
123
+
124
+ private def parser
125
+ @parser ||= OptionParser.new do |opts|
126
+ opts.banner = "exwiw #{Exwiw::VERSION}"
127
+ opts.version = Exwiw::VERSION
128
+
129
+ opts.on("-h", "--host=HOST", "Target database host") { |v| @database_host = v }
130
+ opts.on("-p", "--port=PORT", "Target database port") { |v| @database_port = v }
131
+ opts.on("-u", "--user=USERNAME", "Target database user") { |v| @database_user = v }
132
+ opts.on("-o", "--output-dir=[DUMP_DIR_PATH]", "Output file path. default is dump/") do |v|
133
+ @output_dir = v.end_with?("/") ? v[0..-2] : v
134
+ end
135
+ opts.on("-c", "--config-dir=CONFIG_DIR_PATH", "Config dir path.") do |v|
136
+ @config_dir = v.end_with?("/") ? v[0..-2] : v
137
+ end
138
+ opts.on("-a", "--adapter=ADAPTER", "Database adapter") { |v| @database_adapter = v }
139
+ opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
140
+ opts.on("--target-table=TABLE", "Target table for extraction") { |v| @target_table_name = v }
141
+ opts.on("--ids=IDS", "Comma-separated list of identifiers") { |v| @ids = v.split(',') }
142
+ opts.on("--log-level=LEVEL", "Log level (debug, info). default is info") { |v| @log_level = v.to_sym }
143
+
144
+ opts.on("--help", "Print this help") do
145
+ @help = true
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module DetermineTableProcessingOrder
5
+ module_function
6
+
7
+ # @param tables [Array<Exwiw::TableConfig>] tables
8
+ # @return [Array<String>] sorted table names
9
+ def run(tables)
10
+ return tables.map(&:name) if tables.size < 2
11
+
12
+ ordered_table_names = []
13
+
14
+ table_by_name = tables.each_with_object({}) do |table, acc|
15
+ acc[table.name] = table
16
+ end
17
+
18
+ loop do
19
+ break if table_by_name.empty?
20
+
21
+ tables_with_no_dependencies = table_by_name.values.select do |table|
22
+ not_resolved_names = compute_table_dependencies(table) - ordered_table_names - [table.name]
23
+
24
+ not_resolved_names.empty?
25
+ end
26
+
27
+ tables_with_no_dependencies.each do |table|
28
+ ordered_table_names << table.name
29
+ table_by_name.delete(table.name)
30
+ end
31
+ end
32
+
33
+ ordered_table_names
34
+ end
35
+
36
+ def compute_table_dependencies(table)
37
+ table.belongs_tos.each_with_object([]) do |relation, acc|
38
+ acc << relation.table_name
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ module QueryAst
5
+ class JoinClause
6
+ attr_reader :base_table_name, :foreign_key, :join_table_name, :primary_key, :where_clauses
7
+
8
+ def initialize(base_table_name:, foreign_key:, join_table_name:, primary_key:, where_clauses: [])
9
+ @base_table_name = base_table_name
10
+ @foreign_key = foreign_key
11
+ @join_table_name = join_table_name
12
+ @primary_key = primary_key
13
+ @where_clauses = where_clauses
14
+ end
15
+
16
+ def to_h
17
+ hash = {
18
+ base_table_name: base_table_name,
19
+ foreign_key: foreign_key,
20
+ join_table_name: join_table_name,
21
+ primary_key: primary_key,
22
+ }
23
+ hash[:where_clauses] = where_clauses.map(&:to_h) if where_clauses.size.positive?
24
+ hash
25
+ end
26
+ end
27
+
28
+ WhereClause = Struct.new(:column_name, :operator, :value, keyword_init: true) do
29
+ def to_h
30
+ {
31
+ column_name: column_name,
32
+ operator: operator,
33
+ value: value
34
+ }
35
+ end
36
+ end
37
+
38
+ module ColumnValue
39
+ Base = Struct.new(:name, :value, keyword_init: true)
40
+ Plain = Class.new(Base)
41
+ ReplaceWith = Class.new(Base)
42
+ RawSql = Class.new(Base)
43
+ end
44
+
45
+ class Select
46
+ attr_reader :from_table_name, :columns, :where_clauses, :join_clauses
47
+
48
+ def initialize
49
+ @from_table_name = nil
50
+ @columns = []
51
+ @where_clauses = []
52
+ @join_clauses = []
53
+ end
54
+
55
+ def from(table)
56
+ @from_table_name = table
57
+ end
58
+
59
+ def select(columns)
60
+ @columns = map_column_value(columns)
61
+ end
62
+
63
+ def where(where_clause)
64
+ @where_clauses << where_clause
65
+ end
66
+
67
+ def join(join_clause)
68
+ @join_clauses << join_clause
69
+ end
70
+
71
+ private def map_column_value(columns)
72
+ columns.map do |c|
73
+ if c.raw_sql
74
+ QueryAst::ColumnValue::RawSql.new(name: c.name, value: c.raw_sql)
75
+ elsif c.replace_with
76
+ QueryAst::ColumnValue::ReplaceWith.new(name: c.name, value: c.replace_with)
77
+ else
78
+ QueryAst::ColumnValue::Plain.new(name: c.name, value: c.name)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class QueryAstBuilder
5
+ def self.run(table_name, table_by_name, dump_target, logger)
6
+ new(table_name, table_by_name, dump_target, logger).run
7
+ end
8
+
9
+ attr_reader :table_name, :table_by_name, :dump_target
10
+
11
+ def initialize(table_name, table_by_name, dump_target, logger)
12
+ @table_name = table_name
13
+ @table_by_name = table_by_name
14
+ @dump_target = dump_target
15
+ @logger = logger
16
+ end
17
+
18
+ def run
19
+ table = table_by_name.fetch(table_name)
20
+
21
+ where_clauses = build_where_clauses(table, dump_target)
22
+ join_clauses = build_join_clauses(table, table_by_name, dump_target)
23
+
24
+ QueryAst::Select.new.tap do |ast|
25
+ ast.from(table.name)
26
+ ast.select(table.columns)
27
+ join_clauses.each { |join_clause| ast.join(join_clause) }
28
+ where_clauses.each { |where_clause| ast.where(where_clause) }
29
+ end
30
+ end
31
+
32
+ private def build_join_clauses(table, table_by_name, dump_target)
33
+ path_tables = find_path_to_dump_target(table, table_by_name, dump_target)
34
+ @logger.debug(" Join path from the table to dump target: #{path_tables}")
35
+
36
+ # the path is empty, it means that the table is not related to the dump target
37
+ # the path is 1, it's impossible case
38
+ return [] if path_tables.size < 2
39
+
40
+ join_clauses = []
41
+
42
+ path_tables.each_cons(2) do |from_table_name, to_table_name|
43
+ from_table = table_by_name[from_table_name]
44
+ to_table = table_by_name[to_table_name]
45
+
46
+ relation = from_table.belongs_to(to_table_name)
47
+
48
+ join_clause = QueryAst::JoinClause.new(
49
+ base_table_name: from_table.name,
50
+ foreign_key: relation.foreign_key,
51
+ join_table_name: to_table.name,
52
+ primary_key: to_table.primary_key,
53
+ where_clauses: []
54
+ )
55
+ relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
56
+ if relation_to_dump_target
57
+ join_clause.where_clauses.push QueryAst::WhereClause.new(
58
+ column_name: relation_to_dump_target.foreign_key,
59
+ operator: :eq,
60
+ value: dump_target.ids
61
+ )
62
+ end
63
+
64
+ join_clauses.push(join_clause)
65
+ end
66
+
67
+ join_clauses
68
+ end
69
+
70
+ private def build_where_clauses(table, dump_target)
71
+ clauses = []
72
+
73
+ if table.name == dump_target.table_name
74
+ clauses.push Exwiw::QueryAst::WhereClause.new(
75
+ column_name: 'id',
76
+ operator: :eq,
77
+ value: dump_target.ids
78
+ )
79
+
80
+ return clauses
81
+ end
82
+
83
+ belongs_to = table.belongs_to(dump_target.table_name)
84
+ return clauses if belongs_to.nil?
85
+
86
+ clauses.push Exwiw::QueryAst::WhereClause.new(
87
+ column_name: belongs_to.foreign_key,
88
+ operator: :eq,
89
+ value: dump_target.ids
90
+ )
91
+
92
+ if table.filter
93
+ clauses.push table.filter
94
+ end
95
+
96
+ clauses
97
+ end
98
+
99
+ private def find_path_to_dump_target(table, table_by_name, dump_target)
100
+ return [] if table.name == dump_target.table_name
101
+
102
+ visited = {}
103
+ queue = [[table.name, []]]
104
+
105
+ until queue.empty?
106
+ current_table_name, path = queue.shift
107
+ current_table = table_by_name[current_table_name]
108
+
109
+ next if visited[current_table_name]
110
+ visited[current_table_name] = true
111
+
112
+ current_table.belongs_tos.each do |relation|
113
+ next_table_name = relation.table_name
114
+ next_path = path + [current_table_name]
115
+
116
+ return next_path if next_table_name == dump_target.table_name
117
+
118
+ queue.push([next_table_name, next_path])
119
+ end
120
+ end
121
+
122
+ queue
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ load "tasks/exwiw.rake"
7
+ end
8
+ end
9
+ end