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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/GET_STARTED.md +207 -0
  4. data/README.md +69 -0
  5. data/db_core/include/mcdb_format.h +113 -0
  6. data/db_core/src/mcdb_format.c +594 -0
  7. data/db_deployment/mysql/Makefile +48 -0
  8. data/db_deployment/mysql/README.md +61 -0
  9. data/db_deployment/mysql/bin/common +212 -0
  10. data/db_deployment/mysql/bin/disable +40 -0
  11. data/db_deployment/mysql/bin/doctor +64 -0
  12. data/db_deployment/mysql/bin/enable +61 -0
  13. data/db_deployment/mysql/bin/install +64 -0
  14. data/db_deployment/mysql/bin/status +67 -0
  15. data/db_deployment/mysql/bin/uninstall +47 -0
  16. data/db_deployment/mysql/bin/upgrade +127 -0
  17. data/db_deployment/mysql/bin/verify +14 -0
  18. data/db_deployment/postgres/Makefile +45 -0
  19. data/db_deployment/postgres/README.md +84 -0
  20. data/db_deployment/postgres/bin/doctor +61 -0
  21. data/db_deployment/postgres/bin/enable +138 -0
  22. data/db_deployment/postgres/bin/install +106 -0
  23. data/db_deployment/postgres/bin/uninstall +54 -0
  24. data/db_deployment/postgres/bin/verify +14 -0
  25. data/docs/database-envelope-v1.md +159 -0
  26. data/docs/database-envelope-v2.md +147 -0
  27. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  28. data/exe/multi_compress +228 -0
  29. data/ext/multi_compress/extconf.rb +4 -0
  30. data/ext/multi_compress/multi_compress.c +62 -0
  31. data/lib/multi_compress/active_record.rb +61 -0
  32. data/lib/multi_compress/codec.rb +164 -0
  33. data/lib/multi_compress/database.rb +312 -0
  34. data/lib/multi_compress/db_deployment.rb +868 -0
  35. data/lib/multi_compress/version.rb +1 -1
  36. data/multi_compress.gemspec +55 -0
  37. data/mysql_udf/Makefile +96 -0
  38. data/mysql_udf/README.md +140 -0
  39. data/mysql_udf/sql/examples.sql +16 -0
  40. data/mysql_udf/sql/install.sql +39 -0
  41. data/mysql_udf/sql/uninstall.sql +10 -0
  42. data/mysql_udf/sql/views.sql +20 -0
  43. data/mysql_udf/src/multi_compress_mysql.c +472 -0
  44. data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
  45. data/mysql_udf/test/mcdb_cli.c +60 -0
  46. data/mysql_udf/test/run_e2e.sh +414 -0
  47. data/postgres_extension/Makefile +74 -0
  48. data/postgres_extension/README.md +160 -0
  49. data/postgres_extension/multi_compress.control +4 -0
  50. data/postgres_extension/multi_compress_pg.exports +24 -0
  51. data/postgres_extension/sql/examples.sql +23 -0
  52. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  53. data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
  54. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  55. data/postgres_extension/sql/uninstall.sql +2 -0
  56. data/postgres_extension/sql/views.sql +7 -0
  57. data/postgres_extension/src/mcdb_format.c +2 -0
  58. data/postgres_extension/src/multi_compress_pg.c +381 -0
  59. data/postgres_extension/test/run_e2e.sh +411 -0
  60. 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