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,74 @@
1
+ EXTENSION = multi_compress
2
+ MODULE_big = multi_compress_pg
3
+ OBJS = src/multi_compress_pg.o src/mcdb_format.o
4
+ DATA = sql/multi_compress--0.5.0.sql
5
+
6
+ PG_CONFIG ?= pg_config
7
+ ZSTD_DIR ?= ../ext/multi_compress/vendor/zstd/lib
8
+ CORE_DIR ?= ../db_core
9
+ BUILD ?= build
10
+ CC ?= cc
11
+ AR ?= ar
12
+
13
+ WARNFLAGS ?= -Wall -Wextra -Werror
14
+ PG_CPPFLAGS += -I$(CORE_DIR)/include -I$(ZSTD_DIR)
15
+ PG_CFLAGS += -O2 -fPIC -std=c99 $(WARNFLAGS)
16
+ EXPORT_MAP := $(CURDIR)/multi_compress_pg.exports
17
+ SHLIB_LINK += -Wl,--version-script=$(EXPORT_MAP) $(ZSTD_LIB)
18
+ EXTRA_CLEAN += $(BUILD)
19
+
20
+ ZSTD_CFLAGS ?= -O2 -fPIC -std=c99 -I$(ZSTD_DIR) -I$(ZSTD_DIR)/common
21
+ ZSTD_OBJ_DIR := $(BUILD)/zstd
22
+ ZSTD_C_SRCS := $(wildcard $(ZSTD_DIR)/common/*.c) $(wildcard $(ZSTD_DIR)/decompress/*.c)
23
+ ZSTD_C_OBJS := $(patsubst $(ZSTD_DIR)/%.c,$(ZSTD_OBJ_DIR)/%.o,$(ZSTD_C_SRCS))
24
+
25
+ ARCH := $(shell uname -m)
26
+ ifneq (,$(filter x86_64 amd64,$(ARCH)))
27
+ ZSTD_ASM_SRC := $(ZSTD_DIR)/decompress/huf_decompress_amd64.S
28
+ ifneq (,$(wildcard $(ZSTD_ASM_SRC)))
29
+ ZSTD_ASM_OBJ := $(ZSTD_OBJ_DIR)/decompress/huf_decompress_amd64.o
30
+ endif
31
+ endif
32
+
33
+ ZSTD_OBJS := $(ZSTD_C_OBJS) $(ZSTD_ASM_OBJ)
34
+ ZSTD_LIB := $(BUILD)/libzstd_dec.a
35
+
36
+ PGXS := $(shell $(PG_CONFIG) --pgxs)
37
+ include $(PGXS)
38
+
39
+ .PHONY: all test parity e2e fixtures
40
+
41
+ all: $(ZSTD_LIB)
42
+
43
+ $(MODULE_big)$(DLSUFFIX): $(ZSTD_LIB)
44
+
45
+ $(BUILD):
46
+ mkdir -p $@
47
+
48
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.c
49
+ @mkdir -p $(dir $@)
50
+ $(CC) $(ZSTD_CFLAGS) -MMD -MP -c $< -o $@
51
+
52
+ $(ZSTD_OBJ_DIR)/%.o: $(ZSTD_DIR)/%.S
53
+ @mkdir -p $(dir $@)
54
+ $(CC) -fPIC -I$(ZSTD_DIR) -c $< -o $@
55
+
56
+ $(ZSTD_LIB): $(ZSTD_OBJS) | $(BUILD)
57
+ $(AR) rcs $@ $^
58
+
59
+ # The source wrapper includes the common MCDB1 decoder; it is not a fork.
60
+ src/mcdb_format.o: src/mcdb_format.c $(CORE_DIR)/src/mcdb_format.c $(CORE_DIR)/include/mcdb_format.h
61
+
62
+ # Golden fixtures are committed. Regeneration is a development action only.
63
+ fixtures:
64
+ RUBYLIB=../lib ruby ../test/fixtures/database_v1/generate.rb
65
+
66
+ parity:
67
+ $(MAKE) -C ../mysql_udf test
68
+
69
+ test: all parity
70
+
71
+ e2e:
72
+ ./test/run_e2e.sh
73
+
74
+ -include $(ZSTD_C_OBJS:.o=.d)
@@ -0,0 +1,112 @@
1
+ # MultiCompress PostgreSQL extension (`MCDB1` reader)
2
+
3
+ This PostgreSQL extension reads the same frozen `MCDB1` envelopes as the Ruby
4
+ API and the MySQL UDF. Rails stores `MultiCompress::Database.compress(text)` in
5
+ `bytea`; PostgreSQL exposes decoded UTF-8 through SQL and a view for DBeaver.
6
+
7
+ It is a separate server-native artifact, not part of `gem install`. The shared
8
+ format contract is [`docs/database-envelope-v1.md`](../docs/database-envelope-v1.md).
9
+
10
+ ## SQL API
11
+
12
+ | Function | Returns | Purpose |
13
+ |---|---:|---|
14
+ | `multi_compress_db_version()` | `text` | reader build, MCDB1 and zstd version smoke test |
15
+ | `multi_compress_db_is_valid(bytea)` | `boolean` | validates an MCDB1 blob without raising |
16
+ | `multi_compress_db_decompress(bytea)` | `text` | returns UTF-8 text or raises on corruption |
17
+
18
+ `multi_compress_db_decompress` requires a UTF-8 PostgreSQL database. MCDB1 does
19
+ not permit NUL bytes, so decoded values are valid PostgreSQL `text` values.
20
+
21
+ ## Production deployment: generated DBA bundle
22
+
23
+ Do **not** ask a DBA to clone this repository, find a release tag, manually copy
24
+ `multi_compress_pg.so`, or guess PostgreSQL install paths. The application
25
+ developer generates a source bundle from the exact installed gem version:
26
+
27
+ ```bash
28
+ bundle exec multi_compress db package postgres --output tmp/multi-compress-postgres.tar.gz
29
+ ```
30
+
31
+ The DBA receives that one archive and runs this on the PostgreSQL host:
32
+
33
+ ```bash
34
+ tar -xzf multi-compress-postgres.tar.gz
35
+ cd multi_compress-postgres-0.5.0
36
+ make verify
37
+ make doctor
38
+ sudo make install
39
+ sudo -u postgres make enable DB=app_production \
40
+ MIGRATION_ROLE=app_migrations READ_ROLE=dbeaver_readonly
41
+ ```
42
+
43
+ `install` compiles the extension against the selected host `pg_config` and puts
44
+ its library/control/SQL files in the directories reported by that exact server.
45
+ Install the matching `postgresql-server-dev-<major>` package and a C toolchain
46
+ beforehand; `make doctor` verifies both.
47
+ For a non-default PostgreSQL install use:
48
+
49
+ ```bash
50
+ sudo make install PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
51
+ ```
52
+
53
+ `enable` rejects non-UTF8 databases and an extension already installed in a
54
+ different schema. It runs `CREATE EXTENSION` once per application database,
55
+ revokes public access, grants the migration role `USAGE` on the extension schema,
56
+ and grants both named roles `EXECUTE` on the decoder functions. The application
57
+ migration must grant the read role `SELECT` on each generated view. Repeat the
58
+ native install on every reporting, failover and restore target before enabling
59
+ or querying MCDB1 there.
60
+
61
+ ## DBeaver: generated readable view
62
+
63
+ Generate the view in the application repository and put the output in a Rails
64
+ migration or versioned SQL. It is application schema, not a hand-typed DBA task:
65
+
66
+ ```bash
67
+ bundle exec multi_compress db view postgres \
68
+ --table app.events \
69
+ --column payload_compressed \
70
+ --view admin.events_readable \
71
+ --columns id,created_at,status \
72
+ --as payload \
73
+ --output db/views/events_readable.sql
74
+ ```
75
+
76
+ DBeaver users only query `admin.events_readable`. Their role needs `USAGE` on
77
+ `admin`, `SELECT` on this view, and `EXECUTE` on the decoder functions granted by
78
+ `make enable`; it does not need `USAGE` on `multi_compress`. If `make enable`
79
+ used a different extension schema, pass it to the generator with
80
+ `--extension-schema that_schema`:
81
+
82
+ ```sql
83
+ SELECT id, created_at, status, payload
84
+ FROM admin.events_readable
85
+ WHERE id = 123;
86
+ ```
87
+
88
+ The view decompresses selected rows. Filter with indexed ordinary columns before
89
+ reading it; do not scan decoded payloads with an unbounded `LIKE '%text%'`.
90
+
91
+ ## Migration and constraints
92
+
93
+ Do not reinterpret an existing plaintext column as MCDB1. Add a new nullable
94
+ `bytea` column, dual-write it from the application, backfill in batches, verify
95
+ `multi_compress.multi_compress_db_is_valid(blob)`, switch reads, then keep the
96
+ old column for a rollback window. MCDB1 v1 is zstd-only, accepts valid UTF-8
97
+ text without NUL bytes, and has a 16 MiB decompressed limit. The target database
98
+ must use UTF-8 encoding for the text-returning function.
99
+
100
+ ## Development source build
101
+
102
+ The source target remains useful for contributors and CI:
103
+
104
+ ```bash
105
+ cd postgres_extension
106
+ make PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
107
+ make test PG_CONFIG=/usr/lib/postgresql/17/bin/pg_config
108
+ make e2e
109
+ ```
110
+
111
+ `test/run_e2e.sh` builds against a pinned PostgreSQL Docker target and is the
112
+ server-side acceptance gate. It is not part of the end-user installation flow.
@@ -0,0 +1,4 @@
1
+ comment = 'MCDB1 reader for MultiCompress database envelopes'
2
+ default_version = '0.5.0'
3
+ module_pathname = '$libdir/multi_compress_pg'
4
+ relocatable = true
@@ -0,0 +1,12 @@
1
+ {
2
+ global:
3
+ Pg_magic_func;
4
+ pg_finfo_multi_compress_db_version;
5
+ pg_finfo_multi_compress_db_is_valid;
6
+ pg_finfo_multi_compress_db_decompress;
7
+ multi_compress_db_version;
8
+ multi_compress_db_is_valid;
9
+ multi_compress_db_decompress;
10
+ local:
11
+ *;
12
+ };
@@ -0,0 +1,12 @@
1
+ -- Install into a dedicated schema.
2
+ CREATE SCHEMA IF NOT EXISTS multi_compress;
3
+ CREATE EXTENSION multi_compress WITH SCHEMA multi_compress;
4
+
5
+ SELECT multi_compress.multi_compress_db_version();
6
+ SELECT multi_compress.multi_compress_db_is_valid(payload_compressed)
7
+ FROM events
8
+ WHERE id = 123;
9
+
10
+ SELECT multi_compress.multi_compress_db_decompress(payload_compressed)
11
+ FROM events
12
+ WHERE id = 123;
@@ -0,0 +1,24 @@
1
+ \echo Use "CREATE EXTENSION multi_compress" to load this file. \quit
2
+
3
+ CREATE FUNCTION multi_compress_db_version()
4
+ RETURNS text
5
+ AS 'MODULE_PATHNAME', 'multi_compress_db_version'
6
+ LANGUAGE C
7
+ IMMUTABLE
8
+ PARALLEL SAFE;
9
+
10
+ CREATE FUNCTION multi_compress_db_is_valid(blob bytea)
11
+ RETURNS boolean
12
+ AS 'MODULE_PATHNAME', 'multi_compress_db_is_valid'
13
+ LANGUAGE C
14
+ IMMUTABLE
15
+ STRICT
16
+ PARALLEL SAFE;
17
+
18
+ CREATE FUNCTION multi_compress_db_decompress(blob bytea)
19
+ RETURNS text
20
+ AS 'MODULE_PATHNAME', 'multi_compress_db_decompress'
21
+ LANGUAGE C
22
+ IMMUTABLE
23
+ STRICT
24
+ PARALLEL SAFE;
@@ -0,0 +1,2 @@
1
+ -- Drop dependent views first, then remove the extension from this database.
2
+ DROP EXTENSION IF EXISTS multi_compress;
@@ -0,0 +1,7 @@
1
+ -- Replace multi_compress with the schema used in CREATE EXTENSION ... WITH SCHEMA.
2
+ CREATE OR REPLACE VIEW events_readable AS
3
+ SELECT
4
+ id,
5
+ multi_compress.multi_compress_db_decompress(payload_compressed) AS payload,
6
+ created_at
7
+ FROM events;
@@ -0,0 +1,2 @@
1
+ /* Shared MCDB1 decoder. The implementation lives once in db_core/. */
2
+ #include "../../db_core/src/mcdb_format.c"
@@ -0,0 +1,96 @@
1
+ #include "postgres.h"
2
+
3
+ #include "fmgr.h"
4
+ #include "mb/pg_wchar.h"
5
+ #include "utils/builtins.h"
6
+ #include "varatt.h"
7
+
8
+ #include <stdio.h>
9
+ #include <stdlib.h>
10
+ #include <string.h>
11
+
12
+ #include <zstd.h>
13
+
14
+ #include "mcdb_format.h"
15
+
16
+ PG_MODULE_MAGIC;
17
+
18
+ PG_FUNCTION_INFO_V1(multi_compress_db_version);
19
+ PG_FUNCTION_INFO_V1(multi_compress_db_is_valid);
20
+ PG_FUNCTION_INFO_V1(multi_compress_db_decompress);
21
+
22
+ static void mcdb_require_utf8_database(void) {
23
+ if (GetDatabaseEncoding() != PG_UTF8)
24
+ ereport(
25
+ ERROR,
26
+ (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
27
+ errmsg("multi_compress requires a UTF-8 PostgreSQL database"),
28
+ errdetail("MCDB1 stores UTF-8 text and multi_compress_db_decompress returns text.")));
29
+ }
30
+
31
+ static void mcdb_raise(mcdb_status status, const char *detail) {
32
+ if (status == MCDB_ERR_ALLOC)
33
+ ereport(ERROR, (errcode(ERRCODE_OUT_OF_MEMORY),
34
+ errmsg("multi_compress: unable to allocate MCDB1 output")));
35
+
36
+ ereport(ERROR,
37
+ (errcode(ERRCODE_DATA_CORRUPTED), errmsg("multi_compress: invalid MCDB1 envelope"),
38
+ errdetail("%s", detail && detail[0] ? detail : mcdb_status_str(status))));
39
+ }
40
+
41
+ Datum multi_compress_db_version(PG_FUNCTION_ARGS) {
42
+ char version[64];
43
+
44
+ (void)fcinfo;
45
+
46
+ snprintf(version, sizeof(version), "multi_compress reader %s; MCDB1; zstd %s",
47
+ MCDB_READER_VERSION, ZSTD_versionString());
48
+ PG_RETURN_TEXT_P(cstring_to_text(version));
49
+ }
50
+
51
+ Datum multi_compress_db_is_valid(PG_FUNCTION_ARGS) {
52
+ bytea *input;
53
+ char err[MCDB_ERRLEN];
54
+ mcdb_status status;
55
+
56
+ if (PG_ARGISNULL(0))
57
+ PG_RETURN_NULL();
58
+
59
+ input = PG_GETARG_BYTEA_PP(0);
60
+ status = mcdb_decode((const unsigned char *)VARDATA_ANY(input),
61
+ (size_t)VARSIZE_ANY_EXHDR(input), NULL, NULL, err);
62
+
63
+ PG_RETURN_BOOL(status == MCDB_OK);
64
+ }
65
+
66
+ Datum multi_compress_db_decompress(PG_FUNCTION_ARGS) {
67
+ bytea *input;
68
+ size_t output_len = 0;
69
+ char err[MCDB_ERRLEN];
70
+ mcdb_status status;
71
+ uint64_t original_size = 0;
72
+ text *result;
73
+
74
+ if (PG_ARGISNULL(0))
75
+ PG_RETURN_NULL();
76
+
77
+ mcdb_require_utf8_database();
78
+
79
+ input = PG_GETARG_BYTEA_PP(0);
80
+ status = mcdb_validate_header((const unsigned char *)VARDATA_ANY(input),
81
+ (size_t)VARSIZE_ANY_EXHDR(input), &original_size);
82
+ if (status != MCDB_OK)
83
+ mcdb_raise(status, mcdb_status_str(status));
84
+
85
+ result = (text *)palloc(VARHDRSZ + (size_t)original_size);
86
+
87
+ status = mcdb_decode_into((const unsigned char *)VARDATA_ANY(input),
88
+ (size_t)VARSIZE_ANY_EXHDR(input), (unsigned char *)VARDATA(result),
89
+ (size_t)original_size, &output_len, err);
90
+ if (status != MCDB_OK)
91
+ mcdb_raise(status, err);
92
+
93
+ SET_VARSIZE(result, VARHDRSZ + output_len);
94
+
95
+ PG_RETURN_TEXT_P(result);
96
+ }
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")/../.."
4
+
5
+ # Platform-specific manifest for postgres:17.9-bookworm on linux/amd64.
6
+ DEFAULT_POSTGRES_IMAGE="postgres:17.9-bookworm@sha256:3a69b9de644363f30110fbaa78b9e3298ce71bb1aa9124c0b0ceb0a2602c3283"
7
+ IMAGE="${MCDB_POSTGRES_IMAGE:-$DEFAULT_POSTGRES_IMAGE}"
8
+ PLATFORM="${MCDB_POSTGRES_PLATFORM:-linux/amd64}"
9
+ CONTAINER="${MCDB_POSTGRES_CONTAINER:-mcdb-postgres-e2e}"
10
+ WAIT_SECONDS="${MCDB_POSTGRES_WAIT_SECONDS:-90}"
11
+ WORK_ROOT="${MCDB_POSTGRES_WORK_ROOT:-$(mktemp -d -t multi-compress-postgres.XXXXXX)}"
12
+ REMOVE_WORK_ROOT=0
13
+ ARCHIVE="$WORK_ROOT/multi_compress-postgres.tar.gz"
14
+ GEM_UNPACK_DIR="$WORK_ROOT/unpacked"
15
+ GEM_FILE="$WORK_ROOT/multi_compress.gem"
16
+ PACKAGED_GEM_ROOT=""
17
+ BUNDLE_PARENT="/opt"
18
+
19
+ command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 2; }
20
+
21
+ cleanup() {
22
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
23
+ [ "$REMOVE_WORK_ROOT" -eq 1 ] && rm -rf "$WORK_ROOT"
24
+ }
25
+ trap cleanup EXIT
26
+
27
+ if [ -z "${MCDB_POSTGRES_WORK_ROOT:-}" ]; then
28
+ REMOVE_WORK_ROOT=1
29
+ fi
30
+
31
+ ensure_ruby_extension() {
32
+ if ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null 2>&1; then
33
+ return
34
+ fi
35
+
36
+ echo "building Ruby extension..."
37
+ if command -v bundle >/dev/null 2>&1; then
38
+ bundle exec rake compile
39
+ else
40
+ rake compile
41
+ fi
42
+ ruby -Ilib -e 'require "multi_compress"; require "multi_compress/database"' >/dev/null
43
+ }
44
+
45
+ require_fixture() {
46
+ local path="$1"
47
+ [ -f "$path" ] || { echo "missing committed fixture: $path" >&2; exit 2; }
48
+ }
49
+
50
+ fixture_hex() {
51
+ ruby -e 'print File.binread(ARGV.fetch(0)).unpack1("H*")' "$1"
52
+ }
53
+
54
+ prepare_packaged_gem() {
55
+ gem build multi_compress.gemspec --output "$GEM_FILE" >/dev/null
56
+ mkdir -p "$GEM_UNPACK_DIR"
57
+ gem unpack "$GEM_FILE" --target "$GEM_UNPACK_DIR" >/dev/null
58
+ shopt -s nullglob
59
+ roots=("$GEM_UNPACK_DIR"/*)
60
+ shopt -u nullglob
61
+ [ "${#roots[@]}" -eq 1 ] && [ -d "${roots[0]}" ] || { echo "could not unpack exactly one multi_compress gem root" >&2; exit 2; }
62
+ PACKAGED_GEM_ROOT="${roots[0]}"
63
+ [ -f "$PACKAGED_GEM_ROOT/multi_compress.gemspec" ] || { echo "packaged gem lacks gemspec" >&2; exit 2; }
64
+ [ -d "$PACKAGED_GEM_ROOT/lib" ] || { echo "packaged gem lacks lib" >&2; exit 2; }
65
+ [ -d "$PACKAGED_GEM_ROOT/exe" ] || { echo "packaged gem lacks exe" >&2; exit 2; }
66
+ [ -f "$PACKAGED_GEM_ROOT/lib/multi_compress/db_deployment.rb" ] || { echo "packaged gem lacks db deployment CLI" >&2; exit 2; }
67
+ }
68
+
69
+ db_cli() {
70
+ ruby -I "$PACKAGED_GEM_ROOT/lib" "$PACKAGED_GEM_ROOT/exe/multi_compress" db "$@"
71
+ }
72
+
73
+ compress_hex() {
74
+ MCDB_TEXT="$1" ruby -Ilib -r multi_compress -r multi_compress/database \
75
+ -e 'print MultiCompress::Database.compress(ENV.fetch("MCDB_TEXT")).unpack1("H*")'
76
+ }
77
+
78
+ bundle_name() {
79
+ ruby -I "$PACKAGED_GEM_ROOT/lib" -r multi_compress/version \
80
+ -e 'print "multi_compress-postgres-#{MultiCompress::VERSION}"'
81
+ }
82
+
83
+ build_deployment_bundle() {
84
+ db_cli package postgres --output "$ARCHIVE" --force
85
+ test -s "$ARCHIVE"
86
+ }
87
+
88
+ install_postgres_build_tools() {
89
+ local runtime_pg_config="$1"
90
+
91
+ docker exec -u 0 \
92
+ -e "MCDB_RUNTIME_PG_CONFIG=$runtime_pg_config" \
93
+ "$CONTAINER" sh -ceu '
94
+ runtime_pg_config="${MCDB_RUNTIME_PG_CONFIG:?missing runtime pg_config}"
95
+ pg_major="$("$runtime_pg_config" --version | awk '\''{print $2}'\'' | cut -d. -f1)"
96
+ case "$pg_major" in
97
+ ""|*[!0-9]*)
98
+ echo "could not determine PostgreSQL major version" >&2
99
+ exit 2
100
+ ;;
101
+ esac
102
+
103
+ if ! command -v make >/dev/null 2>&1 || ! command -v cc >/dev/null 2>&1; then
104
+ if ! command -v apt-get >/dev/null 2>&1; then
105
+ echo "PostgreSQL container has no supported package manager" >&2
106
+ exit 2
107
+ fi
108
+ apt-get update >&2
109
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
110
+ build-essential ca-certificates curl gnupg tar gzip >&2
111
+ fi
112
+
113
+ runtime_pgxs="$("$runtime_pg_config" --pgxs 2>/dev/null || true)"
114
+ runtime_headers="$("$runtime_pg_config" --includedir-server 2>/dev/null || true)"
115
+ if [ ! -f "$runtime_pgxs" ] || [ ! -f "$runtime_headers/postgres.h" ]; then
116
+ if ! command -v apt-get >/dev/null 2>&1; then
117
+ echo "PostgreSQL development headers are unavailable" >&2
118
+ exit 2
119
+ fi
120
+
121
+ install -d -m 0755 /etc/apt/keyrings
122
+ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | \
123
+ gpg --dearmor --yes -o /etc/apt/keyrings/postgresql.gpg
124
+ . /etc/os-release
125
+ echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" \
126
+ >/etc/apt/sources.list.d/pgdg.list
127
+ apt-get update >&2
128
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
129
+ "postgresql-server-dev-${pg_major}" >&2
130
+ fi
131
+
132
+ build_pg_config=""
133
+ for candidate in "/usr/lib/postgresql/${pg_major}/bin/pg_config" "$(command -v pg_config || true)"; do
134
+ [ -n "$candidate" ] && [ -x "$candidate" ] || continue
135
+ pgxs="$("$candidate" --pgxs 2>/dev/null || true)"
136
+ headers="$("$candidate" --includedir-server 2>/dev/null || true)"
137
+ if [ -f "$pgxs" ] && [ -f "$headers/postgres.h" ]; then
138
+ build_pg_config="$candidate"
139
+ break
140
+ fi
141
+ done
142
+
143
+ [ -n "$build_pg_config" ] || {
144
+ echo "PostgreSQL PGXS/server headers are unavailable" >&2
145
+ exit 2
146
+ }
147
+ printf "%s\n" "$build_pg_config"
148
+ '
149
+ }
150
+
151
+ install_bundle_on_db_host() {
152
+ local bundle_name="$1"
153
+ local build_pg_config="$2"
154
+ local runtime_pg_config="$3"
155
+ local bundle_root="${BUNDLE_PARENT}/${bundle_name}"
156
+
157
+ tar -tzf "$ARCHIVE" | grep -Fx "${bundle_name}/THIRD_PARTY_NOTICES.md" >/dev/null
158
+ if tar -tzf "$ARCHIVE" | grep -E '/\.DS_Store$' >/dev/null; then
159
+ echo 'deployment bundle must not contain .DS_Store' >&2
160
+ exit 2
161
+ fi
162
+ docker cp "$ARCHIVE" "$CONTAINER:/tmp/${bundle_name}.tar.gz"
163
+ docker exec -u 0 \
164
+ -e "MCDB_BUNDLE_ROOT=$bundle_root" \
165
+ -e "MCDB_BUILD_PG_CONFIG=$build_pg_config" \
166
+ -e "MCDB_RUNTIME_PG_CONFIG=$runtime_pg_config" \
167
+ "$CONTAINER" sh -ceu '
168
+ rm -rf "$MCDB_BUNDLE_ROOT"
169
+ mkdir -p "$(dirname "$MCDB_BUNDLE_ROOT")"
170
+ tar -xzf "/tmp/$(basename "$MCDB_BUNDLE_ROOT").tar.gz" -C "$(dirname "$MCDB_BUNDLE_ROOT")"
171
+ cd "$MCDB_BUNDLE_ROOT"
172
+ make verify
173
+ make doctor PG_CONFIG="$MCDB_BUILD_PG_CONFIG"
174
+ make install BUILD_PG_CONFIG="$MCDB_BUILD_PG_CONFIG" RUNTIME_PG_CONFIG="$MCDB_RUNTIME_PG_CONFIG"
175
+ '
176
+
177
+ }
178
+
179
+ apply_readable_view() {
180
+ local path="$1"
181
+ db_cli view postgres \
182
+ --table app.events \
183
+ --column payload_compressed \
184
+ --view admin.events_readable \
185
+ --columns id \
186
+ --output "$path"
187
+ docker exec -i "$CONTAINER" psql -X -U app_migrations -d postgres -v ON_ERROR_STOP=1 < "$path"
188
+ }
189
+
190
+ ensure_ruby_extension
191
+ prepare_packaged_gem
192
+ build_deployment_bundle
193
+
194
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
195
+ require_fixture "test/fixtures/database_v1/${name}.mcdb"
196
+ done
197
+
198
+ TEXT='Привет, DBeaver! Сәлем! 🌍'
199
+ BIG=$(ruby -e 'print "a" * 300')
200
+ VALID_HEX=$(compress_hex "$TEXT")
201
+ BIG_HEX=$(compress_hex "$BIG")
202
+ BUNDLE_NAME="$(bundle_name)"
203
+ VIEW_SQL="$(mktemp -t multi-compress-postgres-view.XXXXXX.sql)"
204
+ trap 'rm -f "$VIEW_SQL"; cleanup' EXIT
205
+
206
+ docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
207
+ docker run --platform "$PLATFORM" -d --name "$CONTAINER" \
208
+ -e POSTGRES_HOST_AUTH_METHOD=trust "$IMAGE" >/dev/null
209
+
210
+ echo "waiting for PostgreSQL in $IMAGE..."
211
+ deadline=$(( $(date +%s) + WAIT_SECONDS ))
212
+ until docker exec "$CONTAINER" pg_isready -U postgres -d postgres >/dev/null 2>&1; do
213
+ if [ "$(date +%s)" -ge "$deadline" ]; then
214
+ echo "PostgreSQL did not become ready within ${WAIT_SECONDS}s" >&2
215
+ docker logs "$CONTAINER" >&2 || true
216
+ exit 1
217
+ fi
218
+ sleep 2
219
+ done
220
+
221
+ q() { docker exec "$CONTAINER" psql -X -U postgres -d postgres -Atq -v ON_ERROR_STOP=1 -c "$1"; }
222
+ qfail() {
223
+ if q "$1" >/dev/null 2>&1; then return 1; else return 0; fi
224
+ }
225
+ q_read() { docker exec "$CONTAINER" psql -X -U dbeaver_readonly -d postgres -Atq -v ON_ERROR_STOP=1 -c "$1"; }
226
+ q_read_fail() {
227
+ if q_read "$1" >/dev/null 2>&1; then return 1; else return 0; fi
228
+ }
229
+
230
+ RUNTIME_PG_CONFIG=$(docker exec "$CONTAINER" sh -ceu 'command -v pg_config' | tr -d '\r\n')
231
+ [ -n "$RUNTIME_PG_CONFIG" ] || { echo "PostgreSQL runtime does not provide pg_config" >&2; exit 2; }
232
+ BUILD_PG_CONFIG=$(install_postgres_build_tools "$RUNTIME_PG_CONFIG")
233
+ install_bundle_on_db_host "$BUNDLE_NAME" "$BUILD_PG_CONFIG" "$RUNTIME_PG_CONFIG"
234
+
235
+ q "CREATE ROLE app_migrations LOGIN;
236
+ CREATE ROLE dbeaver_readonly LOGIN;
237
+ CREATE SCHEMA app;
238
+ CREATE SCHEMA admin;
239
+ CREATE TABLE app.events (id integer PRIMARY KEY, payload_compressed bytea NULL);
240
+ INSERT INTO app.events VALUES
241
+ (1, decode('$VALID_HEX', 'hex')),
242
+ (2, NULL),
243
+ (3, decode('$BIG_HEX', 'hex')),
244
+ (4, decode('$VALID_HEX', 'hex'));
245
+ GRANT USAGE ON SCHEMA app, admin TO app_migrations, dbeaver_readonly;
246
+ GRANT CREATE ON SCHEMA admin TO app_migrations;
247
+ GRANT SELECT ON app.events TO app_migrations;"
248
+ docker exec -u postgres -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" "$CONTAINER" sh -ceu '
249
+ cd "$MCDB_BUNDLE_ROOT"
250
+ make enable DB=postgres MIGRATION_ROLE=app_migrations READ_ROLE=dbeaver_readonly
251
+ '
252
+ apply_readable_view "$VIEW_SQL"
253
+ q 'GRANT SELECT ON admin.events_readable TO dbeaver_readonly;'
254
+
255
+ q "CREATE DATABASE mcdb_latin1 WITH TEMPLATE template0 ENCODING 'LATIN1' LC_COLLATE 'C' LC_CTYPE 'C';"
256
+ docker exec -u postgres -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" "$CONTAINER" sh -ceu '
257
+ cd "$MCDB_BUNDLE_ROOT"
258
+ if make enable DB=mcdb_latin1 MIGRATION_ROLE=app_migrations READ_ROLE=dbeaver_readonly >/tmp/mcdb-latin1.out 2>&1; then
259
+ echo "LATIN1 enable unexpectedly succeeded" >&2
260
+ exit 1
261
+ fi
262
+ grep -F "requires UTF8" /tmp/mcdb-latin1.out
263
+ '
264
+ q "CREATE DATABASE mcdb_other_schema WITH TEMPLATE template0 ENCODING 'UTF8';"
265
+ docker exec "$CONTAINER" psql -X -U postgres -d mcdb_other_schema -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA wrong_schema; CREATE EXTENSION multi_compress WITH SCHEMA wrong_schema;' >/dev/null
266
+ docker exec -u postgres -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" "$CONTAINER" sh -ceu '
267
+ cd "$MCDB_BUNDLE_ROOT"
268
+ if make enable DB=mcdb_other_schema SCHEMA=multi_compress MIGRATION_ROLE=app_migrations READ_ROLE=dbeaver_readonly >/tmp/mcdb-schema.out 2>&1; then
269
+ echo "mismatched extension schema unexpectedly succeeded" >&2
270
+ exit 1
271
+ fi
272
+ grep -F "already installed in schema wrong_schema" /tmp/mcdb-schema.out
273
+ '
274
+
275
+ fails=0
276
+ check() {
277
+ if [ "$2" = "$3" ]; then echo " PASS $1"; else echo " FAIL $1 (got [$2] want [$3])"; fails=$((fails + 1)); fi
278
+ }
279
+ check_err() {
280
+ if qfail "$2"; then echo " PASS $1 (rejected)"; else echo " FAIL $1 (should have errored)"; fails=$((fails + 1)); fi
281
+ }
282
+
283
+ check "UTF8 database" "$(q 'SHOW server_encoding;' | tr -d '\r\n')" "UTF8"
284
+ check "deployment bundle version() smoke" "$(q 'SELECT strpos(multi_compress.multi_compress_db_version(), chr(77) || chr(67) || chr(68) || chr(66) || chr(49)) > 0;' | tr -d '\r\n')" "t"
285
+ check "PostgreSQL reader uses vendored zstd" "$(q "SELECT strpos(multi_compress.multi_compress_db_version(), 'zstd 1.5.7') > 0;" | tr -d '\r\n')" "t"
286
+ check "decode roundtrip" "$(q 'SELECT multi_compress.multi_compress_db_decompress(payload_compressed) FROM app.events WHERE id=1;' | tr -d '\r')" "$TEXT"
287
+ check "NULL -> NULL" "$(q 'SELECT multi_compress.multi_compress_db_decompress(payload_compressed) IS NULL FROM app.events WHERE id=2;' | tr -d '\r\n')" "t"
288
+ check "big (>255, >input)" "$(q 'SELECT octet_length(multi_compress.multi_compress_db_decompress(payload_compressed)) FROM app.events WHERE id=3;' | tr -d '\r\n')" "300"
289
+ check "generated readable view" "$(q 'SELECT count(*) FROM admin.events_readable WHERE id IN (1, 4);' | tr -d '\r\n')" "2"
290
+ check "read role reads generated Unicode view" "$(q_read 'SELECT payload FROM admin.events_readable WHERE id=1;' | tr -d '\r')" "$TEXT"
291
+ if q_read_fail 'SELECT payload_compressed FROM app.events WHERE id=1;'; then
292
+ echo " PASS read role cannot read source table"
293
+ else
294
+ echo " FAIL read role unexpectedly read source table"
295
+ fails=$((fails + 1))
296
+ fi
297
+ RUNTIME_MAJOR="$(docker exec "$CONTAINER" "$RUNTIME_PG_CONFIG" --version | awk '{print $2}' | cut -d. -f1 | tr -d '\r\n')"
298
+ SERVER_MAJOR="$(q 'SHOW server_version;' | cut -d. -f1 | tr -d '\r\n')"
299
+ check "connected server major matches PG_CONFIG" "$SERVER_MAJOR" "$RUNTIME_MAJOR"
300
+ check "is_valid true" "$(q 'SELECT multi_compress.multi_compress_db_is_valid(payload_compressed) FROM app.events WHERE id=1;' | tr -d '\r\n')" "t"
301
+ check "is_valid on NULL" "$(q 'SELECT multi_compress.multi_compress_db_is_valid(payload_compressed) IS NULL FROM app.events WHERE id=2;' | tr -d '\r\n')" "t"
302
+
303
+ for name in corrupt_magic corrupt_payload corrupt_crc invalid_utf8 nul_text trailing_skippable trailing_frame; do
304
+ hex=$(fixture_hex "test/fixtures/database_v1/${name}.mcdb")
305
+ check_err "$name errors" "SELECT multi_compress.multi_compress_db_decompress(decode('$hex', 'hex'));"
306
+ done
307
+ check "is_valid false (crc)" "$(q "SELECT multi_compress.multi_compress_db_is_valid(decode('$(fixture_hex test/fixtures/database_v1/corrupt_crc.mcdb)', 'hex'));" | tr -d '\r\n')" "f"
308
+
309
+ q 'DROP VIEW admin.events_readable; DROP EXTENSION multi_compress;'
310
+ docker exec "$CONTAINER" psql -X -U postgres -d mcdb_other_schema -v ON_ERROR_STOP=1 -c 'DROP EXTENSION multi_compress;' >/dev/null
311
+ docker exec -u 0 -e "MCDB_BUNDLE_ROOT=${BUNDLE_PARENT}/${BUNDLE_NAME}" -e "MCDB_RUNTIME_PG_CONFIG=$RUNTIME_PG_CONFIG" "$CONTAINER" sh -ceu '
312
+ cd "$MCDB_BUNDLE_ROOT"
313
+ if make uninstall RUNTIME_PG_CONFIG="$MCDB_RUNTIME_PG_CONFIG" >/dev/null 2>&1; then
314
+ echo "uninstall succeeded without CONFIRM" >&2
315
+ exit 1
316
+ fi
317
+ make uninstall CONFIRM=REMOVE_MULTI_COMPRESS RUNTIME_PG_CONFIG="$MCDB_RUNTIME_PG_CONFIG"
318
+ '
319
+ check_err "uninstall removes functions" "SELECT multi_compress.multi_compress_db_version();"
320
+ check_err "uninstall removes extension files" "CREATE EXTENSION multi_compress;"
321
+
322
+ if [ "$fails" -ne 0 ]; then echo "E2E FAILED ($fails)"; exit 1; fi
323
+ echo "E2E OK"