multi_compress 0.6.0 → 0.6.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/GET_STARTED.md +1 -1
  4. data/README.md +1 -1
  5. data/THIRD_PARTY_NOTICES.md +1 -1
  6. data/ext/multi_compress/multi_compress.c +230 -77
  7. data/ext/multi_compress/vendor/.vendored +1 -1
  8. data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
  9. data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
  10. data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
  11. data/lib/multi_compress/active_record.rb +11 -5
  12. data/lib/multi_compress/codec.rb +164 -94
  13. data/lib/multi_compress/database/api.rb +42 -0
  14. data/lib/multi_compress/database/decoding.rb +42 -0
  15. data/lib/multi_compress/database/dictionary.rb +72 -0
  16. data/lib/multi_compress/database/encoding.rb +31 -0
  17. data/lib/multi_compress/database/header.rb +71 -0
  18. data/lib/multi_compress/database/operations.rb +73 -0
  19. data/lib/multi_compress/database/validation.rb +95 -0
  20. data/lib/multi_compress/database.rb +10 -282
  21. data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
  22. data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
  23. data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
  24. data/lib/multi_compress/db_deployment/commands.rb +309 -0
  25. data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
  26. data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
  27. data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
  28. data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
  29. data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
  30. data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
  31. data/lib/multi_compress/db_deployment.rb +10 -844
  32. data/lib/multi_compress/reader/buffer_operations.rb +57 -0
  33. data/lib/multi_compress/reader/read_operations.rb +67 -0
  34. data/lib/multi_compress/reader.rb +97 -0
  35. data/lib/multi_compress/stream_io.rb +13 -0
  36. data/lib/multi_compress/version.rb +1 -1
  37. data/lib/multi_compress/writer.rb +85 -0
  38. data/lib/multi_compress.rb +42 -333
  39. metadata +25 -6
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ class DictionaryRegistry
6
+ include SqlIdentifiers
7
+
8
+ attr_reader :target, :schema, :table, :heads_table, :owner, :migration_role,
9
+ :extension_schema, :payload_table, :payload_column, :payload_dictionary_id_column
10
+ private :target, :schema, :table, :heads_table, :owner, :migration_role,
11
+ :extension_schema, :payload_table, :payload_column, :payload_dictionary_id_column
12
+
13
+ def initialize(target, options)
14
+ @target = target
15
+ load_options(options)
16
+ validate_payload_options!
17
+ rescue KeyError
18
+ raise Error,
19
+ target == "postgres" ? "--schema, --owner and --migration-role are required" : "--database is required"
20
+ end
21
+
22
+ private
23
+
24
+ def load_options(options)
25
+ @schema = identifier!(options.fetch(:schema), target == "postgres" ? "--schema" : "--database")
26
+ @table = identifier!(options.fetch(:table), "--table")
27
+ @heads_table = identifier!(options.fetch(:heads_table), "--heads-table")
28
+ @owner = optional_identifier(options, :owner, "--owner")
29
+ @migration_role = optional_identifier(options, :migration_role, "--migration-role")
30
+ @extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
31
+ load_payload_options(options)
32
+ end
33
+
34
+ def load_payload_options(options)
35
+ @payload_table = optional_identifier(options, :payload_table, "--payload-table")
36
+ @payload_column = optional_identifier(options, :payload_column, "--payload-column")
37
+ @payload_dictionary_id_column = optional_identifier(
38
+ options, :payload_dictionary_id_column, "--payload-dictionary-id-column"
39
+ )
40
+ end
41
+
42
+ def validate_payload_options!
43
+ payload_options = [payload_table, payload_column, payload_dictionary_id_column]
44
+ unless payload_options.all? || payload_options.none?
45
+ raise Error, "--payload-table, --payload-column and --payload-dictionary-id-column must be used together"
46
+ end
47
+ return unless target == "postgres" && (!owner || !migration_role)
48
+
49
+ raise Error, "--owner and --migration-role are required for postgres registry DDL"
50
+ end
51
+
52
+ public
53
+
54
+ def to_sql
55
+ target == "postgres" ? postgres_sql : mysql_sql
56
+ end
57
+
58
+ private
59
+
60
+ def postgres_sql
61
+ q = method(:pg_quote)
62
+ schema = q.call(schema())
63
+ table = q.call(table())
64
+ heads = q.call(heads_table)
65
+ ext = q.call(extension_schema)
66
+ <<~SQL
67
+ -- Generated by multi_compress #{MultiCompress::VERSION}. Apply as a privileged migration/DBA role.
68
+ -- The owner role must be a pre-existing NOLOGIN role. Dictionary bytes are append-only application data.
69
+ CREATE TABLE #{schema}.#{table} (
70
+ id bigint PRIMARY KEY CHECK (id > 0),
71
+ family text NOT NULL,
72
+ zstd_dict_id bigint NOT NULL CHECK (zstd_dict_id > 0),
73
+ sha256 bytea NOT NULL UNIQUE CHECK (octet_length(sha256) = 32),
74
+ bytes bytea NOT NULL CHECK (octet_length(bytes) BETWEEN 1 AND #{MAX_DICTIONARY_BYTES}),
75
+ created_at timestamptz NOT NULL DEFAULT now()
76
+ );
77
+ ALTER TABLE #{schema}.#{table} OWNER TO #{q.call(owner)};
78
+ -- PostgreSQL RI triggers execute the FK lookup under the relation owner.
79
+ -- A NOLOGIN owner therefore still needs schema USAGE for the payload FK.
80
+ GRANT USAGE ON SCHEMA #{schema} TO #{q.call(owner)};
81
+
82
+ CREATE TABLE #{schema}.#{heads} (
83
+ family text PRIMARY KEY,
84
+ dictionary_id bigint NOT NULL REFERENCES #{schema}.#{table}(id) ON DELETE RESTRICT
85
+ );
86
+ ALTER TABLE #{schema}.#{heads} OWNER TO #{q.call(owner)};
87
+
88
+ CREATE FUNCTION #{schema}.mcdb_dictionary_version_validate() RETURNS trigger
89
+ LANGUAGE plpgsql AS $$
90
+ BEGIN
91
+ IF NEW.id <= 0 OR NEW.zstd_dict_id <= 0 OR octet_length(NEW.sha256) <> 32 THEN
92
+ RAISE EXCEPTION 'invalid MCDB2 dictionary metadata';
93
+ END IF;
94
+ IF NEW.sha256 IS DISTINCT FROM #{ext}.multi_compress_db_dictionary_sha256(NEW.bytes) THEN
95
+ RAISE EXCEPTION 'MCDB2 dictionary sha256 does not match bytes';
96
+ END IF;
97
+ IF NEW.zstd_dict_id IS DISTINCT FROM #{ext}.multi_compress_db_dictionary_zstd_id(NEW.bytes) THEN
98
+ RAISE EXCEPTION 'MCDB2 zstd dictionary id does not match bytes';
99
+ END IF;
100
+ RETURN NEW;
101
+ END $$;
102
+ ALTER FUNCTION #{schema}.mcdb_dictionary_version_validate() OWNER TO #{q.call(owner)};
103
+
104
+ CREATE FUNCTION #{schema}.mcdb_dictionary_version_frozen() RETURNS trigger
105
+ LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'MCDB dictionary versions are append-only'; END $$;
106
+ ALTER FUNCTION #{schema}.mcdb_dictionary_version_frozen() OWNER TO #{q.call(owner)};
107
+
108
+ CREATE TRIGGER mcdb_dictionary_version_validate
109
+ BEFORE INSERT ON #{schema}.#{table}
110
+ FOR EACH ROW EXECUTE FUNCTION #{schema}.mcdb_dictionary_version_validate();
111
+ CREATE TRIGGER mcdb_dictionary_version_frozen
112
+ BEFORE UPDATE OR DELETE ON #{schema}.#{table}
113
+ FOR EACH ROW EXECUTE FUNCTION #{schema}.mcdb_dictionary_version_frozen();
114
+
115
+ REVOKE ALL ON #{schema}.#{table}, #{schema}.#{heads} FROM PUBLIC;
116
+ GRANT SELECT, INSERT ON #{schema}.#{table} TO #{q.call(migration_role)};
117
+ GRANT SELECT, INSERT, UPDATE, DELETE ON #{schema}.#{heads} TO #{q.call(migration_role)};
118
+ #{postgres_payload_consistency_sql}
119
+ SQL
120
+ end
121
+
122
+ def mysql_sql
123
+ schema = mysql_quote(schema())
124
+ table = mysql_quote(table())
125
+ heads = mysql_quote(heads_table)
126
+ <<~SQL
127
+ -- Generated by multi_compress #{MultiCompress::VERSION}. MySQL 5.7 registry DDL.
128
+ -- Apply this in the application database. Dictionary versions are append-only.
129
+ USE #{schema};
130
+ CREATE TABLE #{table} (
131
+ id BIGINT UNSIGNED NOT NULL,
132
+ family VARCHAR(191) NOT NULL,
133
+ zstd_dict_id BIGINT UNSIGNED NOT NULL,
134
+ sha256 BINARY(32) NOT NULL,
135
+ bytes LONGBLOB NOT NULL,
136
+ created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
137
+ PRIMARY KEY (id),
138
+ UNIQUE KEY mcdb_dictionary_versions_sha256 (sha256)
139
+ ) ENGINE=InnoDB;
140
+ CREATE TABLE #{heads} (
141
+ family VARCHAR(191) NOT NULL,
142
+ dictionary_id BIGINT UNSIGNED NOT NULL,
143
+ PRIMARY KEY (family),
144
+ CONSTRAINT mcdb_dictionary_heads_dictionary_fk
145
+ FOREIGN KEY (dictionary_id) REFERENCES #{table}(id) ON DELETE RESTRICT
146
+ ) ENGINE=InnoDB;
147
+
148
+ DELIMITER //
149
+ CREATE TRIGGER #{mysql_quote("#{table()}_validate")}
150
+ BEFORE INSERT ON #{table} FOR EACH ROW
151
+ BEGIN
152
+ IF NEW.id = 0 OR NEW.id > 9223372036854775807 OR NEW.zstd_dict_id = 0 OR OCTET_LENGTH(NEW.sha256) <> 32 OR
153
+ OCTET_LENGTH(NEW.bytes) = 0 OR OCTET_LENGTH(NEW.bytes) > #{MAX_DICTIONARY_BYTES} OR
154
+ NOT (NEW.sha256 <=> multi_compress_db_dictionary_sha256(NEW.bytes)) OR
155
+ NOT (NEW.zstd_dict_id <=> multi_compress_db_dictionary_zstd_id(NEW.bytes)) THEN
156
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid MCDB2 dictionary metadata';
157
+ END IF;
158
+ END//
159
+ CREATE TRIGGER #{mysql_quote("#{table()}_frozen_update")}
160
+ BEFORE UPDATE ON #{table} FOR EACH ROW
161
+ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB dictionary versions are append-only'; END//
162
+ CREATE TRIGGER #{mysql_quote("#{table()}_frozen_delete")}
163
+ BEFORE DELETE ON #{table} FOR EACH ROW
164
+ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB dictionary versions are append-only'; END//
165
+ #{mysql_payload_consistency_sql}
166
+ DELIMITER ;
167
+ SQL
168
+ end
169
+
170
+ def payload_consistency?
171
+ !payload_table.nil?
172
+ end
173
+
174
+ def payload_consistency_token
175
+ values = [schema, payload_table, payload_column, payload_dictionary_id_column]
176
+ Digest::SHA256.hexdigest(values.join("\0"))[0, 12]
177
+ end
178
+
179
+ def postgres_payload_consistency_sql
180
+ return "" unless payload_consistency?
181
+
182
+ q = method(:pg_quote)
183
+ token = payload_consistency_token
184
+ function_name = "mcdb_payload_ref_#{token}"
185
+ trigger_name = "mcdb_payload_ref_#{token}_check"
186
+ <<~SQL
187
+
188
+ ALTER TABLE #{q.call(schema)}.#{q.call(payload_table)}
189
+ ADD CONSTRAINT #{q.call("mcdb_payload_dictionary_#{token}_fk")}
190
+ FOREIGN KEY (#{q.call(payload_dictionary_id_column)})
191
+ REFERENCES #{q.call(schema)}.#{q.call(table)}(id) ON DELETE RESTRICT;
192
+
193
+ CREATE FUNCTION #{q.call(schema)}.#{q.call(function_name)}() RETURNS trigger
194
+ LANGUAGE plpgsql AS $$
195
+ BEGIN
196
+ IF NEW.#{q.call(payload_column)} IS NULL OR NEW.#{q.call(payload_dictionary_id_column)} IS NULL OR
197
+ #{q.call(extension_schema)}.multi_compress_db_dictionary_ref(NEW.#{q.call(payload_column)}) IS DISTINCT FROM NEW.#{q.call(payload_dictionary_id_column)} THEN
198
+ RAISE EXCEPTION 'MCDB2 payload dictionary reference must match its dictionary FK';
199
+ END IF;
200
+ RETURN NEW;
201
+ END $$;
202
+ ALTER FUNCTION #{q.call(schema)}.#{q.call(function_name)}() OWNER TO #{q.call(owner)};
203
+ CREATE TRIGGER #{q.call(trigger_name)}
204
+ BEFORE INSERT OR UPDATE OF #{q.call(payload_column)}, #{q.call(payload_dictionary_id_column)}
205
+ ON #{q.call(schema)}.#{q.call(payload_table)}
206
+ FOR EACH ROW EXECUTE FUNCTION #{q.call(schema)}.#{q.call(function_name)}();
207
+ SQL
208
+ end
209
+
210
+ def mysql_payload_consistency_sql
211
+ return "" unless payload_consistency?
212
+
213
+ token = payload_consistency_token
214
+ insert_trigger = mysql_quote("mcdb_payload_ref_#{token}_insert")
215
+ update_trigger = mysql_quote("mcdb_payload_ref_#{token}_update")
216
+ payload_table = mysql_quote(payload_table())
217
+ payload_column = mysql_quote(payload_column())
218
+ payload_ref_column = mysql_quote(payload_dictionary_id_column)
219
+ <<~SQL
220
+
221
+ ALTER TABLE #{payload_table}
222
+ ADD CONSTRAINT #{mysql_quote("mcdb_payload_dictionary_#{token}_fk")}
223
+ FOREIGN KEY (#{payload_ref_column}) REFERENCES #{mysql_quote(table)}(id) ON DELETE RESTRICT;
224
+ CREATE TRIGGER #{insert_trigger}
225
+ BEFORE INSERT ON #{payload_table} FOR EACH ROW
226
+ BEGIN
227
+ IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
228
+ NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
229
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
230
+ END IF;
231
+ END//
232
+ CREATE TRIGGER #{update_trigger}
233
+ BEFORE UPDATE ON #{payload_table} FOR EACH ROW
234
+ BEGIN
235
+ IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
236
+ NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
237
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
238
+ END IF;
239
+ END//
240
+ SQL
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "multi_compress/db_deployment/readable_view_options"
4
+
5
+ module MultiCompress
6
+ module DBDeployment
7
+ class ReadableView
8
+ include SqlIdentifiers
9
+ include ReadableViewOptions
10
+
11
+ attr_reader :target, :table, :view, :column, :columns, :decoded_name, :extension_schema,
12
+ :dictionary_table, :dictionary_id_column, :registry_id_column,
13
+ :dictionary_sha256_column, :dictionary_bytes_column
14
+ private :target, :table, :view, :column, :columns, :decoded_name, :extension_schema,
15
+ :dictionary_table, :dictionary_id_column, :registry_id_column,
16
+ :dictionary_sha256_column, :dictionary_bytes_column
17
+
18
+ def initialize(target, options)
19
+ @target = target
20
+ load_options(options)
21
+ validate_options!
22
+ rescue KeyError
23
+ raise Error, "--table, --column, --view and --columns are required"
24
+ end
25
+
26
+ def to_sql
27
+ case target
28
+ when "postgres" then postgres_sql
29
+ when "mysql" then mysql_sql
30
+ else raise Error, "unsupported database target: #{target.inspect}"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def postgres_sql
37
+ selections = columns.map { |column| " source.#{pg_quote(column)}" }
38
+ selections << " #{postgres_decoder_sql} AS #{pg_quote(decoded_name)}"
39
+
40
+ <<~SQL
41
+ -- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
42
+ -- Query it in DBeaver; do not expose the compressed column or dictionary registry directly.
43
+ CREATE OR REPLACE VIEW #{pg_qualified(view)} AS
44
+ SELECT
45
+ #{selections.join(",\n")}
46
+ FROM #{pg_qualified(table)} AS source#{postgres_dictionary_join};
47
+ SQL
48
+ end
49
+
50
+ def mysql_sql
51
+ selections = columns.map { |column| " source.#{mysql_quote(column)}" }
52
+ selections << " CONVERT(#{mysql_decoder_sql} USING utf8mb4) AS #{mysql_quote(decoded_name)}"
53
+
54
+ <<~SQL
55
+ -- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
56
+ -- Query it in DBeaver; do not expose the compressed column or dictionary registry directly.
57
+ -- MCDB2 requires this view to remain MERGE-able: no DISTINCT/GROUP BY/UNION/aggregate/subquery/LIMIT.
58
+ CREATE OR REPLACE#{mysql_algorithm} VIEW #{mysql_qualified(view)} AS
59
+ SELECT
60
+ #{selections.join(",\n")}
61
+ FROM #{mysql_qualified(table)} AS source#{mysql_dictionary_join};
62
+ SQL
63
+ end
64
+
65
+ def dictionary?
66
+ !dictionary_table.nil?
67
+ end
68
+
69
+ def postgres_decoder_sql
70
+ unless dictionary?
71
+ return "#{pg_quote(extension_schema)}.multi_compress_db_decompress(source.#{pg_quote(column)})"
72
+ end
73
+
74
+ "#{pg_quote(extension_schema)}.multi_compress_db_decompress_dict(" \
75
+ "source.#{pg_quote(column)}, source.#{pg_quote(dictionary_id_column)}, " \
76
+ "dictionary.#{pg_quote(dictionary_sha256_column)}, dictionary.#{pg_quote(dictionary_bytes_column)})"
77
+ end
78
+
79
+ def mysql_decoder_sql
80
+ return "multi_compress_db_decompress(source.#{mysql_quote(column)})" unless dictionary?
81
+
82
+ "multi_compress_db_decompress_dict(" \
83
+ "source.#{mysql_quote(column)}, source.#{mysql_quote(dictionary_id_column)}, " \
84
+ "dictionary.#{mysql_quote(dictionary_sha256_column)}, dictionary.#{mysql_quote(dictionary_bytes_column)})"
85
+ end
86
+
87
+ def postgres_dictionary_join
88
+ return "" unless dictionary?
89
+
90
+ join = "\nJOIN #{pg_qualified(dictionary_table)} AS dictionary"
91
+ join << " ON dictionary.#{pg_quote(registry_id_column)} = source.#{pg_quote(dictionary_id_column)}"
92
+ end
93
+
94
+ def mysql_dictionary_join
95
+ return "" unless dictionary?
96
+
97
+ # MySQL 5.7 may otherwise start from the tiny registry and then examine a
98
+ # broad source set. The generated view always places source on the left, so
99
+ # STRAIGHT_JOIN preserves source-first predicate pushdown before dictionary
100
+ # lookup. An outer ORDER BY can still legitimately report filesort; that is
101
+ # distinct from a materialized view (<derived...> in EXPLAIN).
102
+ join = "\nSTRAIGHT_JOIN #{mysql_qualified(dictionary_table)} AS dictionary"
103
+ join << " ON dictionary.#{mysql_quote(registry_id_column)}"
104
+ join << " = source.#{mysql_quote(dictionary_id_column)}"
105
+ end
106
+
107
+ def mysql_algorithm
108
+ dictionary? ? " ALGORITHM=MERGE SQL SECURITY DEFINER" : ""
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ module ReadableViewOptions
6
+ private
7
+
8
+ def load_options(options)
9
+ load_source_options(options)
10
+ load_dictionary_options(options)
11
+ end
12
+
13
+ def load_source_options(options)
14
+ @table = qualified_identifier!(options.fetch(:table), "--table")
15
+ @view = qualified_identifier!(options.fetch(:view), "--view")
16
+ @column = identifier!(options.fetch(:column), "--column")
17
+ @columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
18
+ @decoded_name = identifier!(options.fetch(:as), "--as")
19
+ @extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
20
+ end
21
+
22
+ def load_dictionary_options(options)
23
+ @dictionary_table = qualified_identifier!(options.fetch(:dictionary_table), "--dictionary-table") if
24
+ options[:dictionary_table]
25
+ @dictionary_id_column = optional_identifier(options, :dictionary_id_column, "--dictionary-id-column")
26
+ @registry_id_column = identifier!(options.fetch(:registry_id_column, "id"), "--registry-id-column")
27
+ @dictionary_sha256_column = identifier!(options.fetch(:dictionary_sha256_column, "sha256"),
28
+ "--dictionary-sha256-column")
29
+ @dictionary_bytes_column = identifier!(options.fetch(:dictionary_bytes_column, "bytes"),
30
+ "--dictionary-bytes-column")
31
+ end
32
+
33
+ def validate_options!
34
+ validate_dictionary_options!
35
+ validate_columns!
36
+ end
37
+
38
+ def validate_dictionary_options!
39
+ return if dictionary_table.nil? == dictionary_id_column.nil?
40
+
41
+ raise Error, "--dictionary-table and --dictionary-id-column must be used together for MCDB2"
42
+ end
43
+
44
+ def validate_columns!
45
+ raise Error, "--columns must contain at least one plain column" if columns.empty?
46
+ raise Error, "--columns must not contain duplicate names" unless columns.uniq.length == columns.length
47
+ raise Error, "--as must not duplicate a selected column" if columns.include?(decoded_name)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MultiCompress
4
+ module DBDeployment
5
+ module SqlIdentifiers
6
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
7
+
8
+ private
9
+
10
+ def identifier!(value, option)
11
+ string = value.to_s
12
+ return string if IDENTIFIER.match?(string)
13
+
14
+ raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit"
15
+ end
16
+
17
+ def optional_identifier(options, key, label)
18
+ identifier!(options.fetch(key), label) if options.key?(key) && options[key]
19
+ end
20
+
21
+ def qualified_identifier!(value, option)
22
+ pieces = value.to_s.split(".", -1)
23
+ return pieces if pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
24
+
25
+ raise Error, "#{option} must be NAME or SCHEMA.NAME using letters, digits and underscores"
26
+ end
27
+
28
+ def pg_quote(identifier)
29
+ %("#{identifier}")
30
+ end
31
+
32
+ def mysql_quote(identifier)
33
+ "`#{identifier}`"
34
+ end
35
+
36
+ def pg_qualified(pieces)
37
+ pieces.map { |piece| pg_quote(piece) }.join(".")
38
+ end
39
+
40
+ def mysql_qualified(pieces)
41
+ pieces.map { |piece| mysql_quote(piece) }.join(".")
42
+ end
43
+ end
44
+ end
45
+ end