multi_compress 0.5.0 → 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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/GET_STARTED.md +19 -0
  4. data/README.md +14 -1
  5. data/db_core/include/mcdb_format.h +67 -9
  6. data/db_core/src/mcdb_format.c +392 -139
  7. data/db_deployment/mysql/README.md +19 -0
  8. data/db_deployment/mysql/bin/common +70 -18
  9. data/db_deployment/mysql/bin/enable +5 -1
  10. data/db_deployment/mysql/bin/status +2 -0
  11. data/db_deployment/mysql/bin/upgrade +17 -5
  12. data/db_deployment/postgres/README.md +24 -0
  13. data/db_deployment/postgres/bin/enable +2 -0
  14. data/db_deployment/postgres/bin/install +4 -2
  15. data/docs/database-envelope-v2.md +147 -0
  16. data/docs/rfcs/0002-mcdb2-dictionaries.md +75 -0
  17. data/ext/multi_compress/multi_compress.c +32 -0
  18. data/lib/multi_compress/database.rb +255 -49
  19. data/lib/multi_compress/db_deployment.rb +423 -11
  20. data/lib/multi_compress/version.rb +1 -1
  21. data/mysql_udf/README.md +45 -0
  22. data/mysql_udf/sql/examples.sql +5 -0
  23. data/mysql_udf/sql/install.sql +25 -1
  24. data/mysql_udf/sql/uninstall.sql +7 -1
  25. data/mysql_udf/sql/views.sql +16 -4
  26. data/mysql_udf/src/multi_compress_mysql.c +383 -44
  27. data/mysql_udf/test/run_e2e.sh +119 -1
  28. data/postgres_extension/Makefile +2 -2
  29. data/postgres_extension/README.md +48 -0
  30. data/postgres_extension/multi_compress.control +2 -2
  31. data/postgres_extension/multi_compress_pg.exports +12 -0
  32. data/postgres_extension/sql/examples.sql +11 -0
  33. data/postgres_extension/sql/multi_compress--0.5.0--0.6.0.sql +59 -0
  34. data/postgres_extension/sql/multi_compress--0.6.0.sql +85 -0
  35. data/postgres_extension/src/multi_compress_pg.c +301 -16
  36. data/postgres_extension/test/run_e2e.sh +90 -2
  37. metadata +6 -2
@@ -14,8 +14,9 @@ require "zlib"
14
14
  module MultiCompress
15
15
  module DBDeployment
16
16
  ROOT = File.expand_path("../..", __dir__)
17
- FORMAT = "MCDB1"
18
- BUNDLE_FORMAT_VERSION = 1
17
+ FORMAT = "MCDB1 + MCDB2"
18
+ BUNDLE_FORMAT_VERSION = 2
19
+ MAX_DICTIONARY_BYTES = 256 * 1024
19
20
 
20
21
  class Error < StandardError; end
21
22
 
@@ -32,6 +33,8 @@ module MultiCompress
32
33
  PackageCommand.new.run(argv)
33
34
  when "view"
34
35
  ViewCommand.new.run(argv)
36
+ when "registry"
37
+ RegistryCommand.new.run(argv)
35
38
  when "help", "--help", "-h", nil
36
39
  puts help
37
40
  0
@@ -66,7 +69,14 @@ module MultiCompress
66
69
  install`, then `make enable DB=...`.
67
70
 
68
71
  `view` emits a safe readable-view definition so DBeaver users query
69
- decoded text instead of the compressed bytea/BLOB column.
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.
70
80
  TEXT
71
81
  end
72
82
  end
@@ -105,7 +115,12 @@ module MultiCompress
105
115
  raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
106
116
  raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
107
117
 
108
- options = { columns: [], as: "payload", output: nil, extension_schema: "multi_compress" }
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
+ }
109
124
  parser = OptionParser.new do |opts|
110
125
  opts.banner = "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN --view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
111
126
  opts.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
@@ -113,6 +128,11 @@ module MultiCompress
113
128
  opts.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
114
129
  opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip) }
115
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 }
116
136
  if target == "postgres"
117
137
  opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
118
138
  end
@@ -145,6 +165,275 @@ module MultiCompress
145
165
  end
146
166
  end
147
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
+
148
437
  class ReadableView
149
438
  IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
150
439
 
@@ -156,6 +445,14 @@ module MultiCompress
156
445
  @columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
157
446
  @decoded_name = identifier!(options.fetch(:as), "--as")
158
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
159
456
  raise Error, "--columns must contain at least one plain column" if @columns.empty?
160
457
  raise Error, "--columns must not contain duplicate names" unless @columns.uniq.length == @columns.length
161
458
  raise Error, "--as must not duplicate a selected column" if @columns.include?(@decoded_name)
@@ -175,32 +472,71 @@ module MultiCompress
175
472
 
176
473
  def postgres_sql
177
474
  selections = @columns.map { |column| " source.#{pg_quote(column)}" }
178
- selections << " #{pg_quote(@extension_schema)}.multi_compress_db_decompress(source.#{pg_quote(@column)}) AS #{pg_quote(@decoded_name)}"
475
+ selections << " #{postgres_decoder_sql} AS #{pg_quote(@decoded_name)}"
179
476
 
180
477
  <<~SQL
181
478
  -- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
182
- -- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
479
+ -- Query it in DBeaver; do not expose the compressed column or dictionary registry directly.
183
480
  CREATE OR REPLACE VIEW #{pg_qualified(@view)} AS
184
481
  SELECT
185
482
  #{selections.join(",\n")}
186
- FROM #{pg_qualified(@table)} AS source;
483
+ FROM #{pg_qualified(@table)} AS source#{postgres_dictionary_join};
187
484
  SQL
188
485
  end
189
486
 
190
487
  def mysql_sql
191
488
  selections = @columns.map { |column| " source.#{mysql_quote(column)}" }
192
- selections << " CONVERT(multi_compress_db_decompress(source.#{mysql_quote(@column)}) USING utf8mb4) AS #{mysql_quote(@decoded_name)}"
489
+ selections << " CONVERT(#{mysql_decoder_sql} USING utf8mb4) AS #{mysql_quote(@decoded_name)}"
490
+ algorithm = dictionary? ? " ALGORITHM=MERGE SQL SECURITY DEFINER" : ""
193
491
 
194
492
  <<~SQL
195
493
  -- Generated by multi_compress #{MultiCompress::VERSION}. Keep this view in your schema migrations.
196
- -- Query it in DBeaver; do not expose the compressed column for ad-hoc reading.
197
- CREATE OR REPLACE VIEW #{mysql_qualified(@view)} AS
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
198
497
  SELECT
199
498
  #{selections.join(",\n")}
200
- FROM #{mysql_qualified(@table)} AS source;
499
+ FROM #{mysql_qualified(@table)} AS source#{mysql_dictionary_join};
201
500
  SQL
202
501
  end
203
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
+
204
540
  def qualified_identifier!(value, option)
205
541
  pieces = value.to_s.split(".", -1)
206
542
  unless pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
@@ -209,6 +545,78 @@ module MultiCompress
209
545
  pieces
210
546
  end
211
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
+
212
620
  def identifier!(value, option)
213
621
  string = value.to_s
214
622
  raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
@@ -299,6 +707,8 @@ module MultiCompress
299
707
  postgres_extension
300
708
  ext/multi_compress/vendor/zstd
301
709
  docs/database-envelope-v1.md
710
+ docs/database-envelope-v2.md
711
+ docs/rfcs/0002-mcdb2-dictionaries.md
302
712
  LICENSE.txt
303
713
  THIRD_PARTY_NOTICES.md
304
714
  ],
@@ -310,6 +720,8 @@ module MultiCompress
310
720
  mysql_udf
311
721
  ext/multi_compress/vendor/zstd
312
722
  docs/database-envelope-v1.md
723
+ docs/database-envelope-v2.md
724
+ docs/rfcs/0002-mcdb2-dictionaries.md
313
725
  LICENSE.txt
314
726
  THIRD_PARTY_NOTICES.md
315
727
  ],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MultiCompress
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/mysql_udf/README.md CHANGED
@@ -93,3 +93,48 @@ invokes it.
93
93
  `make verify` catches accidental corruption after extraction; it does not
94
94
  authenticate an archive. Use an out-of-band SHA-256, minisign/GPG signature, or
95
95
  signed release provenance for a real trust chain.
96
+
97
+ ## MCDB2 dictionary-backed columns
98
+
99
+ MCDB2 is opt-in for one homogeneous column with many similar small JSON/text
100
+ values. Upgrade the UDF library first; the controlled `make upgrade` flow keeps
101
+ all MCDB1 and MCDB2 functions registered.
102
+
103
+ Dictionary bytes are application data. Generate the append-only registry in the
104
+ application database, register immutable versions, and retain all referenced
105
+ versions in backups and replicas:
106
+
107
+ ```bash
108
+ bundle exec multi_compress db registry mysql --database app \
109
+ --output db/mcdb_dictionary_registry.sql
110
+ ```
111
+
112
+ For a new MCDB2 column add `payload_dictionary_id BIGINT UNSIGNED NOT NULL` with
113
+ a foreign key to `mcdb_dictionary_versions(id)`, then generate the view:
114
+
115
+ ```bash
116
+ bundle exec multi_compress db view mysql \
117
+ --table app.events \
118
+ --column payload_compressed \
119
+ --dictionary-table app.mcdb_dictionary_versions \
120
+ --dictionary-id-column payload_dictionary_id \
121
+ --view admin.events_readable \
122
+ --columns id,created_at,status \
123
+ --as payload \
124
+ --output db/views/events_readable.sql
125
+ ```
126
+
127
+ The generated MCDB2 view requests `ALGORITHM=MERGE SQL SECURITY DEFINER` and uses
128
+ a source-first `STRAIGHT_JOIN` to the registry. That keeps an indexed outer source
129
+ predicate from starting at the registry. It passes payload, registry id, SHA-256
130
+ and dictionary bytes to the UDF. Keep the definition free of `DISTINCT`, aggregates,
131
+ `GROUP BY`, `UNION`, subqueries and inner `LIMIT`; validate real queries with
132
+ `EXPLAIN`: source must be a selective `range`/`ref` access and no `<derived>` row
133
+ may appear. MySQL 5.7 can still report `Using temporary; Using filesort` for an
134
+ outer `ORDER BY` over the joined projection; that is sorting, not view
135
+ materialization. A stable definer account must remain part of backup/restore
136
+ procedures.
137
+
138
+ Details are in [`docs/database-envelope-v2.md`](../docs/database-envelope-v2.md).
139
+
140
+ For MCDB2 registry DDL, pass `--payload-table`, `--payload-column`, and `--payload-dictionary-id-column` to `multi_compress db registry`; this creates the payload FK and enforces header dictionary-reference consistency.
@@ -9,3 +9,8 @@ FROM events
9
9
  WHERE multi_compress_db_is_valid(payload_compressed) = 0;
10
10
 
11
11
  SELECT multi_compress_db_version();
12
+
13
+ -- MCDB2: use the generated ALGORITHM=MERGE readable view. Avoid filtering on
14
+ -- decoded payload text; filter/order by indexed source columns before LIMIT.
15
+ --
16
+ -- SELECT id, payload FROM admin.events_readable WHERE id >= 100 ORDER BY id LIMIT 100;
@@ -1,4 +1,4 @@
1
- -- Install the MultiCompress MCDB1 reader UDFs (MySQL 5.7).
1
+ -- Install the MultiCompress MCDB1/MCDB2 reader UDFs (MySQL 5.7).
2
2
 
3
3
  CREATE FUNCTION multi_compress_db_version
4
4
  RETURNS STRING
@@ -12,4 +12,28 @@ CREATE FUNCTION multi_compress_db_decompress
12
12
  RETURNS STRING
13
13
  SONAME 'multi_compress_mysql.so';
14
14
 
15
+ CREATE FUNCTION multi_compress_db_original_size
16
+ RETURNS INTEGER
17
+ SONAME 'multi_compress_mysql.so';
18
+
19
+ CREATE FUNCTION multi_compress_db_dictionary_ref
20
+ RETURNS INTEGER
21
+ SONAME 'multi_compress_mysql.so';
22
+
23
+ CREATE FUNCTION multi_compress_db_dictionary_zstd_id
24
+ RETURNS INTEGER
25
+ SONAME 'multi_compress_mysql.so';
26
+
27
+ CREATE FUNCTION multi_compress_db_dictionary_sha256
28
+ RETURNS STRING
29
+ SONAME 'multi_compress_mysql.so';
30
+
31
+ CREATE FUNCTION multi_compress_db_is_valid_dict
32
+ RETURNS INTEGER
33
+ SONAME 'multi_compress_mysql.so';
34
+
35
+ CREATE FUNCTION multi_compress_db_decompress_dict
36
+ RETURNS STRING
37
+ SONAME 'multi_compress_mysql.so';
38
+
15
39
  SELECT multi_compress_db_version();
@@ -1,4 +1,10 @@
1
- -- Remove the MultiCompress MCDB1 reader UDFs.
1
+ -- Run only after verifying no generated readable view depends on these UDFs.
2
+ DROP FUNCTION IF EXISTS multi_compress_db_decompress_dict;
3
+ DROP FUNCTION IF EXISTS multi_compress_db_is_valid_dict;
4
+ DROP FUNCTION IF EXISTS multi_compress_db_dictionary_sha256;
5
+ DROP FUNCTION IF EXISTS multi_compress_db_dictionary_zstd_id;
6
+ DROP FUNCTION IF EXISTS multi_compress_db_original_size;
7
+ DROP FUNCTION IF EXISTS multi_compress_db_dictionary_ref;
2
8
  DROP FUNCTION IF EXISTS multi_compress_db_decompress;
3
9
  DROP FUNCTION IF EXISTS multi_compress_db_is_valid;
4
10
  DROP FUNCTION IF EXISTS multi_compress_db_version;
@@ -1,8 +1,20 @@
1
- -- Example read-side view for DBeaver / DataGrip / mysql console.
2
-
1
+ -- MCDB1 view example. Generate production SQL with:
2
+ -- multi_compress db view mysql ...
3
3
  CREATE OR REPLACE VIEW events_readable AS
4
4
  SELECT
5
5
  id,
6
- CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) AS payload,
7
- created_at
6
+ CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) AS payload
8
7
  FROM events;
8
+
9
+ -- MCDB2 dictionary-backed shape. The generated view uses ALGORITHM=MERGE and
10
+ -- a source-first STRAIGHT_JOIN to the append-only registry, so an indexed outer
11
+ -- WHERE predicate is pushed into the source scan in MySQL 5.7. An outer ORDER BY
12
+ -- may still use filesort; that is not view materialization.
13
+ --
14
+ -- CREATE ALGORITHM=MERGE SQL SECURITY DEFINER VIEW events_readable AS
15
+ -- SELECT e.id,
16
+ -- CONVERT(multi_compress_db_decompress_dict(
17
+ -- e.payload_compressed, e.payload_dictionary_id, d.sha256, d.bytes
18
+ -- ) USING utf8mb4) AS payload
19
+ -- FROM events e
20
+ -- STRAIGHT_JOIN mcdb_dictionary_versions d ON d.id = e.payload_dictionary_id;