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.
@@ -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, :include_insert
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
- insert = include_insert ? "INSERT INTO #{working_db}.#{table_name}" : ""
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.source_client.query_options[:database]
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,9 +30,7 @@ module DataTaster
30
30
  private
31
31
 
32
32
  def default_data
33
- {
34
- "schema_migrations" => "1 = 1",
35
- }
33
+ DataTaster.config.output.default_data
36
34
  end
37
35
  end
38
36
  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 #{working_db}.#{table_name}
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 #{working_db}.#{table_name}
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 #{working_db}.#{table_name}
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 #{working_db}.#{table_name}
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 working_db
95
- DataTaster.config.working_client.query_options[:database]
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
@@ -14,7 +14,9 @@ module DataTaster
14
14
  end
15
15
 
16
16
  def db_config
17
- ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: "primary").configuration_hash
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
- # Ensures the given tables are cleaned of
6
- # information deemed sensitive
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, :include_insert
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 include_insert
47
+ return sql unless output.run_sanitization?
33
48
 
34
- DataTaster.safe_execute(sql)
49
+ output.write_statement(sql)
50
+ sql
35
51
  rescue => e
36
- e.message << context_warning
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataTaster
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end