multi_compress 0.3.5 → 0.6.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/CHANGELOG.md +111 -0
- data/GET_STARTED.md +207 -0
- data/README.md +69 -0
- data/db_core/include/mcdb_format.h +113 -0
- data/db_core/src/mcdb_format.c +594 -0
- data/db_deployment/mysql/Makefile +48 -0
- data/db_deployment/mysql/README.md +61 -0
- data/db_deployment/mysql/bin/common +212 -0
- data/db_deployment/mysql/bin/disable +40 -0
- data/db_deployment/mysql/bin/doctor +64 -0
- data/db_deployment/mysql/bin/enable +61 -0
- data/db_deployment/mysql/bin/install +64 -0
- data/db_deployment/mysql/bin/status +67 -0
- data/db_deployment/mysql/bin/uninstall +47 -0
- data/db_deployment/mysql/bin/upgrade +127 -0
- data/db_deployment/mysql/bin/verify +14 -0
- data/db_deployment/postgres/Makefile +45 -0
- data/db_deployment/postgres/README.md +84 -0
- data/db_deployment/postgres/bin/doctor +61 -0
- data/db_deployment/postgres/bin/enable +138 -0
- data/db_deployment/postgres/bin/install +106 -0
- data/db_deployment/postgres/bin/uninstall +54 -0
- data/db_deployment/postgres/bin/verify +14 -0
- data/docs/database-envelope-v1.md +159 -0
- data/docs/database-envelope-v2.md +147 -0
- data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
- data/exe/multi_compress +228 -0
- data/ext/multi_compress/extconf.rb +4 -0
- data/ext/multi_compress/multi_compress.c +62 -0
- data/lib/multi_compress/active_record.rb +61 -0
- data/lib/multi_compress/codec.rb +164 -0
- data/lib/multi_compress/database.rb +312 -0
- data/lib/multi_compress/db_deployment.rb +868 -0
- data/lib/multi_compress/version.rb +1 -1
- data/multi_compress.gemspec +55 -0
- data/mysql_udf/Makefile +96 -0
- data/mysql_udf/README.md +140 -0
- data/mysql_udf/sql/examples.sql +16 -0
- data/mysql_udf/sql/install.sql +39 -0
- data/mysql_udf/sql/uninstall.sql +10 -0
- data/mysql_udf/sql/views.sql +20 -0
- data/mysql_udf/src/multi_compress_mysql.c +472 -0
- data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
- data/mysql_udf/test/mcdb_cli.c +60 -0
- data/mysql_udf/test/run_e2e.sh +414 -0
- data/postgres_extension/Makefile +74 -0
- data/postgres_extension/README.md +160 -0
- data/postgres_extension/multi_compress.control +4 -0
- data/postgres_extension/multi_compress_pg.exports +24 -0
- data/postgres_extension/sql/examples.sql +23 -0
- data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
- data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
- data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
- data/postgres_extension/sql/uninstall.sql +2 -0
- data/postgres_extension/sql/views.sql +7 -0
- data/postgres_extension/src/mcdb_format.c +2 -0
- data/postgres_extension/src/multi_compress_pg.c +381 -0
- data/postgres_extension/test/run_e2e.sh +411 -0
- metadata +56 -3
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "multi_compress/version"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "pathname"
|
|
9
|
+
require "rubygems/package"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
require "tmpdir"
|
|
12
|
+
require "zlib"
|
|
13
|
+
|
|
14
|
+
module MultiCompress
|
|
15
|
+
module DBDeployment
|
|
16
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
17
|
+
FORMAT = "MCDB1 + MCDB2"
|
|
18
|
+
BUNDLE_FORMAT_VERSION = 2
|
|
19
|
+
MAX_DICTIONARY_BYTES = 256 * 1024
|
|
20
|
+
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
class CLI
|
|
24
|
+
def self.run(argv)
|
|
25
|
+
new.run(argv)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run(argv)
|
|
29
|
+
command = argv.shift
|
|
30
|
+
|
|
31
|
+
case command
|
|
32
|
+
when "package"
|
|
33
|
+
PackageCommand.new.run(argv)
|
|
34
|
+
when "view"
|
|
35
|
+
ViewCommand.new.run(argv)
|
|
36
|
+
when "registry"
|
|
37
|
+
RegistryCommand.new.run(argv)
|
|
38
|
+
when "help", "--help", "-h", nil
|
|
39
|
+
puts help
|
|
40
|
+
0
|
|
41
|
+
else
|
|
42
|
+
raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}"
|
|
43
|
+
end
|
|
44
|
+
rescue OptionParser::ParseError, Error => e
|
|
45
|
+
warn "multi_compress db: #{e.message}"
|
|
46
|
+
2
|
|
47
|
+
rescue SystemCallError, IOError => e
|
|
48
|
+
warn "multi_compress db: #{e.message}"
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def help
|
|
55
|
+
<<~TEXT
|
|
56
|
+
Usage:
|
|
57
|
+
multi_compress db package postgres [--output PATH] [--force]
|
|
58
|
+
multi_compress db package mysql [--output PATH] [--force]
|
|
59
|
+
|
|
60
|
+
multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
|
|
61
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
|
|
62
|
+
[--extension-schema multi_compress] [--output PATH]
|
|
63
|
+
|
|
64
|
+
multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
|
|
65
|
+
--view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
|
|
66
|
+
|
|
67
|
+
`package` creates a self-contained source bundle for the DBA. The DBA
|
|
68
|
+
extracts it on the database host and runs `make doctor`, `sudo make
|
|
69
|
+
install`, then `make enable DB=...`.
|
|
70
|
+
|
|
71
|
+
`view` emits a safe readable-view definition so DBeaver users query
|
|
72
|
+
decoded text instead of the compressed bytea/BLOB column. Supply
|
|
73
|
+
--dictionary-table and --dictionary-id-column for MCDB2.
|
|
74
|
+
|
|
75
|
+
`registry` emits append-only MCDB2 dictionary-registry DDL. Dictionary
|
|
76
|
+
bytes are application data and are never placed in a DBA reader bundle.
|
|
77
|
+
Supplying --payload-table, --payload-column and
|
|
78
|
+
--payload-dictionary-id-column also emits the MCDB2 header/FK
|
|
79
|
+
consistency trigger for that source table.
|
|
80
|
+
TEXT
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class PackageCommand
|
|
85
|
+
TARGETS = %w[postgres mysql].freeze
|
|
86
|
+
|
|
87
|
+
def run(argv)
|
|
88
|
+
target = argv.shift
|
|
89
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
90
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
91
|
+
|
|
92
|
+
options = { output: nil, force: false }
|
|
93
|
+
parser = OptionParser.new do |opts|
|
|
94
|
+
opts.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
|
|
95
|
+
opts.on("-o", "--output PATH", "write the deployment archive to PATH") { |value| options[:output] = value }
|
|
96
|
+
opts.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
|
|
97
|
+
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
98
|
+
end
|
|
99
|
+
parser.parse!(argv)
|
|
100
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
101
|
+
|
|
102
|
+
output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
|
|
103
|
+
DeploymentBundle.new(target, output, force: options[:force]).write!
|
|
104
|
+
puts File.expand_path(output)
|
|
105
|
+
0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class ViewCommand
|
|
110
|
+
TARGETS = %w[postgres mysql].freeze
|
|
111
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
112
|
+
|
|
113
|
+
def run(argv)
|
|
114
|
+
target = argv.shift
|
|
115
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
116
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
117
|
+
|
|
118
|
+
options = {
|
|
119
|
+
columns: [], as: "payload", output: nil, extension_schema: "multi_compress",
|
|
120
|
+
dictionary_table: nil, dictionary_id_column: nil,
|
|
121
|
+
registry_id_column: "id", dictionary_sha256_column: "sha256",
|
|
122
|
+
dictionary_bytes_column: "bytes"
|
|
123
|
+
}
|
|
124
|
+
parser = OptionParser.new do |opts|
|
|
125
|
+
opts.banner = "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN --view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
|
|
126
|
+
opts.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
|
|
127
|
+
opts.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
|
|
128
|
+
opts.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
|
|
129
|
+
opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip) }
|
|
130
|
+
opts.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
|
|
131
|
+
opts.on("--dictionary-table NAME", "MCDB2 immutable dictionary registry table") { |value| options[:dictionary_table] = value }
|
|
132
|
+
opts.on("--dictionary-id-column NAME", "MCDB2 FK column on source table") { |value| options[:dictionary_id_column] = value }
|
|
133
|
+
opts.on("--registry-id-column NAME", "registry PK (default: id)") { |value| options[:registry_id_column] = value }
|
|
134
|
+
opts.on("--dictionary-sha256-column NAME", "registry SHA-256 column (default: sha256)") { |value| options[:dictionary_sha256_column] = value }
|
|
135
|
+
opts.on("--dictionary-bytes-column NAME", "registry dictionary bytes column (default: bytes)") { |value| options[:dictionary_bytes_column] = value }
|
|
136
|
+
if target == "postgres"
|
|
137
|
+
opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
|
|
138
|
+
end
|
|
139
|
+
opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
140
|
+
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
141
|
+
end
|
|
142
|
+
parser.parse!(argv)
|
|
143
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
144
|
+
|
|
145
|
+
sql = ReadableView.new(target, options).to_sql
|
|
146
|
+
if options[:output]
|
|
147
|
+
write_text_atomically(options[:output], sql)
|
|
148
|
+
puts File.expand_path(options[:output])
|
|
149
|
+
else
|
|
150
|
+
$stdout.write(sql)
|
|
151
|
+
end
|
|
152
|
+
0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def write_text_atomically(path, contents)
|
|
158
|
+
directory = File.dirname(File.expand_path(path))
|
|
159
|
+
FileUtils.mkdir_p(directory)
|
|
160
|
+
temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
|
|
161
|
+
File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
|
|
162
|
+
File.rename(temporary, path)
|
|
163
|
+
ensure
|
|
164
|
+
File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
class RegistryCommand
|
|
169
|
+
TARGETS = %w[postgres mysql].freeze
|
|
170
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
171
|
+
|
|
172
|
+
def run(argv)
|
|
173
|
+
target = argv.shift
|
|
174
|
+
raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
|
|
175
|
+
raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
|
|
176
|
+
|
|
177
|
+
options = { output: nil, extension_schema: "multi_compress", table: "mcdb_dictionary_versions", heads_table: "mcdb_dictionary_heads" }
|
|
178
|
+
parser = OptionParser.new do |opts|
|
|
179
|
+
opts.banner = "Usage: multi_compress db registry #{target} [OPTIONS]"
|
|
180
|
+
if target == "postgres"
|
|
181
|
+
opts.on("--schema NAME", "application schema") { |value| options[:schema] = value }
|
|
182
|
+
opts.on("--owner ROLE", "NOLOGIN owner for append-only dictionary versions") { |value| options[:owner] = value }
|
|
183
|
+
opts.on("--migration-role ROLE", "role allowed to insert dictionary versions") { |value| options[:migration_role] = value }
|
|
184
|
+
opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
|
|
185
|
+
else
|
|
186
|
+
opts.on("--database NAME", "application database") { |value| options[:schema] = value }
|
|
187
|
+
end
|
|
188
|
+
opts.on("--table NAME", "versions table (default: mcdb_dictionary_versions)") { |value| options[:table] = value }
|
|
189
|
+
opts.on("--heads-table NAME", "heads table (default: mcdb_dictionary_heads)") { |value| options[:heads_table] = value }
|
|
190
|
+
opts.on("--payload-table NAME", "MCDB2 source table in this schema/database") { |value| options[:payload_table] = value }
|
|
191
|
+
opts.on("--payload-column NAME", "MCDB2 compressed envelope column on --payload-table") { |value| options[:payload_column] = value }
|
|
192
|
+
opts.on("--payload-dictionary-id-column NAME", "MCDB2 dictionary FK column on --payload-table") { |value| options[:payload_dictionary_id_column] = value }
|
|
193
|
+
opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
|
|
194
|
+
opts.on("-h", "--help", "show this help") { puts opts; return 0 }
|
|
195
|
+
end
|
|
196
|
+
parser.parse!(argv)
|
|
197
|
+
raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
|
|
198
|
+
sql = DictionaryRegistry.new(target, options).to_sql
|
|
199
|
+
if options[:output]
|
|
200
|
+
ViewCommand.new.send(:write_text_atomically, options[:output], sql)
|
|
201
|
+
puts File.expand_path(options[:output])
|
|
202
|
+
else
|
|
203
|
+
$stdout.write(sql)
|
|
204
|
+
end
|
|
205
|
+
0
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
class DictionaryRegistry
|
|
210
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
211
|
+
|
|
212
|
+
def initialize(target, options)
|
|
213
|
+
@target = target
|
|
214
|
+
@schema = identifier!(options.fetch(:schema), target == "postgres" ? "--schema" : "--database")
|
|
215
|
+
@table = identifier!(options.fetch(:table), "--table")
|
|
216
|
+
@heads_table = identifier!(options.fetch(:heads_table), "--heads-table")
|
|
217
|
+
@owner = options[:owner] && identifier!(options[:owner], "--owner")
|
|
218
|
+
@migration_role = options[:migration_role] && identifier!(options[:migration_role], "--migration-role")
|
|
219
|
+
@extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
|
|
220
|
+
@payload_table = options[:payload_table] && identifier!(options[:payload_table], "--payload-table")
|
|
221
|
+
@payload_column = options[:payload_column] && identifier!(options[:payload_column], "--payload-column")
|
|
222
|
+
@payload_dictionary_id_column = options[:payload_dictionary_id_column] && identifier!(options[:payload_dictionary_id_column], "--payload-dictionary-id-column")
|
|
223
|
+
payload_options = [@payload_table, @payload_column, @payload_dictionary_id_column]
|
|
224
|
+
unless payload_options.all? || payload_options.none?
|
|
225
|
+
raise Error, "--payload-table, --payload-column and --payload-dictionary-id-column must be used together"
|
|
226
|
+
end
|
|
227
|
+
if @target == "postgres" && (!@owner || !@migration_role)
|
|
228
|
+
raise Error, "--owner and --migration-role are required for postgres registry DDL"
|
|
229
|
+
end
|
|
230
|
+
rescue KeyError
|
|
231
|
+
raise Error, @target == "postgres" ? "--schema, --owner and --migration-role are required" : "--database is required"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def to_sql
|
|
235
|
+
@target == "postgres" ? postgres_sql : mysql_sql
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
def postgres_sql
|
|
241
|
+
q = method(:pg_quote)
|
|
242
|
+
schema = q.call(@schema)
|
|
243
|
+
table = q.call(@table)
|
|
244
|
+
heads = q.call(@heads_table)
|
|
245
|
+
ext = q.call(@extension_schema)
|
|
246
|
+
<<~SQL
|
|
247
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. Apply as a privileged migration/DBA role.
|
|
248
|
+
-- The owner role must be a pre-existing NOLOGIN role. Dictionary bytes are append-only application data.
|
|
249
|
+
CREATE TABLE #{schema}.#{table} (
|
|
250
|
+
id bigint PRIMARY KEY CHECK (id > 0),
|
|
251
|
+
family text NOT NULL,
|
|
252
|
+
zstd_dict_id bigint NOT NULL CHECK (zstd_dict_id > 0),
|
|
253
|
+
sha256 bytea NOT NULL UNIQUE CHECK (octet_length(sha256) = 32),
|
|
254
|
+
bytes bytea NOT NULL CHECK (octet_length(bytes) BETWEEN 1 AND #{MAX_DICTIONARY_BYTES}),
|
|
255
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
256
|
+
);
|
|
257
|
+
ALTER TABLE #{schema}.#{table} OWNER TO #{q.call(@owner)};
|
|
258
|
+
-- PostgreSQL RI triggers execute the FK lookup under the relation owner.
|
|
259
|
+
-- A NOLOGIN owner therefore still needs schema USAGE for the payload FK.
|
|
260
|
+
GRANT USAGE ON SCHEMA #{schema} TO #{q.call(@owner)};
|
|
261
|
+
|
|
262
|
+
CREATE TABLE #{schema}.#{heads} (
|
|
263
|
+
family text PRIMARY KEY,
|
|
264
|
+
dictionary_id bigint NOT NULL REFERENCES #{schema}.#{table}(id) ON DELETE RESTRICT
|
|
265
|
+
);
|
|
266
|
+
ALTER TABLE #{schema}.#{heads} OWNER TO #{q.call(@owner)};
|
|
267
|
+
|
|
268
|
+
CREATE FUNCTION #{schema}.mcdb_dictionary_version_validate() RETURNS trigger
|
|
269
|
+
LANGUAGE plpgsql AS $$
|
|
270
|
+
BEGIN
|
|
271
|
+
IF NEW.id <= 0 OR NEW.zstd_dict_id <= 0 OR octet_length(NEW.sha256) <> 32 THEN
|
|
272
|
+
RAISE EXCEPTION 'invalid MCDB2 dictionary metadata';
|
|
273
|
+
END IF;
|
|
274
|
+
IF NEW.sha256 IS DISTINCT FROM #{ext}.multi_compress_db_dictionary_sha256(NEW.bytes) THEN
|
|
275
|
+
RAISE EXCEPTION 'MCDB2 dictionary sha256 does not match bytes';
|
|
276
|
+
END IF;
|
|
277
|
+
IF NEW.zstd_dict_id IS DISTINCT FROM #{ext}.multi_compress_db_dictionary_zstd_id(NEW.bytes) THEN
|
|
278
|
+
RAISE EXCEPTION 'MCDB2 zstd dictionary id does not match bytes';
|
|
279
|
+
END IF;
|
|
280
|
+
RETURN NEW;
|
|
281
|
+
END $$;
|
|
282
|
+
ALTER FUNCTION #{schema}.mcdb_dictionary_version_validate() OWNER TO #{q.call(@owner)};
|
|
283
|
+
|
|
284
|
+
CREATE FUNCTION #{schema}.mcdb_dictionary_version_frozen() RETURNS trigger
|
|
285
|
+
LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'MCDB dictionary versions are append-only'; END $$;
|
|
286
|
+
ALTER FUNCTION #{schema}.mcdb_dictionary_version_frozen() OWNER TO #{q.call(@owner)};
|
|
287
|
+
|
|
288
|
+
CREATE TRIGGER mcdb_dictionary_version_validate
|
|
289
|
+
BEFORE INSERT ON #{schema}.#{table}
|
|
290
|
+
FOR EACH ROW EXECUTE FUNCTION #{schema}.mcdb_dictionary_version_validate();
|
|
291
|
+
CREATE TRIGGER mcdb_dictionary_version_frozen
|
|
292
|
+
BEFORE UPDATE OR DELETE ON #{schema}.#{table}
|
|
293
|
+
FOR EACH ROW EXECUTE FUNCTION #{schema}.mcdb_dictionary_version_frozen();
|
|
294
|
+
|
|
295
|
+
REVOKE ALL ON #{schema}.#{table}, #{schema}.#{heads} FROM PUBLIC;
|
|
296
|
+
GRANT SELECT, INSERT ON #{schema}.#{table} TO #{q.call(@migration_role)};
|
|
297
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON #{schema}.#{heads} TO #{q.call(@migration_role)};
|
|
298
|
+
#{postgres_payload_consistency_sql}
|
|
299
|
+
SQL
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def mysql_sql
|
|
303
|
+
schema = mysql_quote(@schema)
|
|
304
|
+
table = mysql_quote(@table)
|
|
305
|
+
heads = mysql_quote(@heads_table)
|
|
306
|
+
<<~SQL
|
|
307
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. MySQL 5.7 registry DDL.
|
|
308
|
+
-- Apply this in the application database. Dictionary versions are append-only.
|
|
309
|
+
USE #{schema};
|
|
310
|
+
CREATE TABLE #{table} (
|
|
311
|
+
id BIGINT UNSIGNED NOT NULL,
|
|
312
|
+
family VARCHAR(191) NOT NULL,
|
|
313
|
+
zstd_dict_id BIGINT UNSIGNED NOT NULL,
|
|
314
|
+
sha256 BINARY(32) NOT NULL,
|
|
315
|
+
bytes LONGBLOB NOT NULL,
|
|
316
|
+
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
317
|
+
PRIMARY KEY (id),
|
|
318
|
+
UNIQUE KEY mcdb_dictionary_versions_sha256 (sha256)
|
|
319
|
+
) ENGINE=InnoDB;
|
|
320
|
+
CREATE TABLE #{heads} (
|
|
321
|
+
family VARCHAR(191) NOT NULL,
|
|
322
|
+
dictionary_id BIGINT UNSIGNED NOT NULL,
|
|
323
|
+
PRIMARY KEY (family),
|
|
324
|
+
CONSTRAINT mcdb_dictionary_heads_dictionary_fk
|
|
325
|
+
FOREIGN KEY (dictionary_id) REFERENCES #{table}(id) ON DELETE RESTRICT
|
|
326
|
+
) ENGINE=InnoDB;
|
|
327
|
+
|
|
328
|
+
DELIMITER //
|
|
329
|
+
CREATE TRIGGER #{mysql_quote("#{@table}_validate")}
|
|
330
|
+
BEFORE INSERT ON #{table} FOR EACH ROW
|
|
331
|
+
BEGIN
|
|
332
|
+
IF NEW.id = 0 OR NEW.id > 9223372036854775807 OR NEW.zstd_dict_id = 0 OR OCTET_LENGTH(NEW.sha256) <> 32 OR
|
|
333
|
+
OCTET_LENGTH(NEW.bytes) = 0 OR OCTET_LENGTH(NEW.bytes) > #{MAX_DICTIONARY_BYTES} OR
|
|
334
|
+
NOT (NEW.sha256 <=> multi_compress_db_dictionary_sha256(NEW.bytes)) OR
|
|
335
|
+
NOT (NEW.zstd_dict_id <=> multi_compress_db_dictionary_zstd_id(NEW.bytes)) THEN
|
|
336
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid MCDB2 dictionary metadata';
|
|
337
|
+
END IF;
|
|
338
|
+
END//
|
|
339
|
+
CREATE TRIGGER #{mysql_quote("#{@table}_frozen_update")}
|
|
340
|
+
BEFORE UPDATE ON #{table} FOR EACH ROW
|
|
341
|
+
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB dictionary versions are append-only'; END//
|
|
342
|
+
CREATE TRIGGER #{mysql_quote("#{@table}_frozen_delete")}
|
|
343
|
+
BEFORE DELETE ON #{table} FOR EACH ROW
|
|
344
|
+
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB dictionary versions are append-only'; END//
|
|
345
|
+
#{mysql_payload_consistency_sql}
|
|
346
|
+
DELIMITER ;
|
|
347
|
+
SQL
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def payload_consistency?
|
|
351
|
+
!@payload_table.nil?
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def payload_consistency_token
|
|
355
|
+
Digest::SHA256.hexdigest([@schema, @payload_table, @payload_column, @payload_dictionary_id_column].join("\0"))[0, 12]
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def postgres_payload_consistency_sql
|
|
359
|
+
return "" unless payload_consistency?
|
|
360
|
+
|
|
361
|
+
q = method(:pg_quote)
|
|
362
|
+
token = payload_consistency_token
|
|
363
|
+
function_name = "mcdb_payload_ref_#{token}"
|
|
364
|
+
trigger_name = "mcdb_payload_ref_#{token}_check"
|
|
365
|
+
<<~SQL
|
|
366
|
+
|
|
367
|
+
ALTER TABLE #{q.call(@schema)}.#{q.call(@payload_table)}
|
|
368
|
+
ADD CONSTRAINT #{q.call("mcdb_payload_dictionary_#{token}_fk")}
|
|
369
|
+
FOREIGN KEY (#{q.call(@payload_dictionary_id_column)})
|
|
370
|
+
REFERENCES #{q.call(@schema)}.#{q.call(@table)}(id) ON DELETE RESTRICT;
|
|
371
|
+
|
|
372
|
+
CREATE FUNCTION #{q.call(@schema)}.#{q.call(function_name)}() RETURNS trigger
|
|
373
|
+
LANGUAGE plpgsql AS $$
|
|
374
|
+
BEGIN
|
|
375
|
+
IF NEW.#{q.call(@payload_column)} IS NULL OR NEW.#{q.call(@payload_dictionary_id_column)} IS NULL OR
|
|
376
|
+
#{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
|
|
377
|
+
RAISE EXCEPTION 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
378
|
+
END IF;
|
|
379
|
+
RETURN NEW;
|
|
380
|
+
END $$;
|
|
381
|
+
ALTER FUNCTION #{q.call(@schema)}.#{q.call(function_name)}() OWNER TO #{q.call(@owner)};
|
|
382
|
+
CREATE TRIGGER #{q.call(trigger_name)}
|
|
383
|
+
BEFORE INSERT OR UPDATE OF #{q.call(@payload_column)}, #{q.call(@payload_dictionary_id_column)}
|
|
384
|
+
ON #{q.call(@schema)}.#{q.call(@payload_table)}
|
|
385
|
+
FOR EACH ROW EXECUTE FUNCTION #{q.call(@schema)}.#{q.call(function_name)}();
|
|
386
|
+
SQL
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def mysql_payload_consistency_sql
|
|
390
|
+
return "" unless payload_consistency?
|
|
391
|
+
|
|
392
|
+
token = payload_consistency_token
|
|
393
|
+
insert_trigger = mysql_quote("mcdb_payload_ref_#{token}_insert")
|
|
394
|
+
update_trigger = mysql_quote("mcdb_payload_ref_#{token}_update")
|
|
395
|
+
payload_table = mysql_quote(@payload_table)
|
|
396
|
+
payload_column = mysql_quote(@payload_column)
|
|
397
|
+
payload_ref_column = mysql_quote(@payload_dictionary_id_column)
|
|
398
|
+
<<~SQL
|
|
399
|
+
|
|
400
|
+
ALTER TABLE #{payload_table}
|
|
401
|
+
ADD CONSTRAINT #{mysql_quote("mcdb_payload_dictionary_#{token}_fk")}
|
|
402
|
+
FOREIGN KEY (#{payload_ref_column}) REFERENCES #{mysql_quote(@table)}(id) ON DELETE RESTRICT;
|
|
403
|
+
CREATE TRIGGER #{insert_trigger}
|
|
404
|
+
BEFORE INSERT ON #{payload_table} FOR EACH ROW
|
|
405
|
+
BEGIN
|
|
406
|
+
IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
|
|
407
|
+
NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
|
|
408
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
409
|
+
END IF;
|
|
410
|
+
END//
|
|
411
|
+
CREATE TRIGGER #{update_trigger}
|
|
412
|
+
BEFORE UPDATE ON #{payload_table} FOR EACH ROW
|
|
413
|
+
BEGIN
|
|
414
|
+
IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
|
|
415
|
+
NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
|
|
416
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
417
|
+
END IF;
|
|
418
|
+
END//
|
|
419
|
+
SQL
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def identifier!(value, option)
|
|
423
|
+
string = value.to_s
|
|
424
|
+
raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
|
|
425
|
+
string
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def pg_quote(identifier)
|
|
429
|
+
%Q("#{identifier}")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def mysql_quote(identifier)
|
|
433
|
+
"`#{identifier}`"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
class ReadableView
|
|
438
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
|
|
439
|
+
|
|
440
|
+
def initialize(target, options)
|
|
441
|
+
@target = target
|
|
442
|
+
@table = qualified_identifier!(options.fetch(:table), "--table")
|
|
443
|
+
@view = qualified_identifier!(options.fetch(:view), "--view")
|
|
444
|
+
@column = identifier!(options.fetch(:column), "--column")
|
|
445
|
+
@columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
|
|
446
|
+
@decoded_name = identifier!(options.fetch(:as), "--as")
|
|
447
|
+
@extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
|
|
448
|
+
@dictionary_table = options[:dictionary_table] && qualified_identifier!(options[:dictionary_table], "--dictionary-table")
|
|
449
|
+
@dictionary_id_column = options[:dictionary_id_column] && identifier!(options[:dictionary_id_column], "--dictionary-id-column")
|
|
450
|
+
@registry_id_column = identifier!(options.fetch(:registry_id_column, "id"), "--registry-id-column")
|
|
451
|
+
@dictionary_sha256_column = identifier!(options.fetch(:dictionary_sha256_column, "sha256"), "--dictionary-sha256-column")
|
|
452
|
+
@dictionary_bytes_column = identifier!(options.fetch(:dictionary_bytes_column, "bytes"), "--dictionary-bytes-column")
|
|
453
|
+
if !!@dictionary_table != !!@dictionary_id_column
|
|
454
|
+
raise Error, "--dictionary-table and --dictionary-id-column must be used together for MCDB2"
|
|
455
|
+
end
|
|
456
|
+
raise Error, "--columns must contain at least one plain column" if @columns.empty?
|
|
457
|
+
raise Error, "--columns must not contain duplicate names" unless @columns.uniq.length == @columns.length
|
|
458
|
+
raise Error, "--as must not duplicate a selected column" if @columns.include?(@decoded_name)
|
|
459
|
+
rescue KeyError
|
|
460
|
+
raise Error, "--table, --column, --view and --columns are required"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def to_sql
|
|
464
|
+
case @target
|
|
465
|
+
when "postgres" then postgres_sql
|
|
466
|
+
when "mysql" then mysql_sql
|
|
467
|
+
else raise Error, "unsupported database target: #{@target.inspect}"
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
private
|
|
472
|
+
|
|
473
|
+
def postgres_sql
|
|
474
|
+
selections = @columns.map { |column| " source.#{pg_quote(column)}" }
|
|
475
|
+
selections << " #{postgres_decoder_sql} AS #{pg_quote(@decoded_name)}"
|
|
476
|
+
|
|
477
|
+
<<~SQL
|
|
478
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
479
|
+
-- Query it in DBeaver; do not expose the compressed column or dictionary registry directly.
|
|
480
|
+
CREATE OR REPLACE VIEW #{pg_qualified(@view)} AS
|
|
481
|
+
SELECT
|
|
482
|
+
#{selections.join(",\n")}
|
|
483
|
+
FROM #{pg_qualified(@table)} AS source#{postgres_dictionary_join};
|
|
484
|
+
SQL
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def mysql_sql
|
|
488
|
+
selections = @columns.map { |column| " source.#{mysql_quote(column)}" }
|
|
489
|
+
selections << " CONVERT(#{mysql_decoder_sql} USING utf8mb4) AS #{mysql_quote(@decoded_name)}"
|
|
490
|
+
algorithm = dictionary? ? " ALGORITHM=MERGE SQL SECURITY DEFINER" : ""
|
|
491
|
+
|
|
492
|
+
<<~SQL
|
|
493
|
+
-- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
|
|
494
|
+
-- Query it in DBeaver; do not expose the compressed column or dictionary registry directly.
|
|
495
|
+
-- MCDB2 requires this view to remain MERGE-able: no DISTINCT/GROUP BY/UNION/aggregate/subquery/LIMIT.
|
|
496
|
+
CREATE OR REPLACE#{algorithm} VIEW #{mysql_qualified(@view)} AS
|
|
497
|
+
SELECT
|
|
498
|
+
#{selections.join(",\n")}
|
|
499
|
+
FROM #{mysql_qualified(@table)} AS source#{mysql_dictionary_join};
|
|
500
|
+
SQL
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def dictionary?
|
|
504
|
+
!@dictionary_table.nil?
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def postgres_decoder_sql
|
|
508
|
+
return "#{pg_quote(@extension_schema)}.multi_compress_db_decompress(source.#{pg_quote(@column)})" unless dictionary?
|
|
509
|
+
|
|
510
|
+
"#{pg_quote(@extension_schema)}.multi_compress_db_decompress_dict(" \
|
|
511
|
+
"source.#{pg_quote(@column)}, source.#{pg_quote(@dictionary_id_column)}, " \
|
|
512
|
+
"dictionary.#{pg_quote(@dictionary_sha256_column)}, dictionary.#{pg_quote(@dictionary_bytes_column)})"
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def mysql_decoder_sql
|
|
516
|
+
return "multi_compress_db_decompress(source.#{mysql_quote(@column)})" unless dictionary?
|
|
517
|
+
|
|
518
|
+
"multi_compress_db_decompress_dict(" \
|
|
519
|
+
"source.#{mysql_quote(@column)}, source.#{mysql_quote(@dictionary_id_column)}, " \
|
|
520
|
+
"dictionary.#{mysql_quote(@dictionary_sha256_column)}, dictionary.#{mysql_quote(@dictionary_bytes_column)})"
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def postgres_dictionary_join
|
|
524
|
+
return "" unless dictionary?
|
|
525
|
+
|
|
526
|
+
"\nJOIN #{pg_qualified(@dictionary_table)} AS dictionary ON dictionary.#{pg_quote(@registry_id_column)} = source.#{pg_quote(@dictionary_id_column)}"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def mysql_dictionary_join
|
|
530
|
+
return "" unless dictionary?
|
|
531
|
+
|
|
532
|
+
# MySQL 5.7 may otherwise start from the tiny registry and then examine a
|
|
533
|
+
# broad source set. The generated view always places source on the left, so
|
|
534
|
+
# STRAIGHT_JOIN preserves source-first predicate pushdown before dictionary
|
|
535
|
+
# lookup. An outer ORDER BY can still legitimately report filesort; that is
|
|
536
|
+
# distinct from a materialized view (<derived...> in EXPLAIN).
|
|
537
|
+
"\nSTRAIGHT_JOIN #{mysql_qualified(@dictionary_table)} AS dictionary ON dictionary.#{mysql_quote(@registry_id_column)} = source.#{mysql_quote(@dictionary_id_column)}"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def qualified_identifier!(value, option)
|
|
541
|
+
pieces = value.to_s.split(".", -1)
|
|
542
|
+
unless pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
|
|
543
|
+
raise Error, "#{option} must be NAME or SCHEMA.NAME using letters, digits and underscores"
|
|
544
|
+
end
|
|
545
|
+
pieces
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def payload_consistency?
|
|
549
|
+
!@payload_table.nil?
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def payload_consistency_token
|
|
553
|
+
Digest::SHA256.hexdigest([@schema, @payload_table, @payload_column, @payload_dictionary_id_column].join("\0"))[0, 12]
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def postgres_payload_consistency_sql
|
|
557
|
+
return "" unless payload_consistency?
|
|
558
|
+
|
|
559
|
+
q = method(:pg_quote)
|
|
560
|
+
token = payload_consistency_token
|
|
561
|
+
function_name = "mcdb_payload_ref_#{token}"
|
|
562
|
+
trigger_name = "mcdb_payload_ref_#{token}_check"
|
|
563
|
+
<<~SQL
|
|
564
|
+
|
|
565
|
+
ALTER TABLE #{q.call(@schema)}.#{q.call(@payload_table)}
|
|
566
|
+
ADD CONSTRAINT #{q.call("mcdb_payload_dictionary_#{token}_fk")}
|
|
567
|
+
FOREIGN KEY (#{q.call(@payload_dictionary_id_column)})
|
|
568
|
+
REFERENCES #{q.call(@schema)}.#{q.call(@table)}(id) ON DELETE RESTRICT;
|
|
569
|
+
|
|
570
|
+
CREATE FUNCTION #{q.call(@schema)}.#{q.call(function_name)}() RETURNS trigger
|
|
571
|
+
LANGUAGE plpgsql AS $$
|
|
572
|
+
BEGIN
|
|
573
|
+
IF NEW.#{q.call(@payload_column)} IS NULL OR NEW.#{q.call(@payload_dictionary_id_column)} IS NULL OR
|
|
574
|
+
#{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
|
|
575
|
+
RAISE EXCEPTION 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
576
|
+
END IF;
|
|
577
|
+
RETURN NEW;
|
|
578
|
+
END $$;
|
|
579
|
+
ALTER FUNCTION #{q.call(@schema)}.#{q.call(function_name)}() OWNER TO #{q.call(@owner)};
|
|
580
|
+
CREATE TRIGGER #{q.call(trigger_name)}
|
|
581
|
+
BEFORE INSERT OR UPDATE OF #{q.call(@payload_column)}, #{q.call(@payload_dictionary_id_column)}
|
|
582
|
+
ON #{q.call(@schema)}.#{q.call(@payload_table)}
|
|
583
|
+
FOR EACH ROW EXECUTE FUNCTION #{q.call(@schema)}.#{q.call(function_name)}();
|
|
584
|
+
SQL
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def mysql_payload_consistency_sql
|
|
588
|
+
return "" unless payload_consistency?
|
|
589
|
+
|
|
590
|
+
token = payload_consistency_token
|
|
591
|
+
insert_trigger = mysql_quote("mcdb_payload_ref_#{token}_insert")
|
|
592
|
+
update_trigger = mysql_quote("mcdb_payload_ref_#{token}_update")
|
|
593
|
+
payload_table = mysql_quote(@payload_table)
|
|
594
|
+
payload_column = mysql_quote(@payload_column)
|
|
595
|
+
payload_ref_column = mysql_quote(@payload_dictionary_id_column)
|
|
596
|
+
<<~SQL
|
|
597
|
+
|
|
598
|
+
ALTER TABLE #{payload_table}
|
|
599
|
+
ADD CONSTRAINT #{mysql_quote("mcdb_payload_dictionary_#{token}_fk")}
|
|
600
|
+
FOREIGN KEY (#{payload_ref_column}) REFERENCES #{mysql_quote(@table)}(id) ON DELETE RESTRICT;
|
|
601
|
+
CREATE TRIGGER #{insert_trigger}
|
|
602
|
+
BEFORE INSERT ON #{payload_table} FOR EACH ROW
|
|
603
|
+
BEGIN
|
|
604
|
+
IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
|
|
605
|
+
NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
|
|
606
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
607
|
+
END IF;
|
|
608
|
+
END//
|
|
609
|
+
CREATE TRIGGER #{update_trigger}
|
|
610
|
+
BEFORE UPDATE ON #{payload_table} FOR EACH ROW
|
|
611
|
+
BEGIN
|
|
612
|
+
IF NEW.#{payload_column} IS NULL OR NEW.#{payload_ref_column} IS NULL OR
|
|
613
|
+
NOT (multi_compress_db_dictionary_ref(NEW.#{payload_column}) <=> NEW.#{payload_ref_column}) THEN
|
|
614
|
+
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MCDB2 payload dictionary reference must match its dictionary FK';
|
|
615
|
+
END IF;
|
|
616
|
+
END//
|
|
617
|
+
SQL
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def identifier!(value, option)
|
|
621
|
+
string = value.to_s
|
|
622
|
+
raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
|
|
623
|
+
|
|
624
|
+
string
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def pg_quote(identifier)
|
|
628
|
+
%Q("#{identifier}")
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def pg_qualified(pieces)
|
|
632
|
+
pieces.map { |piece| pg_quote(piece) }.join(".")
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def mysql_quote(identifier)
|
|
636
|
+
"`#{identifier}`"
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def mysql_qualified(pieces)
|
|
640
|
+
pieces.map { |piece| mysql_quote(piece) }.join(".")
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
class DeploymentBundle
|
|
645
|
+
DEFAULT_ARCHIVE_EPOCH = 315_619_200
|
|
646
|
+
|
|
647
|
+
class DeterministicTarWriter < Gem::Package::TarWriter
|
|
648
|
+
def self.new(io, epoch)
|
|
649
|
+
writer = allocate
|
|
650
|
+
writer.send(:initialize, io, epoch)
|
|
651
|
+
return writer unless block_given?
|
|
652
|
+
|
|
653
|
+
begin
|
|
654
|
+
yield writer
|
|
655
|
+
ensure
|
|
656
|
+
writer.close
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
nil
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def initialize(io, epoch)
|
|
663
|
+
@archive_epoch = epoch
|
|
664
|
+
super(io)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def add_file_simple(name, mode, size)
|
|
668
|
+
check_closed
|
|
669
|
+
|
|
670
|
+
name, prefix = split_name(name)
|
|
671
|
+
header = Gem::Package::TarHeader.new(
|
|
672
|
+
name: name,
|
|
673
|
+
mode: mode,
|
|
674
|
+
size: size,
|
|
675
|
+
prefix: prefix,
|
|
676
|
+
mtime: @archive_epoch
|
|
677
|
+
).to_s
|
|
678
|
+
@io.write(header)
|
|
679
|
+
output = BoundedStream.new(@io, size)
|
|
680
|
+
yield output if block_given?
|
|
681
|
+
@io.write("\0" * (size - output.written))
|
|
682
|
+
@io.write("\0" * ((512 - (size % 512)) % 512))
|
|
683
|
+
self
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def mkdir(name, mode)
|
|
687
|
+
check_closed
|
|
688
|
+
|
|
689
|
+
name, prefix = split_name(name)
|
|
690
|
+
header = Gem::Package::TarHeader.new(
|
|
691
|
+
name: name,
|
|
692
|
+
mode: mode,
|
|
693
|
+
typeflag: "5",
|
|
694
|
+
size: 0,
|
|
695
|
+
prefix: prefix,
|
|
696
|
+
mtime: @archive_epoch
|
|
697
|
+
)
|
|
698
|
+
@io.write(header)
|
|
699
|
+
self
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
TARGETS = {
|
|
704
|
+
"postgres" => {
|
|
705
|
+
source_paths: %w[
|
|
706
|
+
db_core
|
|
707
|
+
postgres_extension
|
|
708
|
+
ext/multi_compress/vendor/zstd
|
|
709
|
+
docs/database-envelope-v1.md
|
|
710
|
+
docs/database-envelope-v2.md
|
|
711
|
+
docs/rfcs/0002-mcdb2-dictionaries.md
|
|
712
|
+
LICENSE.txt
|
|
713
|
+
THIRD_PARTY_NOTICES.md
|
|
714
|
+
],
|
|
715
|
+
template_path: "db_deployment/postgres"
|
|
716
|
+
},
|
|
717
|
+
"mysql" => {
|
|
718
|
+
source_paths: %w[
|
|
719
|
+
db_core
|
|
720
|
+
mysql_udf
|
|
721
|
+
ext/multi_compress/vendor/zstd
|
|
722
|
+
docs/database-envelope-v1.md
|
|
723
|
+
docs/database-envelope-v2.md
|
|
724
|
+
docs/rfcs/0002-mcdb2-dictionaries.md
|
|
725
|
+
LICENSE.txt
|
|
726
|
+
THIRD_PARTY_NOTICES.md
|
|
727
|
+
],
|
|
728
|
+
template_path: "db_deployment/mysql"
|
|
729
|
+
}
|
|
730
|
+
}.freeze
|
|
731
|
+
|
|
732
|
+
def initialize(target, output, force:)
|
|
733
|
+
@target = target
|
|
734
|
+
@output = File.expand_path(output)
|
|
735
|
+
@force = force
|
|
736
|
+
@root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def write!
|
|
740
|
+
raise Error, "deployment sources are unavailable from #{ROOT}" unless File.directory?(ROOT)
|
|
741
|
+
raise Error, "#{@output} already exists (use --force to overwrite)" if File.exist?(@output) && !@force
|
|
742
|
+
|
|
743
|
+
Dir.mktmpdir("multi-compress-db-package") do |temporary_directory|
|
|
744
|
+
bundle_root = File.join(temporary_directory, @root_name)
|
|
745
|
+
FileUtils.mkdir_p(bundle_root)
|
|
746
|
+
stage_payload!(bundle_root)
|
|
747
|
+
write_metadata!(bundle_root)
|
|
748
|
+
write_tarball!(temporary_directory)
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
private
|
|
753
|
+
|
|
754
|
+
def stage_payload!(bundle_root)
|
|
755
|
+
target = TARGETS.fetch(@target)
|
|
756
|
+
target.fetch(:source_paths).each do |relative|
|
|
757
|
+
copy_path!(relative, bundle_root)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
template_root = File.join(ROOT, target.fetch(:template_path))
|
|
761
|
+
Dir.children(template_root).sort.each do |entry|
|
|
762
|
+
next if ignored?(entry)
|
|
763
|
+
|
|
764
|
+
copy_path!(File.join(target.fetch(:template_path), entry), bundle_root, destination: entry)
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def copy_path!(relative, bundle_root, destination: relative)
|
|
769
|
+
source = File.join(ROOT, relative)
|
|
770
|
+
raise Error, "package source is missing: #{relative}" unless File.exist?(source)
|
|
771
|
+
|
|
772
|
+
destination_path = File.join(bundle_root, destination)
|
|
773
|
+
if File.directory?(source)
|
|
774
|
+
FileUtils.mkdir_p(destination_path)
|
|
775
|
+
Dir.children(source).sort.each do |entry|
|
|
776
|
+
next if ignored?(entry)
|
|
777
|
+
|
|
778
|
+
copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
|
|
779
|
+
end
|
|
780
|
+
else
|
|
781
|
+
FileUtils.mkdir_p(File.dirname(destination_path))
|
|
782
|
+
FileUtils.copy_file(source, destination_path)
|
|
783
|
+
File.chmod(File.stat(source).mode & 0o777, destination_path)
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def ignored?(entry)
|
|
788
|
+
entry == ".DS_Store" || entry == ".git" || entry == "build" || entry == "test"
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def write_metadata!(bundle_root)
|
|
792
|
+
payload = regular_files(bundle_root).reject do |path|
|
|
793
|
+
%w[manifest.json SHA256SUMS].include?(File.basename(path))
|
|
794
|
+
end
|
|
795
|
+
files = payload.sort.map do |path|
|
|
796
|
+
relative = relative_to(bundle_root, path)
|
|
797
|
+
{ "path" => relative, "sha256" => Digest::SHA256.file(path).hexdigest }
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
manifest = {
|
|
801
|
+
"bundle_format_version" => BUNDLE_FORMAT_VERSION,
|
|
802
|
+
"gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
|
|
803
|
+
"database_target" => @target,
|
|
804
|
+
"database_envelope" => FORMAT,
|
|
805
|
+
"files" => files
|
|
806
|
+
}
|
|
807
|
+
File.write(File.join(bundle_root, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
|
|
808
|
+
|
|
809
|
+
checksum_paths = regular_files(bundle_root).reject { |path| File.basename(path) == "SHA256SUMS" }
|
|
810
|
+
checksums = checksum_paths.sort.map do |path|
|
|
811
|
+
"#{Digest::SHA256.file(path).hexdigest} #{relative_to(bundle_root, path)}"
|
|
812
|
+
end
|
|
813
|
+
File.write(File.join(bundle_root, "SHA256SUMS"), checksums.join("\n") + "\n")
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def write_tarball!(temporary_directory)
|
|
817
|
+
FileUtils.mkdir_p(File.dirname(@output))
|
|
818
|
+
temporary_output = File.join(
|
|
819
|
+
File.dirname(@output),
|
|
820
|
+
".#{File.basename(@output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
File.open(temporary_output, "wb", 0o644) do |file|
|
|
824
|
+
Zlib::GzipWriter.wrap(file) do |gzip|
|
|
825
|
+
gzip.mtime = archive_epoch
|
|
826
|
+
DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
|
|
827
|
+
add_tree_to_tar!(tar, File.join(temporary_directory, @root_name), @root_name)
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
File.rename(temporary_output, @output)
|
|
832
|
+
ensure
|
|
833
|
+
File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def archive_epoch
|
|
837
|
+
value = ENV["SOURCE_DATE_EPOCH"]
|
|
838
|
+
return DEFAULT_ARCHIVE_EPOCH unless value&.match?(/\A\d+\z/)
|
|
839
|
+
|
|
840
|
+
value.to_i
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def add_tree_to_tar!(tar, directory, archive_path)
|
|
844
|
+
tar.mkdir(archive_path, File.stat(directory).mode & 0o777)
|
|
845
|
+
Dir.children(directory).sort.each do |entry|
|
|
846
|
+
source = File.join(directory, entry)
|
|
847
|
+
destination = File.join(archive_path, entry)
|
|
848
|
+
if File.directory?(source)
|
|
849
|
+
add_tree_to_tar!(tar, source, destination)
|
|
850
|
+
else
|
|
851
|
+
mode = File.stat(source).mode & 0o777
|
|
852
|
+
tar.add_file_simple(destination, mode, File.size(source)) do |destination_io|
|
|
853
|
+
File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
def regular_files(root)
|
|
860
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def relative_to(root, path)
|
|
864
|
+
Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
end
|