exwiw 0.1.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,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