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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/GET_STARTED.md +1 -1
- data/README.md +1 -1
- data/THIRD_PARTY_NOTICES.md +1 -1
- data/ext/multi_compress/multi_compress.c +230 -77
- data/ext/multi_compress/vendor/.vendored +1 -1
- data/ext/multi_compress/vendor/brotli/c/common/version.h +2 -2
- data/ext/multi_compress/vendor/brotli/c/dec/decode.c +59 -30
- data/ext/multi_compress/vendor/brotli/c/include/brotli/decode.h +1 -3
- data/lib/multi_compress/active_record.rb +11 -5
- data/lib/multi_compress/codec.rb +164 -94
- data/lib/multi_compress/database/api.rb +42 -0
- data/lib/multi_compress/database/decoding.rb +42 -0
- data/lib/multi_compress/database/dictionary.rb +72 -0
- data/lib/multi_compress/database/encoding.rb +31 -0
- data/lib/multi_compress/database/header.rb +71 -0
- data/lib/multi_compress/database/operations.rb +73 -0
- data/lib/multi_compress/database/validation.rb +95 -0
- data/lib/multi_compress/database.rb +10 -282
- data/lib/multi_compress/db_deployment/bundle_archiver.rb +70 -0
- data/lib/multi_compress/db_deployment/bundle_metadata.rb +56 -0
- data/lib/multi_compress/db_deployment/bundle_stager.rb +62 -0
- data/lib/multi_compress/db_deployment/commands.rb +309 -0
- data/lib/multi_compress/db_deployment/deployment_bundle.rb +80 -0
- data/lib/multi_compress/db_deployment/deterministic_tar_writer.rb +76 -0
- data/lib/multi_compress/db_deployment/dictionary_registry.rb +244 -0
- data/lib/multi_compress/db_deployment/readable_view.rb +112 -0
- data/lib/multi_compress/db_deployment/readable_view_options.rb +51 -0
- data/lib/multi_compress/db_deployment/sql_identifiers.rb +45 -0
- data/lib/multi_compress/db_deployment.rb +10 -844
- data/lib/multi_compress/reader/buffer_operations.rb +57 -0
- data/lib/multi_compress/reader/read_operations.rb +67 -0
- data/lib/multi_compress/reader.rb +97 -0
- data/lib/multi_compress/stream_io.rb +13 -0
- data/lib/multi_compress/version.rb +1 -1
- data/lib/multi_compress/writer.rb +85 -0
- data/lib/multi_compress.rb +42 -333
- 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
|