data_taster 0.4.2 → 0.5.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.
- checksums.yaml +4 -4
- data/.rubocop.yml +0 -8
- data/Appraisals +6 -8
- data/Gemfile +1 -5
- data/Gemfile.lock +176 -158
- data/config.ru +9 -0
- data/docs/CHANGELOG.md +43 -0
- data/gemfiles/rails_7_1.gemfile +11 -0
- data/gemfiles/rails_7_1.gemfile.lock +354 -0
- data/gemfiles/rails_7_2.gemfile +11 -0
- data/gemfiles/rails_7_2.gemfile.lock +348 -0
- data/lib/data_taster/adapters/database_output.rb +80 -0
- data/lib/data_taster/adapters/file_output.rb +75 -0
- data/lib/data_taster/adapters/mysql_source.rb +23 -0
- data/lib/data_taster/adapters/output.rb +37 -0
- data/lib/data_taster/collection.rb +10 -15
- data/lib/data_taster/confection.rb +1 -3
- data/lib/data_taster/detergent.rb +31 -6
- data/lib/data_taster/export_context.rb +31 -0
- data/lib/data_taster/helper.rb +3 -1
- data/lib/data_taster/sanitizer.rb +27 -9
- data/lib/data_taster/sanitizer_exporter.rb +65 -0
- data/lib/data_taster/sql_literal.rb +102 -0
- data/lib/data_taster/version.rb +1 -1
- data/lib/data_taster.rb +32 -26
- data/sig/data_taster.rbs +66 -1
- metadata +55 -33
- data/gemfiles/rails_6_0.gemfile +0 -12
- data/gemfiles/rails_6_0.gemfile.lock +0 -271
- data/gemfiles/rails_6_1.gemfile +0 -12
- data/gemfiles/rails_6_1.gemfile.lock +0 -274
- data/gemfiles/rails_7_0.gemfile +0 -12
- data/gemfiles/rails_7_0.gemfile.lock +0 -273
- data/lib/data_taster/sample.rb +0 -45
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class DatabaseOutput < Output
|
|
5
|
+
attr_reader :target_client
|
|
6
|
+
|
|
7
|
+
def sample!
|
|
8
|
+
source = DataTaster.config.source
|
|
9
|
+
table_names(source).each do |table_name|
|
|
10
|
+
collection = DataTaster::Collection.new(table_name)
|
|
11
|
+
import_table(collection, table_name)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run_sanitization?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def default_data
|
|
20
|
+
{ "schema_migrations" => "1 = 1" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def import_table(collection, table_name)
|
|
24
|
+
payload = collection.assemble
|
|
25
|
+
return write_drop_table(table_name) if payload.empty?
|
|
26
|
+
|
|
27
|
+
export(collection, table_name, payload)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def target_database
|
|
31
|
+
target_client.query_options[:database]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def qualified_table_name(table_name)
|
|
35
|
+
"#{target_database}.#{table_name}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def write_statement(sql)
|
|
39
|
+
safe_execute(sql)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write_raw(line)
|
|
43
|
+
write_statement(line)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def safe_execute(sql)
|
|
49
|
+
foreign_key_check = target_client.query("SELECT @@FOREIGN_KEY_CHECKS").first["@@FOREIGN_KEY_CHECKS"]
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
target_client.query("SET FOREIGN_KEY_CHECKS=0")
|
|
53
|
+
target_client.query(sql)
|
|
54
|
+
ensure
|
|
55
|
+
target_client.query("SET FOREIGN_KEY_CHECKS=#{foreign_key_check};")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def table_names(source)
|
|
60
|
+
source.table_names
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def export(collection, table_name, payload)
|
|
64
|
+
write_statement("TRUNCATE TABLE #{qualified_table_name(table_name)}")
|
|
65
|
+
insert_table_name = "#{ExportContext.quote_ident(target_database)}.#{ExportContext.quote_ident(table_name)}"
|
|
66
|
+
Sanitizer.new(table_name, payload[:sanitize]).export_sanitized_rows(
|
|
67
|
+
collection,
|
|
68
|
+
insert_table_name: insert_table_name
|
|
69
|
+
) { |header, values| write_sanitized_insert(header, values) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_drop_table(table_name)
|
|
73
|
+
write_statement("DROP TABLE IF EXISTS #{qualified_table_name(table_name)}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_sanitized_insert(header, values)
|
|
77
|
+
write_statement("#{header} #{values}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class FileOutput < Output
|
|
5
|
+
attr_reader :path, :target_database
|
|
6
|
+
|
|
7
|
+
def sample!
|
|
8
|
+
start_export
|
|
9
|
+
|
|
10
|
+
process_export
|
|
11
|
+
|
|
12
|
+
finish_export
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def default_data
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run_sanitization?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def qualified_table_name(table_name)
|
|
24
|
+
DataTaster::ExportContext.quote_ident(table_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write_statement(sql)
|
|
28
|
+
@io.puts "#{sql};"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_raw(line)
|
|
32
|
+
@io.puts line
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def process_export
|
|
38
|
+
table_names.each do |table_name|
|
|
39
|
+
collection = DataTaster::Collection.new(table_name)
|
|
40
|
+
export_table(collection, table_name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start_export
|
|
45
|
+
DataTaster.logger.info("Writing SQL file to #{path}")
|
|
46
|
+
@io = File.open(path, "w")
|
|
47
|
+
@io.puts "SET FOREIGN_KEY_CHECKS=0;"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def finish_export
|
|
51
|
+
@io.puts "SET FOREIGN_KEY_CHECKS=1;"
|
|
52
|
+
@io.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def export_table(collection, table_name)
|
|
56
|
+
payload = collection.assemble
|
|
57
|
+
return if payload.empty?
|
|
58
|
+
|
|
59
|
+
insert_table_name = ExportContext.quote_ident(table_name)
|
|
60
|
+
Sanitizer.new(table_name, payload[:sanitize]).export_sanitized_rows(
|
|
61
|
+
collection,
|
|
62
|
+
insert_table_name: insert_table_name
|
|
63
|
+
) { |header, values| write_sanitized_insert(header, values) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def table_names
|
|
67
|
+
DataTaster.confection.keys
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_sanitized_insert(header, values)
|
|
71
|
+
write_raw(header)
|
|
72
|
+
write_raw("#{values};")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class MysqlSource
|
|
5
|
+
attr_reader :source_client
|
|
6
|
+
|
|
7
|
+
def initialize(source_client:)
|
|
8
|
+
@source_client = source_client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def query(sql)
|
|
12
|
+
source_client.query(sql)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def database
|
|
16
|
+
source_client.query_options[:database]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def table_names
|
|
20
|
+
query("SHOW tables").map { |row| row[row.keys.first] }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class Output
|
|
5
|
+
def initialize(**options)
|
|
6
|
+
options.each { |key, value| instance_variable_set(:"@#{key}", value) }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def sample!
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def default_data
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run_sanitization?
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def target_database
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def qualified_table_name(table_name)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write_statement(_sql)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write_raw(_line)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -12,7 +12,6 @@ module DataTaster
|
|
|
12
12
|
def initialize(table_name)
|
|
13
13
|
@table_name = table_name
|
|
14
14
|
@ingredients = DataTaster.confection[table_name]
|
|
15
|
-
@include_insert = DataTaster.config.include_insert
|
|
16
15
|
end
|
|
17
16
|
|
|
18
17
|
def assemble
|
|
@@ -26,9 +25,16 @@ module DataTaster
|
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
|
|
28
|
+
def export_select_sql
|
|
29
|
+
<<-SQL.squish
|
|
30
|
+
SELECT * FROM #{source_db}.#{table_name}
|
|
31
|
+
WHERE #{where_clause}
|
|
32
|
+
SQL
|
|
33
|
+
end
|
|
34
|
+
|
|
29
35
|
private
|
|
30
36
|
|
|
31
|
-
attr_reader :table_name, :ingredients
|
|
37
|
+
attr_reader :table_name, :ingredients
|
|
32
38
|
|
|
33
39
|
def skippable?
|
|
34
40
|
table_name.downcase.match(/^_/) ||
|
|
@@ -36,14 +42,7 @@ module DataTaster
|
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
def selection
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
sql = <<-SQL.squish
|
|
42
|
-
#{insert}
|
|
43
|
-
SELECT * FROM #{source_db}.#{table_name}
|
|
44
|
-
WHERE #{where_clause}
|
|
45
|
-
SQL
|
|
46
|
-
|
|
45
|
+
sql = export_select_sql
|
|
47
46
|
DataTaster.logger.info(sql)
|
|
48
47
|
sql
|
|
49
48
|
end
|
|
@@ -64,11 +63,7 @@ module DataTaster
|
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
def source_db
|
|
67
|
-
@source_db ||= DataTaster.config.
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def working_db
|
|
71
|
-
@working_db ||= DataTaster.config.working_client.query_options[:database]
|
|
66
|
+
@source_db ||= DataTaster.config.source.database
|
|
72
67
|
end
|
|
73
68
|
end
|
|
74
69
|
end
|
|
@@ -30,10 +30,35 @@ module DataTaster
|
|
|
30
30
|
sql
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def insert_value_expression(row, client, column_types: {})
|
|
34
|
+
return DataTaster::SKIP_CODE if value == DataTaster::SKIP_CODE
|
|
35
|
+
|
|
36
|
+
if sanitize_function?
|
|
37
|
+
wash_values(value.to_s, row, client, column_types)
|
|
38
|
+
elsif value.blank?
|
|
39
|
+
"NULL"
|
|
40
|
+
else
|
|
41
|
+
DataTaster::SqlLiteral.format(client, value, column_type: column_types[column_name.to_s])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
33
45
|
private
|
|
34
46
|
|
|
35
47
|
attr_reader :table_name, :column_name, :value
|
|
36
48
|
|
|
49
|
+
def wash_values(expression, row, client, column_types = {})
|
|
50
|
+
result = expression.dup
|
|
51
|
+
row.keys.map(&:to_s).grep(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/).sort_by { |k| -k.size }.each do |key|
|
|
52
|
+
next unless result.match?(/\b#{Regexp.escape(key)}\b/)
|
|
53
|
+
|
|
54
|
+
result.gsub!(
|
|
55
|
+
/\b#{Regexp.escape(key)}\b/,
|
|
56
|
+
SqlLiteral.format(client, row[key], column_type: column_types[key])
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
37
62
|
def parse_value(given_value)
|
|
38
63
|
# yml files can't hold custom-set dates, they have to be converted to strings
|
|
39
64
|
return given_value unless given_value.is_a?(String) && given_value.match?(/\d{4}-\d{2}-\d{2}/)
|
|
@@ -59,7 +84,7 @@ module DataTaster
|
|
|
59
84
|
|
|
60
85
|
def sql_for_uncast_value
|
|
61
86
|
<<-SQL.squish
|
|
62
|
-
UPDATE #{
|
|
87
|
+
UPDATE #{qualified_table_name}
|
|
63
88
|
SET #{column_name} = #{value}
|
|
64
89
|
WHERE #{column_name} IS NOT NULL
|
|
65
90
|
AND #{column_name} <> #{value}
|
|
@@ -68,7 +93,7 @@ module DataTaster
|
|
|
68
93
|
|
|
69
94
|
def sql_for_date_value
|
|
70
95
|
<<-SQL.squish
|
|
71
|
-
UPDATE #{
|
|
96
|
+
UPDATE #{qualified_table_name}
|
|
72
97
|
SET #{column_name} = '#{value}'
|
|
73
98
|
WHERE #{column_name} IS NOT NULL
|
|
74
99
|
SQL
|
|
@@ -76,7 +101,7 @@ module DataTaster
|
|
|
76
101
|
|
|
77
102
|
def sql_for_nil_value
|
|
78
103
|
<<-SQL.squish
|
|
79
|
-
UPDATE #{
|
|
104
|
+
UPDATE #{qualified_table_name}
|
|
80
105
|
SET #{column_name} = NULL
|
|
81
106
|
WHERE #{column_name} IS NOT NULL
|
|
82
107
|
SQL
|
|
@@ -84,15 +109,15 @@ module DataTaster
|
|
|
84
109
|
|
|
85
110
|
def sql_for_cast_value
|
|
86
111
|
<<-SQL.squish
|
|
87
|
-
UPDATE #{
|
|
112
|
+
UPDATE #{qualified_table_name}
|
|
88
113
|
SET #{column_name} = '#{value}'
|
|
89
114
|
WHERE #{column_name} IS NOT NULL
|
|
90
115
|
AND #{column_name} <> ''
|
|
91
116
|
SQL
|
|
92
117
|
end
|
|
93
118
|
|
|
94
|
-
def
|
|
95
|
-
DataTaster.config.
|
|
119
|
+
def qualified_table_name
|
|
120
|
+
DataTaster.config.output.qualified_table_name(table_name)
|
|
96
121
|
end
|
|
97
122
|
end
|
|
98
123
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class ExportContext
|
|
5
|
+
def initialize(table_name, sanitize, insert_table_name: nil, column_types: {})
|
|
6
|
+
@table_name = table_name
|
|
7
|
+
@insert_table_name = insert_table_name || self.class.quote_ident(table_name)
|
|
8
|
+
@rules = DataTaster::Sanitizer.new(table_name, sanitize).sanitization_rules
|
|
9
|
+
@column_types = column_types.transform_keys(&:to_s)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.quote_ident(name)
|
|
13
|
+
"`#{name.to_s.gsub('`', '``')}`"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :insert_table_name
|
|
17
|
+
|
|
18
|
+
def format_row_tuple(columns, row, client)
|
|
19
|
+
frags = columns.map do |col|
|
|
20
|
+
col_key = col.to_s
|
|
21
|
+
if @rules.key?(col_key) # the colunm has a sanitization rule
|
|
22
|
+
DataTaster::Detergent.new(@table_name, col_key, @rules[col_key])
|
|
23
|
+
.insert_value_expression(row, client, column_types: @column_types)
|
|
24
|
+
else
|
|
25
|
+
DataTaster::SqlLiteral.format(client, row[col], column_type: @column_types[col_key])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
"(#{frags.join(', ')})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/data_taster/helper.rb
CHANGED
|
@@ -14,7 +14,9 @@ module DataTaster
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def db_config
|
|
17
|
-
ActiveRecord::Base.configurations
|
|
17
|
+
ActiveRecord::Base.configurations
|
|
18
|
+
.configs_for(env_name: Rails.env, name: "primary")
|
|
19
|
+
.configuration_hash
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def logg(message)
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module DataTaster
|
|
4
4
|
class Sanitizer
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
BATCH_SIZE = SanitizerExporter::BATCH_SIZE
|
|
6
|
+
|
|
7
|
+
# Ensures the given tables are cleaned of information deemed sensitive
|
|
7
8
|
def initialize(table_name, custom_selections)
|
|
8
9
|
@table_name = table_name
|
|
9
10
|
@custom_selections = custom_selections || {}
|
|
10
|
-
@include_insert = DataTaster.config.include_insert
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def clean!
|
|
@@ -23,19 +23,33 @@ module DataTaster
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def sanitization_rules
|
|
27
|
+
return {} if skippable_table?
|
|
28
|
+
|
|
29
|
+
default_selections.merge(custom_selections).each_with_object({}) do |(column_name, sanitized_value), memo|
|
|
30
|
+
next if sanitized_value == DataTaster::SKIP_CODE
|
|
31
|
+
|
|
32
|
+
memo[column_name.to_s] = sanitized_value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def export_sanitized_rows(collection, insert_table_name:, &write_insert)
|
|
37
|
+
SanitizerExporter.new(table_name, custom_selections, insert_table_name: insert_table_name)
|
|
38
|
+
.export_sanitized_rows(collection, &write_insert)
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
private
|
|
27
42
|
|
|
28
|
-
attr_reader :table_name, :custom_selections
|
|
43
|
+
attr_reader :table_name, :custom_selections
|
|
29
44
|
|
|
30
45
|
def process(sql)
|
|
31
46
|
return if sql == DataTaster::SKIP_CODE
|
|
32
|
-
return sql unless
|
|
47
|
+
return sql unless output.run_sanitization?
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
output.write_statement(sql)
|
|
50
|
+
sql
|
|
35
51
|
rescue => e
|
|
36
|
-
e.message
|
|
37
|
-
|
|
38
|
-
raise e
|
|
52
|
+
raise e, e.message + context_warning
|
|
39
53
|
end
|
|
40
54
|
|
|
41
55
|
def context_warning
|
|
@@ -50,6 +64,10 @@ module DataTaster
|
|
|
50
64
|
WARNING
|
|
51
65
|
end
|
|
52
66
|
|
|
67
|
+
def output
|
|
68
|
+
DataTaster.config.output
|
|
69
|
+
end
|
|
70
|
+
|
|
53
71
|
def skippable_table?
|
|
54
72
|
DataTaster.confection[table_name].blank? ||
|
|
55
73
|
DataTaster.confection[table_name] == DataTaster::SKIP_CODE
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataTaster
|
|
4
|
+
class SanitizerExporter
|
|
5
|
+
BATCH_SIZE = 100
|
|
6
|
+
|
|
7
|
+
def initialize(table_name, custom_selections, insert_table_name:)
|
|
8
|
+
@table_name = table_name
|
|
9
|
+
@custom_selections = custom_selections
|
|
10
|
+
@insert_table_name = insert_table_name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def export_sanitized_rows(collection, &write_insert)
|
|
14
|
+
query_result = export_source.query(collection.export_select_sql)
|
|
15
|
+
|
|
16
|
+
columns = query_result.fields
|
|
17
|
+
return if columns.empty?
|
|
18
|
+
|
|
19
|
+
export_context = build_export_context(columns, query_result)
|
|
20
|
+
process_export_in_batches(export_context, columns, query_result, &write_insert)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :table_name, :custom_selections, :insert_table_name
|
|
26
|
+
|
|
27
|
+
def process_export_in_batches(export_context, columns, query_result, &write_insert)
|
|
28
|
+
batch = []
|
|
29
|
+
query_result.each do |row|
|
|
30
|
+
batch << row
|
|
31
|
+
if batch.size >= BATCH_SIZE
|
|
32
|
+
write_export_batch(export_context, columns, batch, &write_insert)
|
|
33
|
+
batch.clear
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
write_export_batch(export_context, columns, batch, &write_insert) if batch.any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write_export_batch(export_context, columns, rows, &_write_insert)
|
|
40
|
+
return if rows.empty?
|
|
41
|
+
|
|
42
|
+
client = export_source.source_client
|
|
43
|
+
col_list = columns.map { |c| ExportContext.quote_ident(c) }.join(", ")
|
|
44
|
+
tuples = rows.map { |row| export_context.format_row_tuple(columns, row, client) }
|
|
45
|
+
header = "INSERT INTO #{export_context.insert_table_name} (#{col_list}) VALUES"
|
|
46
|
+
values = tuples.join(",\n")
|
|
47
|
+
|
|
48
|
+
yield(header, values)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def export_source
|
|
52
|
+
DataTaster.config.source
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def build_export_context(columns, query_result)
|
|
56
|
+
column_types = columns.zip(query_result.field_types).to_h.transform_keys(&:to_s)
|
|
57
|
+
ExportContext.new(
|
|
58
|
+
table_name,
|
|
59
|
+
custom_selections,
|
|
60
|
+
insert_table_name: insert_table_name,
|
|
61
|
+
column_types: column_types
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module DataTaster
|
|
7
|
+
# Necessary to format Ruby values (the non sanitized values) for use as MySQL INSERT literal fragments
|
|
8
|
+
module SqlLiteral
|
|
9
|
+
BINARY_COLUMN_TYPE = /\A(?:tiny|medium|long)?blob|varbinary|binary|bit|geometry/i
|
|
10
|
+
TEXT_COLUMN_TYPE = /\Ajson|(?:var)?char|(?:tiny|medium|long)?text|enum|set/i
|
|
11
|
+
|
|
12
|
+
def self.format(client, value, column_type: nil)
|
|
13
|
+
case value
|
|
14
|
+
when String
|
|
15
|
+
format_string_value(client, value, column_type: column_type)
|
|
16
|
+
when nil, true, false, Integer, Float, BigDecimal
|
|
17
|
+
format_scalar(value)
|
|
18
|
+
when Time, DateTime, Date
|
|
19
|
+
format_temporal(client, value)
|
|
20
|
+
else
|
|
21
|
+
"'#{client.escape(value.to_s)}'"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.format_temporal(client, value)
|
|
26
|
+
case value
|
|
27
|
+
when Time
|
|
28
|
+
format_escaped_time(client, value, "%Y-%m-%d %H:%M:%S")
|
|
29
|
+
when DateTime
|
|
30
|
+
format_escaped_time(client, value.to_time, "%Y-%m-%d %H:%M:%S")
|
|
31
|
+
when Date
|
|
32
|
+
format_escaped_date(client, value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
private_class_method :format_temporal
|
|
36
|
+
|
|
37
|
+
def self.format_scalar(value)
|
|
38
|
+
return "NULL" if value.nil?
|
|
39
|
+
return "TRUE" if value == true
|
|
40
|
+
return "FALSE" if value == false
|
|
41
|
+
return value.to_s if value.is_a?(Integer)
|
|
42
|
+
return value.finite? ? value.to_s : "NULL" if value.is_a?(Float)
|
|
43
|
+
|
|
44
|
+
value.to_s("F")
|
|
45
|
+
end
|
|
46
|
+
private_class_method :format_scalar
|
|
47
|
+
|
|
48
|
+
def self.format_escaped_time(client, time, strftime_format)
|
|
49
|
+
"'#{client.escape(time.strftime(strftime_format))}'"
|
|
50
|
+
end
|
|
51
|
+
private_class_method :format_escaped_time
|
|
52
|
+
|
|
53
|
+
def self.format_escaped_date(client, value)
|
|
54
|
+
"'#{client.escape(value.strftime('%Y-%m-%d'))}'"
|
|
55
|
+
end
|
|
56
|
+
private_class_method :format_escaped_date
|
|
57
|
+
|
|
58
|
+
def self.format_string_value(client, value, column_type: nil)
|
|
59
|
+
if binary_column?(value, column_type)
|
|
60
|
+
"X'#{value.unpack1('H*')}'"
|
|
61
|
+
else
|
|
62
|
+
"'#{client.escape(encode_as_text(value))}'"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
private_class_method :format_string_value
|
|
66
|
+
|
|
67
|
+
def self.binary_column?(value, column_type)
|
|
68
|
+
return true if binary_column_type?(column_type)
|
|
69
|
+
return false if text_column_type?(column_type)
|
|
70
|
+
|
|
71
|
+
value.encoding == Encoding::ASCII_8BIT && !utf8_text?(value)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :binary_column?
|
|
74
|
+
|
|
75
|
+
def self.binary_column_type?(column_type)
|
|
76
|
+
return false if column_type.nil?
|
|
77
|
+
|
|
78
|
+
column_type.to_s.match?(BINARY_COLUMN_TYPE)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :binary_column_type?
|
|
81
|
+
|
|
82
|
+
def self.text_column_type?(column_type)
|
|
83
|
+
return false if column_type.nil?
|
|
84
|
+
|
|
85
|
+
column_type.to_s.match?(TEXT_COLUMN_TYPE)
|
|
86
|
+
end
|
|
87
|
+
private_class_method :text_column_type?
|
|
88
|
+
|
|
89
|
+
def self.utf8_text?(value)
|
|
90
|
+
value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
91
|
+
end
|
|
92
|
+
private_class_method :utf8_text?
|
|
93
|
+
|
|
94
|
+
def self.encode_as_text(value)
|
|
95
|
+
return value unless value.encoding == Encoding::ASCII_8BIT
|
|
96
|
+
|
|
97
|
+
candidate = value.dup.force_encoding(Encoding::UTF_8)
|
|
98
|
+
candidate.valid_encoding? ? candidate : value
|
|
99
|
+
end
|
|
100
|
+
private_class_method :encode_as_text
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/data_taster/version.rb
CHANGED