multi_compress 0.3.5 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/GET_STARTED.md +188 -0
  4. data/README.md +56 -0
  5. data/db_core/include/mcdb_format.h +55 -0
  6. data/db_core/src/mcdb_format.c +341 -0
  7. data/db_deployment/mysql/Makefile +48 -0
  8. data/db_deployment/mysql/README.md +42 -0
  9. data/db_deployment/mysql/bin/common +160 -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 +57 -0
  13. data/db_deployment/mysql/bin/install +64 -0
  14. data/db_deployment/mysql/bin/status +65 -0
  15. data/db_deployment/mysql/bin/uninstall +47 -0
  16. data/db_deployment/mysql/bin/upgrade +115 -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 +60 -0
  20. data/db_deployment/postgres/bin/doctor +61 -0
  21. data/db_deployment/postgres/bin/enable +136 -0
  22. data/db_deployment/postgres/bin/install +104 -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/exe/multi_compress +228 -0
  27. data/ext/multi_compress/extconf.rb +4 -0
  28. data/ext/multi_compress/multi_compress.c +30 -0
  29. data/lib/multi_compress/active_record.rb +61 -0
  30. data/lib/multi_compress/codec.rb +164 -0
  31. data/lib/multi_compress/database.rb +106 -0
  32. data/lib/multi_compress/db_deployment.rb +456 -0
  33. data/lib/multi_compress/version.rb +1 -1
  34. data/multi_compress.gemspec +55 -0
  35. data/mysql_udf/Makefile +96 -0
  36. data/mysql_udf/README.md +95 -0
  37. data/mysql_udf/sql/examples.sql +11 -0
  38. data/mysql_udf/sql/install.sql +15 -0
  39. data/mysql_udf/sql/uninstall.sql +4 -0
  40. data/mysql_udf/sql/views.sql +8 -0
  41. data/mysql_udf/src/multi_compress_mysql.c +133 -0
  42. data/mysql_udf/src/mysql_udf_abi_57.h +34 -0
  43. data/mysql_udf/test/mcdb_cli.c +60 -0
  44. data/mysql_udf/test/run_e2e.sh +296 -0
  45. data/postgres_extension/Makefile +74 -0
  46. data/postgres_extension/README.md +112 -0
  47. data/postgres_extension/multi_compress.control +4 -0
  48. data/postgres_extension/multi_compress_pg.exports +12 -0
  49. data/postgres_extension/sql/examples.sql +12 -0
  50. data/postgres_extension/sql/multi_compress--0.5.0.sql +24 -0
  51. data/postgres_extension/sql/uninstall.sql +2 -0
  52. data/postgres_extension/sql/views.sql +7 -0
  53. data/postgres_extension/src/mcdb_format.c +2 -0
  54. data/postgres_extension/src/multi_compress_pg.c +96 -0
  55. data/postgres_extension/test/run_e2e.sh +323 -0
  56. metadata +52 -3
@@ -0,0 +1,456 @@
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"
18
+ BUNDLE_FORMAT_VERSION = 1
19
+
20
+ class Error < StandardError; end
21
+
22
+ class CLI
23
+ def self.run(argv)
24
+ new.run(argv)
25
+ end
26
+
27
+ def run(argv)
28
+ command = argv.shift
29
+
30
+ case command
31
+ when "package"
32
+ PackageCommand.new.run(argv)
33
+ when "view"
34
+ ViewCommand.new.run(argv)
35
+ when "help", "--help", "-h", nil
36
+ puts help
37
+ 0
38
+ else
39
+ raise OptionParser::InvalidOption, "unknown db command: #{command.inspect}"
40
+ end
41
+ rescue OptionParser::ParseError, Error => e
42
+ warn "multi_compress db: #{e.message}"
43
+ 2
44
+ rescue SystemCallError, IOError => e
45
+ warn "multi_compress db: #{e.message}"
46
+ 1
47
+ end
48
+
49
+ private
50
+
51
+ def help
52
+ <<~TEXT
53
+ Usage:
54
+ multi_compress db package postgres [--output PATH] [--force]
55
+ multi_compress db package mysql [--output PATH] [--force]
56
+
57
+ multi_compress db view postgres --table SCHEMA.TABLE --column COLUMN \\
58
+ --view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] \\
59
+ [--extension-schema multi_compress] [--output PATH]
60
+
61
+ multi_compress db view mysql --table SCHEMA.TABLE --column COLUMN \\
62
+ --view SCHEMA.VIEW --columns ID,CREATED_AT [--as PAYLOAD] [--output PATH]
63
+
64
+ `package` creates a self-contained source bundle for the DBA. The DBA
65
+ extracts it on the database host and runs `make doctor`, `sudo make
66
+ install`, then `make enable DB=...`.
67
+
68
+ `view` emits a safe readable-view definition so DBeaver users query
69
+ decoded text instead of the compressed bytea/BLOB column.
70
+ TEXT
71
+ end
72
+ end
73
+
74
+ class PackageCommand
75
+ TARGETS = %w[postgres mysql].freeze
76
+
77
+ def run(argv)
78
+ target = argv.shift
79
+ raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
80
+ raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
81
+
82
+ options = { output: nil, force: false }
83
+ parser = OptionParser.new do |opts|
84
+ opts.banner = "Usage: multi_compress db package #{target} [OPTIONS]"
85
+ opts.on("-o", "--output PATH", "write the deployment archive to PATH") { |value| options[:output] = value }
86
+ opts.on("-f", "--force", "overwrite an existing archive") { options[:force] = true }
87
+ opts.on("-h", "--help", "show this help") { puts opts; return 0 }
88
+ end
89
+ parser.parse!(argv)
90
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
91
+
92
+ output = options[:output] || "multi_compress-#{target}-#{MultiCompress::VERSION}.tar.gz"
93
+ DeploymentBundle.new(target, output, force: options[:force]).write!
94
+ puts File.expand_path(output)
95
+ 0
96
+ end
97
+ end
98
+
99
+ class ViewCommand
100
+ TARGETS = %w[postgres mysql].freeze
101
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
102
+
103
+ def run(argv)
104
+ target = argv.shift
105
+ raise OptionParser::MissingArgument, "TARGET must be postgres or mysql" if target.nil?
106
+ raise OptionParser::InvalidArgument, "TARGET must be postgres or mysql" unless TARGETS.include?(target)
107
+
108
+ options = { columns: [], as: "payload", output: nil, extension_schema: "multi_compress" }
109
+ parser = OptionParser.new do |opts|
110
+ opts.banner = "Usage: multi_compress db view #{target} --table SCHEMA.TABLE --column COLUMN --view SCHEMA.VIEW --columns ID,CREATED_AT [OPTIONS]"
111
+ opts.on("--table NAME", "source table, optionally schema-qualified") { |value| options[:table] = value }
112
+ opts.on("--column NAME", "compressed bytea/BLOB column") { |value| options[:column] = value }
113
+ opts.on("--view NAME", "destination view, optionally schema-qualified") { |value| options[:view] = value }
114
+ opts.on("--columns LIST", "comma-separated plain columns to expose") { |value| options[:columns] = value.split(",").map(&:strip) }
115
+ opts.on("--as NAME", "decoded column name (default: payload)") { |value| options[:as] = value }
116
+ if target == "postgres"
117
+ opts.on("--extension-schema NAME", "extension schema (default: multi_compress)") { |value| options[:extension_schema] = value }
118
+ end
119
+ opts.on("-o", "--output PATH", "write SQL to PATH instead of stdout") { |value| options[:output] = value }
120
+ opts.on("-h", "--help", "show this help") { puts opts; return 0 }
121
+ end
122
+ parser.parse!(argv)
123
+ raise OptionParser::InvalidOption, argv.join(" ") unless argv.empty?
124
+
125
+ sql = ReadableView.new(target, options).to_sql
126
+ if options[:output]
127
+ write_text_atomically(options[:output], sql)
128
+ puts File.expand_path(options[:output])
129
+ else
130
+ $stdout.write(sql)
131
+ end
132
+ 0
133
+ end
134
+
135
+ private
136
+
137
+ def write_text_atomically(path, contents)
138
+ directory = File.dirname(File.expand_path(path))
139
+ FileUtils.mkdir_p(directory)
140
+ temporary = File.join(directory, ".multi-compress-view-#{Process.pid}-#{SecureRandom.hex(8)}")
141
+ File.open(temporary, "wb", 0o644) { |file| file.write(contents) }
142
+ File.rename(temporary, path)
143
+ ensure
144
+ File.delete(temporary) if defined?(temporary) && File.exist?(temporary)
145
+ end
146
+ end
147
+
148
+ class ReadableView
149
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
150
+
151
+ def initialize(target, options)
152
+ @target = target
153
+ @table = qualified_identifier!(options.fetch(:table), "--table")
154
+ @view = qualified_identifier!(options.fetch(:view), "--view")
155
+ @column = identifier!(options.fetch(:column), "--column")
156
+ @columns = Array(options.fetch(:columns)).reject(&:empty?).map { |value| identifier!(value, "--columns") }
157
+ @decoded_name = identifier!(options.fetch(:as), "--as")
158
+ @extension_schema = identifier!(options.fetch(:extension_schema, "multi_compress"), "--extension-schema")
159
+ raise Error, "--columns must contain at least one plain column" if @columns.empty?
160
+ raise Error, "--columns must not contain duplicate names" unless @columns.uniq.length == @columns.length
161
+ raise Error, "--as must not duplicate a selected column" if @columns.include?(@decoded_name)
162
+ rescue KeyError
163
+ raise Error, "--table, --column, --view and --columns are required"
164
+ end
165
+
166
+ def to_sql
167
+ case @target
168
+ when "postgres" then postgres_sql
169
+ when "mysql" then mysql_sql
170
+ else raise Error, "unsupported database target: #{@target.inspect}"
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def postgres_sql
177
+ 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)}"
179
+
180
+ <<~SQL
181
+ -- 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.
183
+ CREATE OR REPLACE VIEW #{pg_qualified(@view)} AS
184
+ SELECT
185
+ #{selections.join(",\n")}
186
+ FROM #{pg_qualified(@table)} AS source;
187
+ SQL
188
+ end
189
+
190
+ def mysql_sql
191
+ 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)}"
193
+
194
+ <<~SQL
195
+ -- 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
198
+ SELECT
199
+ #{selections.join(",\n")}
200
+ FROM #{mysql_qualified(@table)} AS source;
201
+ SQL
202
+ end
203
+
204
+ def qualified_identifier!(value, option)
205
+ pieces = value.to_s.split(".", -1)
206
+ unless pieces.length.between?(1, 2) && pieces.all? { |piece| IDENTIFIER.match?(piece) }
207
+ raise Error, "#{option} must be NAME or SCHEMA.NAME using letters, digits and underscores"
208
+ end
209
+ pieces
210
+ end
211
+
212
+ def identifier!(value, option)
213
+ string = value.to_s
214
+ raise Error, "#{option} must use letters, digits and underscores and cannot start with a digit" unless IDENTIFIER.match?(string)
215
+
216
+ string
217
+ end
218
+
219
+ def pg_quote(identifier)
220
+ %Q("#{identifier}")
221
+ end
222
+
223
+ def pg_qualified(pieces)
224
+ pieces.map { |piece| pg_quote(piece) }.join(".")
225
+ end
226
+
227
+ def mysql_quote(identifier)
228
+ "`#{identifier}`"
229
+ end
230
+
231
+ def mysql_qualified(pieces)
232
+ pieces.map { |piece| mysql_quote(piece) }.join(".")
233
+ end
234
+ end
235
+
236
+ class DeploymentBundle
237
+ DEFAULT_ARCHIVE_EPOCH = 315_619_200
238
+
239
+ class DeterministicTarWriter < Gem::Package::TarWriter
240
+ def self.new(io, epoch)
241
+ writer = allocate
242
+ writer.send(:initialize, io, epoch)
243
+ return writer unless block_given?
244
+
245
+ begin
246
+ yield writer
247
+ ensure
248
+ writer.close
249
+ end
250
+
251
+ nil
252
+ end
253
+
254
+ def initialize(io, epoch)
255
+ @archive_epoch = epoch
256
+ super(io)
257
+ end
258
+
259
+ def add_file_simple(name, mode, size)
260
+ check_closed
261
+
262
+ name, prefix = split_name(name)
263
+ header = Gem::Package::TarHeader.new(
264
+ name: name,
265
+ mode: mode,
266
+ size: size,
267
+ prefix: prefix,
268
+ mtime: @archive_epoch
269
+ ).to_s
270
+ @io.write(header)
271
+ output = BoundedStream.new(@io, size)
272
+ yield output if block_given?
273
+ @io.write("\0" * (size - output.written))
274
+ @io.write("\0" * ((512 - (size % 512)) % 512))
275
+ self
276
+ end
277
+
278
+ def mkdir(name, mode)
279
+ check_closed
280
+
281
+ name, prefix = split_name(name)
282
+ header = Gem::Package::TarHeader.new(
283
+ name: name,
284
+ mode: mode,
285
+ typeflag: "5",
286
+ size: 0,
287
+ prefix: prefix,
288
+ mtime: @archive_epoch
289
+ )
290
+ @io.write(header)
291
+ self
292
+ end
293
+ end
294
+
295
+ TARGETS = {
296
+ "postgres" => {
297
+ source_paths: %w[
298
+ db_core
299
+ postgres_extension
300
+ ext/multi_compress/vendor/zstd
301
+ docs/database-envelope-v1.md
302
+ LICENSE.txt
303
+ THIRD_PARTY_NOTICES.md
304
+ ],
305
+ template_path: "db_deployment/postgres"
306
+ },
307
+ "mysql" => {
308
+ source_paths: %w[
309
+ db_core
310
+ mysql_udf
311
+ ext/multi_compress/vendor/zstd
312
+ docs/database-envelope-v1.md
313
+ LICENSE.txt
314
+ THIRD_PARTY_NOTICES.md
315
+ ],
316
+ template_path: "db_deployment/mysql"
317
+ }
318
+ }.freeze
319
+
320
+ def initialize(target, output, force:)
321
+ @target = target
322
+ @output = File.expand_path(output)
323
+ @force = force
324
+ @root_name = "multi_compress-#{target}-#{MultiCompress::VERSION}"
325
+ end
326
+
327
+ def write!
328
+ raise Error, "deployment sources are unavailable from #{ROOT}" unless File.directory?(ROOT)
329
+ raise Error, "#{@output} already exists (use --force to overwrite)" if File.exist?(@output) && !@force
330
+
331
+ Dir.mktmpdir("multi-compress-db-package") do |temporary_directory|
332
+ bundle_root = File.join(temporary_directory, @root_name)
333
+ FileUtils.mkdir_p(bundle_root)
334
+ stage_payload!(bundle_root)
335
+ write_metadata!(bundle_root)
336
+ write_tarball!(temporary_directory)
337
+ end
338
+ end
339
+
340
+ private
341
+
342
+ def stage_payload!(bundle_root)
343
+ target = TARGETS.fetch(@target)
344
+ target.fetch(:source_paths).each do |relative|
345
+ copy_path!(relative, bundle_root)
346
+ end
347
+
348
+ template_root = File.join(ROOT, target.fetch(:template_path))
349
+ Dir.children(template_root).sort.each do |entry|
350
+ next if ignored?(entry)
351
+
352
+ copy_path!(File.join(target.fetch(:template_path), entry), bundle_root, destination: entry)
353
+ end
354
+ end
355
+
356
+ def copy_path!(relative, bundle_root, destination: relative)
357
+ source = File.join(ROOT, relative)
358
+ raise Error, "package source is missing: #{relative}" unless File.exist?(source)
359
+
360
+ destination_path = File.join(bundle_root, destination)
361
+ if File.directory?(source)
362
+ FileUtils.mkdir_p(destination_path)
363
+ Dir.children(source).sort.each do |entry|
364
+ next if ignored?(entry)
365
+
366
+ copy_path!(File.join(relative, entry), bundle_root, destination: File.join(destination, entry))
367
+ end
368
+ else
369
+ FileUtils.mkdir_p(File.dirname(destination_path))
370
+ FileUtils.copy_file(source, destination_path)
371
+ File.chmod(File.stat(source).mode & 0o777, destination_path)
372
+ end
373
+ end
374
+
375
+ def ignored?(entry)
376
+ entry == ".DS_Store" || entry == ".git" || entry == "build" || entry == "test"
377
+ end
378
+
379
+ def write_metadata!(bundle_root)
380
+ payload = regular_files(bundle_root).reject do |path|
381
+ %w[manifest.json SHA256SUMS].include?(File.basename(path))
382
+ end
383
+ files = payload.sort.map do |path|
384
+ relative = relative_to(bundle_root, path)
385
+ { "path" => relative, "sha256" => Digest::SHA256.file(path).hexdigest }
386
+ end
387
+
388
+ manifest = {
389
+ "bundle_format_version" => BUNDLE_FORMAT_VERSION,
390
+ "gem" => { "name" => "multi_compress", "version" => MultiCompress::VERSION },
391
+ "database_target" => @target,
392
+ "database_envelope" => FORMAT,
393
+ "files" => files
394
+ }
395
+ File.write(File.join(bundle_root, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
396
+
397
+ checksum_paths = regular_files(bundle_root).reject { |path| File.basename(path) == "SHA256SUMS" }
398
+ checksums = checksum_paths.sort.map do |path|
399
+ "#{Digest::SHA256.file(path).hexdigest} #{relative_to(bundle_root, path)}"
400
+ end
401
+ File.write(File.join(bundle_root, "SHA256SUMS"), checksums.join("\n") + "\n")
402
+ end
403
+
404
+ def write_tarball!(temporary_directory)
405
+ FileUtils.mkdir_p(File.dirname(@output))
406
+ temporary_output = File.join(
407
+ File.dirname(@output),
408
+ ".#{File.basename(@output)}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
409
+ )
410
+
411
+ File.open(temporary_output, "wb", 0o644) do |file|
412
+ Zlib::GzipWriter.wrap(file) do |gzip|
413
+ gzip.mtime = archive_epoch
414
+ DeterministicTarWriter.new(gzip, archive_epoch) do |tar|
415
+ add_tree_to_tar!(tar, File.join(temporary_directory, @root_name), @root_name)
416
+ end
417
+ end
418
+ end
419
+ File.rename(temporary_output, @output)
420
+ ensure
421
+ File.delete(temporary_output) if defined?(temporary_output) && File.exist?(temporary_output)
422
+ end
423
+
424
+ def archive_epoch
425
+ value = ENV["SOURCE_DATE_EPOCH"]
426
+ return DEFAULT_ARCHIVE_EPOCH unless value&.match?(/\A\d+\z/)
427
+
428
+ value.to_i
429
+ end
430
+
431
+ def add_tree_to_tar!(tar, directory, archive_path)
432
+ tar.mkdir(archive_path, File.stat(directory).mode & 0o777)
433
+ Dir.children(directory).sort.each do |entry|
434
+ source = File.join(directory, entry)
435
+ destination = File.join(archive_path, entry)
436
+ if File.directory?(source)
437
+ add_tree_to_tar!(tar, source, destination)
438
+ else
439
+ mode = File.stat(source).mode & 0o777
440
+ tar.add_file_simple(destination, mode, File.size(source)) do |destination_io|
441
+ File.open(source, "rb") { |source_io| IO.copy_stream(source_io, destination_io) }
442
+ end
443
+ end
444
+ end
445
+ end
446
+
447
+ def regular_files(root)
448
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).select { |path| File.file?(path) }
449
+ end
450
+
451
+ def relative_to(root, path)
452
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
453
+ end
454
+ end
455
+ end
456
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MultiCompress
4
- VERSION = "0.3.5"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -0,0 +1,55 @@
1
+ require_relative "lib/multi_compress/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "multi_compress"
5
+ spec.version = MultiCompress::VERSION
6
+ spec.authors = ["Roman Haydarov"]
7
+ spec.email = ["romnhajdarov@gmail.com"]
8
+
9
+ spec.summary = "Modern fiber-friendly compression for Ruby: zstd, lz4, brotli in one gem"
10
+ spec.description = "Unified C-extension gem for zstd, lz4, and brotli compression. " \
11
+ "One-shot, streaming, IO wrappers, dictionary support. " \
12
+ "Fiber-friendly: cooperates with Fiber::Scheduler (async, falcon) " \
13
+ "so CPU-heavy compression never blocks the event loop. " \
14
+ "Ships vendored sources — no system libraries required."
15
+ spec.homepage = "https://github.com/roman-haidarov/multi_compress"
16
+ spec.license = "MIT"
17
+ spec.required_ruby_version = ">= 2.7.1"
18
+
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
+
23
+ spec.files = Dir[
24
+ "lib/**/*.rb",
25
+ "exe/*",
26
+ "docs/**/*.md",
27
+ "ext/**/*.{c,h,rb}",
28
+ "ext/multi_compress/vendor/**/*",
29
+ "ext/multi_compress/vendor/.vendored",
30
+ "db_core/**/*.{c,h}",
31
+ "postgres_extension/**/*",
32
+ "mysql_udf/**/*",
33
+ "db_deployment/**/*",
34
+ "multi_compress.gemspec",
35
+ "README.md",
36
+ "GET_STARTED.md",
37
+ "LICENSE.txt",
38
+ "THIRD_PARTY_NOTICES.md",
39
+ "CHANGELOG.md"
40
+ ].reject do |path|
41
+ File.basename(path) == ".DS_Store" ||
42
+ path.split(File::SEPARATOR).include?("build") ||
43
+ %w[.o .so .bundle .dylib .bc .d].include?(File.extname(path))
44
+ end
45
+
46
+ spec.bindir = "exe"
47
+ spec.executables = ["multi_compress"]
48
+ spec.require_paths = ["lib"]
49
+ spec.extensions = ["ext/multi_compress/extconf.rb"]
50
+
51
+ spec.add_development_dependency "bundler", "~> 2.0"
52
+ spec.add_development_dependency "rake", "~> 13.0"
53
+ spec.add_development_dependency "rake-compiler", "~> 1.2"
54
+ spec.add_development_dependency "minitest", "~> 5.0"
55
+ end
@@ -0,0 +1,96 @@
1
+ ZSTD_DIR ?= ../ext/multi_compress/vendor/zstd/lib
2
+ CORE_DIR ?= ../db_core
3
+ BUILD ?= build
4
+ CC ?= gcc
5
+ AR ?= ar
6
+ MYSQL_UDF_ABI ?= mysql57
7
+ MYSQL_INCLUDE ?= /usr/include/mysql
8
+ FIXTURES ?= ../test/fixtures/database_v1
9
+
10
+ ifeq ($(MYSQL_UDF_ABI),mysql57)
11
+ MYSQL_UDF_CPPFLAGS := -DMCDB_MYSQL_UDF_ABI_57
12
+ MYSQL_UDF_HEADER_CHECK :=
13
+ else ifeq ($(MYSQL_UDF_ABI),system)
14
+ MYSQL_UDF_CPPFLAGS := -I$(MYSQL_INCLUDE)
15
+ MYSQL_UDF_HEADER_CHECK := test -f "$(MYSQL_INCLUDE)/mysql.h" || { echo "missing mysql.h: set MYSQL_INCLUDE=/path/to/mysql/include" >&2; exit 2; }
16
+ else
17
+ $(error MYSQL_UDF_ABI must be mysql57 or system)
18
+ endif
19
+
20
+ WARNFLAGS ?= -Wall -Wextra -Werror
21
+ CFLAGS ?= -O2 -fPIC -std=c99 $(WARNFLAGS) -I$(ZSTD_DIR) -I$(CORE_DIR)/include
22
+ ZSTD_CFLAGS ?= -O2 -fPIC -std=c99 -I$(ZSTD_DIR) -I$(ZSTD_DIR)/common
23
+
24
+ ZSTD_OBJ_DIR := $(BUILD)/zstd
25
+ ZSTD_C_SRCS := $(wildcard $(ZSTD_DIR)/common/*.c) $(wildcard $(ZSTD_DIR)/decompress/*.c)
26
+ ZSTD_C_OBJS := $(patsubst $(ZSTD_DIR)/%.c,$(ZSTD_OBJ_DIR)/%.o,$(ZSTD_C_SRCS))
27
+
28
+ ARCH := $(shell uname -m)
29
+ ifneq (,$(filter x86_64 amd64,$(ARCH)))
30
+ ZSTD_ASM_SRC := $(ZSTD_DIR)/decompress/huf_decompress_amd64.S
31
+ ifneq (,$(wildcard $(ZSTD_ASM_SRC)))
32
+ ZSTD_ASM_OBJ := $(ZSTD_OBJ_DIR)/decompress/huf_decompress_amd64.o
33
+ endif
34
+ endif
35
+
36
+ ZSTD_OBJS := $(ZSTD_C_OBJS) $(ZSTD_ASM_OBJ)
37
+ ZSTD_LIB := $(BUILD)/libzstd_dec.a
38
+ UDF_SO := $(BUILD)/multi_compress_mysql.so
39
+ CLI := $(BUILD)/mcdb_cli
40
+ VALID_FIXTURES := utf8_text empty_text large_text
41
+ CORRUPT_FIXTURES := corrupt_magic wrong_version corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame
42
+
43
+ .PHONY: all udf test fixtures clean
44
+ all: udf
45
+
46
+ $(BUILD):
47
+ mkdir -p $@
48
+
49
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.c
50
+ @mkdir -p $(dir $@)
51
+ $(CC) $(ZSTD_CFLAGS) -MMD -MP -c $< -o $@
52
+
53
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.S
54
+ @mkdir -p $(dir $@)
55
+ $(CC) -fPIC -I$(ZSTD_DIR) -c $< -o $@
56
+
57
+ $(ZSTD_LIB): $(ZSTD_OBJS) | $(BUILD)
58
+ $(AR) rcs $@ $^
59
+
60
+ $(UDF_SO): src/multi_compress_mysql.c $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h $(ZSTD_LIB) | $(BUILD)
61
+ @$(MYSQL_UDF_HEADER_CHECK)
62
+ $(CC) $(CFLAGS) $(MYSQL_UDF_CPPFLAGS) -shared \
63
+ src/multi_compress_mysql.c $(CORE_DIR)/src/mcdb_format.c $(ZSTD_LIB) \
64
+ -o $@
65
+ @echo "built $@"
66
+
67
+ udf: $(UDF_SO)
68
+
69
+ $(CLI): $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h test/mcdb_cli.c $(ZSTD_LIB) | $(BUILD)
70
+ $(CC) $(CFLAGS) $(CORE_DIR)/src/mcdb_format.c test/mcdb_cli.c $(ZSTD_LIB) -o $@
71
+
72
+ fixtures:
73
+ RUBYLIB=../lib ruby $(FIXTURES)/generate.rb
74
+
75
+ test: $(CLI)
76
+ @echo "== MCDB1 Ruby<->C parity =="
77
+ @fail=0; \
78
+ for name in $(VALID_FIXTURES); do \
79
+ f=$(FIXTURES)/$$name.mcdb; exp=$(FIXTURES)/$$name.expected; \
80
+ if [ ! -f $$f ] || [ ! -f $$exp ]; then echo " MISSING $$name (run: make fixtures)"; fail=1; continue; fi; \
81
+ if $(CLI) decode $$f | cmp -s - $$exp; then echo " valid $$name OK"; \
82
+ else echo " valid $$name FAIL"; fail=1; fi; \
83
+ done; \
84
+ for name in $(CORRUPT_FIXTURES); do \
85
+ f=$(FIXTURES)/$$name.mcdb; \
86
+ if [ ! -f $$f ]; then echo " MISSING $$name (run: make fixtures)"; fail=1; continue; fi; \
87
+ if $(CLI) decode $$f >/dev/null 2>&1; then echo " corrupt $$name UNEXPECTEDLY VALID"; fail=1; \
88
+ else echo " corrupt $$name correctly rejected"; fi; \
89
+ done; \
90
+ if [ $$fail -ne 0 ]; then echo "PARITY FAILED"; exit 1; fi; \
91
+ echo "PARITY OK"
92
+
93
+ clean:
94
+ rm -rf $(BUILD)
95
+
96
+ -include $(ZSTD_C_OBJS:.o=.d)