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,95 @@
1
+ # MultiCompress MySQL 5.7 UDF (`MCDB1` reader)
2
+
3
+ Decode-only MySQL loadable functions for the MultiCompress Database envelope v1.
4
+ The shared format contract is [`docs/database-envelope-v1.md`](../docs/database-envelope-v1.md).
5
+
6
+ ## SQL API
7
+
8
+ | Function | Returns | Purpose |
9
+ |---|---:|---|
10
+ | `multi_compress_db_version()` | STRING | reader build, MCDB1 and zstd version smoke test |
11
+ | `multi_compress_db_is_valid(blob)` | INTEGER | `1` for valid MCDB1, `0` for corruption, `NULL` for SQL NULL |
12
+ | `multi_compress_db_decompress(blob)` | STRING | decompressed UTF-8 or SQL `NULL` for corruption/SQL NULL |
13
+
14
+ A MySQL readable view cannot distinguish an original SQL `NULL` from a corrupt
15
+ MCDB1 envelope by testing `payload IS NULL`. Diagnose stored values with
16
+ `multi_compress_db_is_valid(payload_compressed)`.
17
+
18
+ ## Production deployment: generated DBA bundle
19
+
20
+ Generate a source bundle from the installed gem:
21
+
22
+ ```bash
23
+ bundle exec multi_compress db package mysql --output tmp/multi-compress-mysql.tar.gz
24
+ ```
25
+
26
+ On the MySQL 5.7 host, use its local Unix socket:
27
+
28
+ ```bash
29
+ tar -xzf multi-compress-mysql.tar.gz
30
+ cd multi_compress-mysql-0.5.0
31
+ make verify
32
+ make doctor MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
33
+ sudo make install MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
34
+ make enable MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
35
+ make status MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
36
+ ```
37
+
38
+ `install` is fresh-install only and refuses to replace a loaded library. A
39
+ healthy second `enable` is a no-op. For a library update, use the controlled
40
+ maintenance flow:
41
+
42
+ ```bash
43
+ sudo make upgrade CONFIRM=UPGRADE_MULTI_COMPRESS \
44
+ MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
45
+ ```
46
+
47
+ `upgrade` captures registrations, drops MultiCompress-owned functions, backs up
48
+ the old library, atomically replaces the library, registers functions, and
49
+ smoke-checks the reader. On failure it restores the previous library and
50
+ registrations. `disable` and `uninstall` are destructive and require:
51
+
52
+ ```bash
53
+ make disable CONFIRM=DISABLE_MULTI_COMPRESS \
54
+ MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
55
+ sudo make uninstall CONFIRM=REMOVE_MULTI_COMPRESS \
56
+ MYSQL_DEFAULTS_FILE=/etc/mysql/admin.cnf MYSQL_SOCKET=/run/mysqld/mysqld.sock
57
+ ```
58
+
59
+ The DBA account needs `INSERT` on `mysql` to register functions, `DELETE` to
60
+ drop them, and `SELECT` on `mysql.func` for `make status`. The bundle's mysql
61
+ client uses `--max_allowed_packet=32M`; mysqld, the Ruby driver, and
62
+ DBeaver/JDBC must each support payloads up to the 16 MiB MCDB1 limit.
63
+
64
+ ## DBeaver readable view
65
+
66
+ The application stores `MultiCompress::Database.compress(text)` in a `LONGBLOB`
67
+ column and commits generated view SQL in a migration:
68
+
69
+ ```bash
70
+ bundle exec multi_compress db view mysql \
71
+ --table app.events \
72
+ --column payload_compressed \
73
+ --view admin.events_readable \
74
+ --columns id,created_at,status \
75
+ --as payload \
76
+ --output db/views/events_readable.sql
77
+ ```
78
+
79
+ MySQL views default to `SQL SECURITY DEFINER`; create the migration with a
80
+ stable technical account and do not remove that definer without recreating the
81
+ view. Grant the DBeaver role `SELECT` on the resulting view. The generated view
82
+ uses `CONVERT(... USING utf8mb4)` so Cyrillic, Kazakh text and emoji display
83
+ correctly. Filter by indexed uncompressed columns before selecting through it.
84
+
85
+ ## Multi-host rollout
86
+
87
+ Build/install the native library on every primary, replica, reporting, failover,
88
+ and restore host that will read through the view before registering UDFs or
89
+ applying the view migration. A read-only view does not itself replicate the UDF;
90
+ replication requires the UDF on a replica only when a replicated statement
91
+ invokes it.
92
+
93
+ `make verify` catches accidental corruption after extraction; it does not
94
+ authenticate an archive. Use an out-of-band SHA-256, minisign/GPG signature, or
95
+ signed release provenance for a real trust chain.
@@ -0,0 +1,11 @@
1
+ -- Point lookup an employee runs from DBeaver:
2
+ SELECT
3
+ CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) AS payload
4
+ FROM events
5
+ WHERE id = 123;
6
+
7
+ SELECT id
8
+ FROM events
9
+ WHERE multi_compress_db_is_valid(payload_compressed) = 0;
10
+
11
+ SELECT multi_compress_db_version();
@@ -0,0 +1,15 @@
1
+ -- Install the MultiCompress MCDB1 reader UDFs (MySQL 5.7).
2
+
3
+ CREATE FUNCTION multi_compress_db_version
4
+ RETURNS STRING
5
+ SONAME 'multi_compress_mysql.so';
6
+
7
+ CREATE FUNCTION multi_compress_db_is_valid
8
+ RETURNS INTEGER
9
+ SONAME 'multi_compress_mysql.so';
10
+
11
+ CREATE FUNCTION multi_compress_db_decompress
12
+ RETURNS STRING
13
+ SONAME 'multi_compress_mysql.so';
14
+
15
+ SELECT multi_compress_db_version();
@@ -0,0 +1,4 @@
1
+ -- Remove the MultiCompress MCDB1 reader UDFs.
2
+ DROP FUNCTION IF EXISTS multi_compress_db_decompress;
3
+ DROP FUNCTION IF EXISTS multi_compress_db_is_valid;
4
+ DROP FUNCTION IF EXISTS multi_compress_db_version;
@@ -0,0 +1,8 @@
1
+ -- Example read-side view for DBeaver / DataGrip / mysql console.
2
+
3
+ CREATE OR REPLACE VIEW events_readable AS
4
+ SELECT
5
+ id,
6
+ CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) AS payload,
7
+ created_at
8
+ FROM events;
@@ -0,0 +1,133 @@
1
+ #include <stdio.h>
2
+ #include <stdlib.h>
3
+ #include <string.h>
4
+
5
+ #ifdef MCDB_MYSQL_UDF_ABI_57
6
+ #include "mysql_udf_abi_57.h"
7
+ #else
8
+ #include <stdbool.h>
9
+ #include <mysql.h>
10
+
11
+ #if defined(MARIADB_BASE_VERSION) || defined(MARIADB_VERSION_ID) || \
12
+ (defined(MYSQL_VERSION_ID) && MYSQL_VERSION_ID < 80000)
13
+ typedef my_bool mcdb_udf_init_result_t;
14
+ #else
15
+ typedef bool mcdb_udf_init_result_t;
16
+ #endif
17
+ #endif
18
+
19
+ #ifdef MCDB_MYSQL_UDF_ABI_57
20
+ typedef my_bool mcdb_udf_init_result_t;
21
+ #endif
22
+
23
+ #include <zstd.h>
24
+
25
+ #include "mcdb_format.h"
26
+
27
+ mcdb_udf_init_result_t multi_compress_db_version_init(UDF_INIT *initid, UDF_ARGS *args,
28
+ char *message) {
29
+ (void)args;
30
+ if (args->arg_count != 0) {
31
+ strcpy(message, "multi_compress_db_version() takes no arguments");
32
+ return 1;
33
+ }
34
+ initid->maybe_null = 0;
35
+ initid->const_item = 1;
36
+ initid->max_length = 64;
37
+ return 0;
38
+ }
39
+
40
+ char *multi_compress_db_version(UDF_INIT *initid, UDF_ARGS *args, char *result,
41
+ unsigned long *length, char *is_null, char *error) {
42
+ (void)initid;
43
+ (void)args;
44
+ *is_null = 0;
45
+ *error = 0;
46
+ /* result buffer is 255 bytes per the UDF ABI */
47
+ int n = snprintf(result, 255, "multi_compress reader %s; MCDB1; zstd %s", MCDB_READER_VERSION,
48
+ ZSTD_versionString());
49
+ *length = (unsigned long)(n > 0 ? n : 0);
50
+ return result;
51
+ }
52
+
53
+ mcdb_udf_init_result_t multi_compress_db_is_valid_init(UDF_INIT *initid, UDF_ARGS *args,
54
+ char *message) {
55
+ if (args->arg_count != 1) {
56
+ strcpy(message, "multi_compress_db_is_valid(blob) takes exactly one argument");
57
+ return 1;
58
+ }
59
+ args->arg_type[0] = STRING_RESULT;
60
+ initid->maybe_null = 1;
61
+ initid->const_item = 0;
62
+ initid->max_length = 1;
63
+ return 0;
64
+ }
65
+
66
+ long long multi_compress_db_is_valid(UDF_INIT *initid, UDF_ARGS *args, char *is_null, char *error) {
67
+ (void)initid;
68
+ *error = 0;
69
+ if (args->args[0] == NULL) {
70
+ *is_null = 1;
71
+ return 0;
72
+ }
73
+ *is_null = 0;
74
+
75
+ unsigned char *out = NULL;
76
+ size_t out_len = 0;
77
+ char err[MCDB_ERRLEN];
78
+ mcdb_status st = mcdb_decode((const unsigned char *)args->args[0], (size_t)args->lengths[0],
79
+ &out, &out_len, err);
80
+ free(out);
81
+ return st == MCDB_OK ? 1 : 0;
82
+ }
83
+
84
+ mcdb_udf_init_result_t multi_compress_db_decompress_init(UDF_INIT *initid, UDF_ARGS *args,
85
+ char *message) {
86
+ if (args->arg_count != 1) {
87
+ strcpy(message, "multi_compress_db_decompress(blob) takes exactly one argument");
88
+ return 1;
89
+ }
90
+ args->arg_type[0] = STRING_RESULT;
91
+ initid->maybe_null = 1;
92
+ initid->const_item = 0;
93
+ initid->max_length = MCDB_MAX_OUTPUT; /* result can be up to 16 MiB, not the input size */
94
+ initid->ptr = NULL; /* holds the malloc'd result between _decompress calls */
95
+ return 0;
96
+ }
97
+
98
+ void multi_compress_db_decompress_deinit(UDF_INIT *initid) {
99
+ if (initid->ptr) {
100
+ free(initid->ptr);
101
+ initid->ptr = NULL;
102
+ }
103
+ }
104
+
105
+ char *multi_compress_db_decompress(UDF_INIT *initid, UDF_ARGS *args, char *result,
106
+ unsigned long *length, char *is_null, char *error) {
107
+ (void)result;
108
+ *error = 0;
109
+ if (args->args[0] == NULL) {
110
+ *is_null = 1;
111
+ return NULL;
112
+ }
113
+ if (initid->ptr) {
114
+ free(initid->ptr);
115
+ initid->ptr = NULL;
116
+ }
117
+
118
+ unsigned char *out = NULL;
119
+ size_t out_len = 0;
120
+ char err[MCDB_ERRLEN];
121
+ mcdb_status st = mcdb_decode((const unsigned char *)args->args[0], (size_t)args->lengths[0],
122
+ &out, &out_len, err);
123
+ if (st != MCDB_OK) {
124
+ *is_null = 1;
125
+ free(out);
126
+ return NULL;
127
+ }
128
+
129
+ initid->ptr = (char *)out;
130
+ *length = (unsigned long)out_len;
131
+ *is_null = 0;
132
+ return (char *)out;
133
+ }
@@ -0,0 +1,34 @@
1
+ #ifndef MCDB_MYSQL_UDF_ABI_57_H
2
+ #define MCDB_MYSQL_UDF_ABI_57_H
3
+
4
+ typedef char my_bool;
5
+
6
+ enum Item_result {
7
+ STRING_RESULT = 0,
8
+ REAL_RESULT,
9
+ INT_RESULT,
10
+ ROW_RESULT,
11
+ DECIMAL_RESULT
12
+ };
13
+
14
+ typedef struct st_udf_args {
15
+ unsigned int arg_count;
16
+ enum Item_result *arg_type;
17
+ char **args;
18
+ unsigned long *lengths;
19
+ char *maybe_null;
20
+ char **attributes;
21
+ unsigned long *attribute_lengths;
22
+ char *extension;
23
+ } UDF_ARGS;
24
+
25
+ typedef struct st_udf_init {
26
+ my_bool maybe_null;
27
+ unsigned int decimals;
28
+ unsigned long max_length;
29
+ char *ptr;
30
+ my_bool const_item;
31
+ void *extension;
32
+ } UDF_INIT;
33
+
34
+ #endif
@@ -0,0 +1,60 @@
1
+ #include "mcdb_format.h"
2
+
3
+ #include <stdio.h>
4
+ #include <stdlib.h>
5
+ #include <string.h>
6
+
7
+ static unsigned char *read_all(const char *path, size_t *len) {
8
+ FILE *f = fopen(path, "rb");
9
+ if (!f) {
10
+ perror("open");
11
+ return NULL;
12
+ }
13
+ fseek(f, 0, SEEK_END);
14
+ long sz = ftell(f);
15
+ fseek(f, 0, SEEK_SET);
16
+ unsigned char *buf = (unsigned char *)malloc(sz > 0 ? (size_t)sz : 1);
17
+ size_t got = fread(buf, 1, (size_t)sz, f);
18
+ fclose(f);
19
+ *len = got;
20
+ return buf;
21
+ }
22
+
23
+ int main(int argc, char **argv) {
24
+ if (argc != 3) {
25
+ fprintf(stderr, "usage: %s decode|valid <file.mcdb>\n", argv[0]);
26
+ return 2;
27
+ }
28
+
29
+ size_t in_len = 0;
30
+ unsigned char *in = read_all(argv[2], &in_len);
31
+ if (!in)
32
+ return 2;
33
+
34
+ if (strcmp(argv[1], "valid") == 0) {
35
+ uint64_t sz;
36
+ mcdb_status st = mcdb_validate_header(in, in_len, &sz);
37
+ unsigned char *out = NULL;
38
+ size_t out_len = 0;
39
+ char err[MCDB_ERRLEN];
40
+ if (st == MCDB_OK)
41
+ st = mcdb_decode(in, in_len, &out, &out_len, err);
42
+ free(out);
43
+ free(in);
44
+ return st == MCDB_OK ? 0 : 1;
45
+ }
46
+
47
+ unsigned char *out = NULL;
48
+ size_t out_len = 0;
49
+ char err[MCDB_ERRLEN];
50
+ mcdb_status st = mcdb_decode(in, in_len, &out, &out_len, err);
51
+ if (st != MCDB_OK) {
52
+ fprintf(stderr, "%s\n", err);
53
+ free(in);
54
+ return 1;
55
+ }
56
+ fwrite(out, 1, out_len, stdout);
57
+ free(out);
58
+ free(in);
59
+ return 0;
60
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/../.."
4
+
5
+ DEFAULT_MYSQL_IMAGE="mysql:5.7.44@sha256:dab0a802b44617303694fb17d166501de279c3031ddeb28c56ecf7fcab5ef0da"
6
+ IMAGE="${MCDB_MYSQL_IMAGE:-$DEFAULT_MYSQL_IMAGE}"
7
+ PLATFORM="${MCDB_MYSQL_PLATFORM:-linux/amd64}"
8
+ CONTAINER="${MCDB_MYSQL_CONTAINER:-mcdb-e2e}"
9
+ WAIT_SECONDS="${MCDB_MYSQL_WAIT_SECONDS:-90}"
10
+ WORK_ROOT="${MCDB_MYSQL_WORK_ROOT:-$(mktemp -d -t multi-compress-mysql.XXXXXX)}"
11
+ REMOVE_WORK_ROOT=0
12
+ ARCHIVE="$WORK_ROOT/multi_compress-mysql.tar.gz"
13
+ GEM_UNPACK_DIR="$WORK_ROOT/unpacked"
14
+ GEM_FILE="$WORK_ROOT/multi_compress.gem"
15
+ PACKAGED_GEM_ROOT=""
16
+ BUNDLE_PARENT="/opt"
17
+
18
+ command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 2; }
19
+
20
+ cleanup() {
21
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
22
+ [ "$REMOVE_WORK_ROOT" -eq 1 ] && rm -rf "$WORK_ROOT"
23
+ }
24
+ trap cleanup EXIT
25
+
26
+ if [ -z "${MCDB_MYSQL_WORK_ROOT:-}" ]; then
27
+ REMOVE_WORK_ROOT=1
28
+ fi
29
+
30
+ ensure_ruby_extension() {
31
+ if ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null 2>&1; then
32
+ return
33
+ fi
34
+
35
+ echo "building Ruby extension..."
36
+ if command -v bundle >/dev/null 2>&1; then
37
+ bundle exec rake compile
38
+ else
39
+ rake compile
40
+ fi
41
+ ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null
42
+ }
43
+
44
+ require_fixture() {
45
+ local path="$1"
46
+ [ -f "$path" ] || { echo "missing committed fixture: $path" >&2; exit 2; }
47
+ }
48
+
49
+ fixture_hex() {
50
+ ruby -e 'print File.binread(ARGV.fetch(0)).unpack1("H*")' "$1"
51
+ }
52
+
53
+ prepare_packaged_gem() {
54
+ gem build multi_compress.gemspec --output "$GEM_FILE" >/dev/null
55
+ mkdir -p "$GEM_UNPACK_DIR"
56
+ gem unpack "$GEM_FILE" --target "$GEM_UNPACK_DIR" >/dev/null
57
+ shopt -s nullglob
58
+ roots=("$GEM_UNPACK_DIR"/*)
59
+ shopt -u nullglob
60
+ [ "${#roots[@]}" -eq 1 ] && [ -d "${roots[0]}" ] || { echo "could not unpack exactly one multi_compress gem root" >&2; exit 2; }
61
+ PACKAGED_GEM_ROOT="${roots[0]}"
62
+ [ -f "$PACKAGED_GEM_ROOT/multi_compress.gemspec" ] || { echo "packaged gem lacks gemspec" >&2; exit 2; }
63
+ [ -d "$PACKAGED_GEM_ROOT/lib" ] || { echo "packaged gem lacks lib" >&2; exit 2; }
64
+ [ -d "$PACKAGED_GEM_ROOT/exe" ] || { echo "packaged gem lacks exe" >&2; exit 2; }
65
+ [ -f "$PACKAGED_GEM_ROOT/lib/multi_compress/db_deployment.rb" ] || { echo "packaged gem lacks db deployment CLI" >&2; exit 2; }
66
+ }
67
+
68
+ db_cli() {
69
+ ruby -I "$PACKAGED_GEM_ROOT/lib" "$PACKAGED_GEM_ROOT/exe/multi_compress" db "$@"
70
+ }
71
+
72
+ compress_hex() {
73
+ MCDB_TEXT="$1" ruby -Ilib -r multi_compress -r multi_compress/database \
74
+ -e 'print MultiCompress::Database.compress(ENV.fetch("MCDB_TEXT")).unpack1("H*")'
75
+ }
76
+
77
+ bundle_name() {
78
+ ruby -I "$PACKAGED_GEM_ROOT/lib" -r multi_compress/version \
79
+ -e 'print "multi_compress-mysql-#{MultiCompress::VERSION}"'
80
+ }
81
+
82
+ build_deployment_bundle() {
83
+ db_cli package mysql --output "$ARCHIVE" --force
84
+ test -s "$ARCHIVE"
85
+ }
86
+
87
+ install_mysql_build_tools() {
88
+ docker exec -u 0 "$CONTAINER" sh -ceu '
89
+ if command -v yum >/dev/null 2>&1; then
90
+ yum install -y gcc make binutils tar gzip
91
+ elif command -v microdnf >/dev/null 2>&1; then
92
+ microdnf install -y gcc make binutils tar gzip
93
+ elif command -v apt-get >/dev/null 2>&1; then
94
+ apt-get update
95
+ DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential tar gzip
96
+ else
97
+ echo "no supported package manager in MySQL container" >&2
98
+ exit 2
99
+ fi
100
+
101
+ command -v cc
102
+ command -v make
103
+ command -v ar
104
+ '
105
+ }
106
+
107
+ install_bundle_on_db_host() {
108
+ local bundle_name="$1"
109
+ local bundle_root="${BUNDLE_PARENT}/${bundle_name}"
110
+
111
+ tar -tzf "$ARCHIVE" | grep -Fx "${bundle_name}/THIRD_PARTY_NOTICES.md" >/dev/null
112
+ if tar -tzf "$ARCHIVE" | grep -E '/\.DS_Store$' >/dev/null; then
113
+ echo 'deployment bundle must not contain .DS_Store' >&2
114
+ exit 2
115
+ fi
116
+ docker cp "$ARCHIVE" "$CONTAINER:/tmp/${bundle_name}.tar.gz"
117
+ docker exec -u 0 -e "MCDB_BUNDLE_ROOT=$bundle_root" "$CONTAINER" sh -ceu '
118
+ rm -rf "$MCDB_BUNDLE_ROOT"
119
+ mkdir -p "$(dirname "$MCDB_BUNDLE_ROOT")"
120
+ tar -xzf "/tmp/$(basename "$MCDB_BUNDLE_ROOT").tar.gz" -C "$(dirname "$MCDB_BUNDLE_ROOT")"
121
+ cd "$MCDB_BUNDLE_ROOT"
122
+ make verify
123
+ make doctor
124
+ make install
125
+ make enable
126
+ make status
127
+ '
128
+ }
129
+
130
+ apply_readable_view() {
131
+ local path="$1"
132
+ db_cli view mysql \
133
+ --table app.events \
134
+ --column payload_compressed \
135
+ --view admin.events_readable \
136
+ --columns id \
137
+ --output "$path"
138
+ docker exec -i "$CONTAINER" mysql --default-character-set=utf8mb4 < "$path"
139
+ }
140
+
141
+ ensure_ruby_extension
142
+ prepare_packaged_gem
143
+ build_deployment_bundle
144
+
145
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
146
+ require_fixture "test/fixtures/database_v1/${name}.mcdb"
147
+ done
148
+
149
+ TEXT='Привет, DBeaver! Сәлем! 🌍'
150
+ BIG=$(ruby -e 'print "a" * 300')
151
+ VALID_HEX=$(compress_hex "$TEXT")
152
+ BIG_HEX=$(compress_hex "$BIG")
153
+ CORRUPT_HEX=$(fixture_hex test/fixtures/database_v1/corrupt_crc.mcdb)
154
+ BUNDLE_NAME="$(bundle_name)"
155
+ VIEW_SQL="$(mktemp -t multi-compress-mysql-view.XXXXXX.sql)"
156
+ trap 'rm -f "$VIEW_SQL"; cleanup' EXIT
157
+
158
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
159
+ docker run --platform "$PLATFORM" -d --name "$CONTAINER" \
160
+ -e MYSQL_ALLOW_EMPTY_PASSWORD=1 "$IMAGE" \
161
+ --character-set-server=utf8mb4 \
162
+ --collation-server=utf8mb4_unicode_ci >/dev/null
163
+
164
+ echo "waiting for mysqld in $IMAGE..."
165
+ deadline=$(( $(date +%s) + WAIT_SECONDS ))
166
+ until docker exec "$CONTAINER" mysqladmin ping --silent 2>/dev/null; do
167
+ if [ "$(date +%s)" -ge "$deadline" ]; then
168
+ echo "mysqld did not become ready within ${WAIT_SECONDS}s" >&2
169
+ docker logs "$CONTAINER" >&2 || true
170
+ exit 1
171
+ fi
172
+ sleep 2
173
+ done
174
+
175
+ q() {
176
+ docker exec "$CONTAINER" mysql \
177
+ --default-character-set=utf8mb4 \
178
+ --batch \
179
+ --skip-column-names \
180
+ --raw \
181
+ -e "$1"
182
+ }
183
+ qfail() {
184
+ if q "$1" >/dev/null 2>&1; then return 1; else return 0; fi
185
+ }
186
+
187
+ install_mysql_build_tools
188
+ install_bundle_on_db_host "$BUNDLE_NAME"
189
+
190
+ q "DROP DATABASE IF EXISTS app;
191
+ DROP DATABASE IF EXISTS admin;
192
+ CREATE DATABASE app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
193
+ CREATE DATABASE admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
194
+ USE app;
195
+ CREATE TABLE events (id INT PRIMARY KEY, payload_compressed LONGBLOB NULL);
196
+ INSERT INTO events VALUES
197
+ (1, UNHEX('$VALID_HEX')),
198
+ (2, NULL),
199
+ (3, UNHEX('$BIG_HEX')),
200
+ (4, UNHEX('$VALID_HEX')),
201
+ (5, UNHEX('$CORRUPT_HEX')),
202
+ (6, UNHEX('$VALID_HEX'));"
203
+ apply_readable_view "$VIEW_SQL"
204
+
205
+ fails=0
206
+ check() {
207
+ if [ "$2" = "$3" ]; then echo " PASS $1"; else echo " FAIL $1 (got [$2] want [$3])"; fails=$((fails + 1)); fi
208
+ }
209
+ check_err() {
210
+ if qfail "$2"; then echo " PASS $1 (rejected)"; else echo " FAIL $1 (should have errored)"; fails=$((fails + 1)); fi
211
+ }
212
+
213
+ check "deployment bundle version() smoke" "$(q 'SELECT LOCATE(CHAR(77,67,68,66,49), multi_compress_db_version()) > 0;')" "1"
214
+ check "MySQL connection uses utf8mb4" \
215
+ "$(q "SELECT CONCAT(@@character_set_client, '/', @@character_set_connection, '/', @@character_set_results);")" \
216
+ "utf8mb4/utf8mb4/utf8mb4"
217
+ check "decode roundtrip" \
218
+ "$(q 'USE app; SELECT CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) FROM events WHERE id=1;')" \
219
+ "$TEXT"
220
+ check "NULL -> NULL" "$(q 'USE app; SELECT multi_compress_db_decompress(payload_compressed) IS NULL FROM events WHERE id=2;')" "1"
221
+ check "big (>255, >input)" "$(q 'USE app; SELECT OCTET_LENGTH(multi_compress_db_decompress(payload_compressed)) FROM events WHERE id=3;')" "300"
222
+ check "generated readable view" "$(q 'SELECT COUNT(*) FROM admin.events_readable WHERE id IN (1, 4);')" "2"
223
+ check "generated readable view returns Unicode" \
224
+ "$(q 'SELECT payload FROM admin.events_readable WHERE id=1;')" \
225
+ "$TEXT"
226
+ check "valid-corrupt-valid scan preserves later valid row" \
227
+ "$(q "USE app; SELECT GROUP_CONCAT(CASE WHEN multi_compress_db_decompress(payload_compressed) IS NULL THEN '<NULL>' ELSE CONVERT(multi_compress_db_decompress(payload_compressed) USING utf8mb4) END ORDER BY id SEPARATOR '|') FROM events WHERE id IN (1, 5, 6);")" \
228
+ "$TEXT|<NULL>|$TEXT"
229
+ check "is_valid true" "$(q 'USE app; SELECT multi_compress_db_is_valid(payload_compressed) FROM events WHERE id=1;')" "1"
230
+ check "is_valid on NULL" "$(q 'USE app; SELECT multi_compress_db_is_valid(payload_compressed) IS NULL FROM events WHERE id=2;')" "1"
231
+
232
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
233
+ hex=$(fixture_hex "test/fixtures/database_v1/${name}.mcdb")
234
+ check "$name -> NULL" \
235
+ "$(q "USE app; SELECT multi_compress_db_decompress(UNHEX('$hex')) IS NULL;")" \
236
+ "1"
237
+ done
238
+ check "is_valid false (crc)" \
239
+ "$(q "SELECT multi_compress_db_is_valid(UNHEX('$(fixture_hex test/fixtures/database_v1/corrupt_crc.mcdb)'));")" \
240
+ "0"
241
+ check "max_allowed_packet is reported" "$(q 'SELECT @@GLOBAL.max_allowed_packet > 0;')" "1"
242
+
243
+ MYSQL_SOCKET_PATH="$(q 'SELECT @@socket;' | tr -d '\r\n')"
244
+ docker exec -u 0 \
245
+ -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" \
246
+ -e "MCDB_MYSQL_SOCKET=$MYSQL_SOCKET_PATH" \
247
+ "$CONTAINER" sh -ceu '
248
+ cd "$MCDB_BUNDLE_ROOT"
249
+ make doctor MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" | grep -F "server socket: $MCDB_MYSQL_SOCKET"
250
+ make upgrade CONFIRM=UPGRADE_MULTI_COMPRESS MYSQL_SOCKET="$MCDB_MYSQL_SOCKET"
251
+ make status MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" | grep -F "state: enabled"
252
+ '
253
+ check "upgrade keeps version() available" "$(q 'SELECT LOCATE(CHAR(77,67,68,66,49), multi_compress_db_version()) > 0;')" "1"
254
+
255
+ # Register one expected name to a different SONAME and prove enable refuses without
256
+ # dropping it. The copied library makes this a real MySQL registration, not a mock.
257
+ docker exec -u 0 \
258
+ -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" \
259
+ -e "MCDB_MYSQL_SOCKET=$MYSQL_SOCKET_PATH" \
260
+ "$CONTAINER" sh -ceu '
261
+ plugin_dir="$(mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" --batch --skip-column-names -e "SELECT @@plugin_dir;")"
262
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "DROP FUNCTION multi_compress_db_version;"
263
+ cp "$plugin_dir/multi_compress_mysql.so" "$plugin_dir/foreign_multi_compress.so"
264
+ foreign_soname="$(printf "\\047foreign_multi_compress.so\\047")"
265
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "CREATE FUNCTION multi_compress_db_version RETURNS STRING SONAME $foreign_soname;"
266
+ cd "$MCDB_BUNDLE_ROOT"
267
+ if make enable MYSQL_SOCKET="$MCDB_MYSQL_SOCKET" >/tmp/mcdb-foreign-enable.out 2>&1; then
268
+ echo "enable unexpectedly accepted a foreign registration" >&2
269
+ exit 1
270
+ fi
271
+ grep -F "foreign SONAME" /tmp/mcdb-foreign-enable.out
272
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" --batch --skip-column-names \
273
+ -e "SELECT dl FROM mysql.func WHERE name = $(printf "\\047multi_compress_db_version\\047");" | grep -Fx "foreign_multi_compress.so"
274
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "DROP FUNCTION multi_compress_db_version;"
275
+ owned_soname="$(printf "\\047multi_compress_mysql.so\\047")"
276
+ mysql --protocol=SOCKET --socket="$MCDB_MYSQL_SOCKET" -e "CREATE FUNCTION multi_compress_db_version RETURNS STRING SONAME $owned_soname;"
277
+ rm -f "$plugin_dir/foreign_multi_compress.so"
278
+ '
279
+
280
+ docker exec -u 0 -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" "$CONTAINER" sh -ceu '
281
+ cd "$MCDB_BUNDLE_ROOT"
282
+ if make disable >/dev/null 2>&1; then
283
+ echo "disable succeeded without CONFIRM" >&2
284
+ exit 1
285
+ fi
286
+ if make uninstall >/dev/null 2>&1; then
287
+ echo "uninstall succeeded without CONFIRM" >&2
288
+ exit 1
289
+ fi
290
+ make enable | grep -F "already enabled; no changes made."
291
+ make uninstall CONFIRM=REMOVE_MULTI_COMPRESS
292
+ '
293
+ check_err "uninstall removes UDF" "SELECT multi_compress_db_version();"
294
+
295
+ if [ "$fails" -ne 0 ]; then echo "E2E FAILED ($fails)"; exit 1; fi
296
+ echo "E2E OK"