From 53b58e3a3c75b0f046000dafba08d7bcdd300eff Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 14:35:00 -0400 Subject: [PATCH 01/10] Import postgres-plugin source from tabularis@ad765f3a Verbatim copy of src/, Cargo.toml, Cargo.lock, and .tabularium from TabularisDB/tabularis's plugins/postgres-plugin/ at commit ad765f3a (82/82 parity tests green per that commit's message). This is a parallel copy, not a move: nothing has been removed from the source repo. Manifest/Cargo.toml adaptation for the standalone repo happens in a follow-up commit. --- .tabularium | 70 ++ Cargo.lock | 1997 +++++++++++++++++++++++++++++++++ Cargo.toml | 32 + src/binding.rs | 387 +++++++ src/binding_tests.rs | 313 ++++++ src/client.rs | 291 +++++ src/client_tests.rs | 79 ++ src/error.rs | 22 + src/extract.rs | 550 +++++++++ src/handlers/blob.rs | 96 ++ src/handlers/blob_tests.rs | 28 + src/handlers/connection.rs | 35 + src/handlers/crud.rs | 188 ++++ src/handlers/ddl.rs | 327 ++++++ src/handlers/ddl_tests.rs | 282 +++++ src/handlers/metadata.rs | 768 +++++++++++++ src/handlers/mod.rs | 8 + src/handlers/query.rs | 259 +++++ src/main.rs | 55 + src/models.rs | 70 ++ src/rpc.rs | 109 ++ src/utils/identifiers.rs | 11 + src/utils/mod.rs | 6 + src/utils/pagination.rs | 120 ++ src/utils/pagination_tests.rs | 70 ++ 25 files changed, 6173 insertions(+) create mode 100644 .tabularium create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/binding.rs create mode 100644 src/binding_tests.rs create mode 100644 src/client.rs create mode 100644 src/client_tests.rs create mode 100644 src/error.rs create mode 100644 src/extract.rs create mode 100644 src/handlers/blob.rs create mode 100644 src/handlers/blob_tests.rs create mode 100644 src/handlers/connection.rs create mode 100644 src/handlers/crud.rs create mode 100644 src/handlers/ddl.rs create mode 100644 src/handlers/ddl_tests.rs create mode 100644 src/handlers/metadata.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/query.rs create mode 100644 src/main.rs create mode 100644 src/models.rs create mode 100644 src/rpc.rs create mode 100644 src/utils/identifiers.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/pagination.rs create mode 100644 src/utils/pagination_tests.rs diff --git a/.tabularium b/.tabularium new file mode 100644 index 0000000..17e1bee --- /dev/null +++ b/.tabularium @@ -0,0 +1,70 @@ +{ + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "id": "postgres-plugin", + "name": "postgres-plugin", + "version": "0.1.0", + "description": "PostgreSQL plugin driver for Tabularis (parity implementation)", + "kind": "driver", + "engine": "postgresql", + "paradigms": ["relational"], + "default_port": 5432, + "default_username": "postgres", + "executable": "postgresql-plugin", + "capabilities": { + "schemas": true, + "views": true, + "materialized_views": true, + "routines": true, + "routine_management": true, + "triggers": true, + "file_based": false, + "folder_based": false, + "connection_string": true, + "connection_string_example": "postgres://user:pass@localhost:5432/db", + "identifier_quote": "\"", + "sql_dialect": "postgres", + "alter_primary_key": true, + "alter_column": true, + "create_foreign_keys": true, + "manage_tables": true, + "supports_ssl": true, + "explain": true, + "readonly": false, + "no_connection_required": false, + "serial_type": "SERIAL", + "auto_increment_keyword": "", + "inline_pk": false + }, + "type_mappings": { + "DATETIME": "TIMESTAMP", + "JSON": "JSONB" + }, + "data_types": [ + {"name": "SMALLINT", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "INTEGER", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "BIGINT", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "SERIAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "BIGSERIAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "REAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "DOUBLE PRECISION", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "NUMERIC", "category": "numeric", "requires_length": false, "requires_precision": true}, + {"name": "DECIMAL", "category": "numeric", "requires_length": false, "requires_precision": true}, + {"name": "MONEY", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "CHAR", "category": "string", "requires_length": true, "requires_precision": false}, + {"name": "VARCHAR", "category": "string", "requires_length": true, "requires_precision": false}, + {"name": "TEXT", "category": "string", "requires_length": false, "requires_precision": false}, + {"name": "DATE", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIME", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIMESTAMP", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIMESTAMPTZ", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "INTERVAL", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "BOOLEAN", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "UUID", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "JSON", "category": "json", "requires_length": false, "requires_precision": false}, + {"name": "JSONB", "category": "json", "requires_length": false, "requires_precision": false}, + {"name": "BYTEA", "category": "binary", "requires_length": false, "requires_precision": false}, + {"name": "INET", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "CIDR", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "MACADDR", "category": "other", "requires_length": false, "requires_precision": false} + ] +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c3d9cc1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1997 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid 0.10.2", + "crypto-common", + "ctutils", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "array-init", + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + +[[package]] +name = "postgresql-plugin" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "deadpool-postgres", + "infer", + "log", + "rust_decimal", + "rustls", + "rustls-platform-verifier", + "serde", + "serde_json", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "uuid", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "postgres-types", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid 0.9.6", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b7da35 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "postgresql-plugin" +version = "0.1.0" +edition = "2021" +description = "PostgreSQL plugin driver for Tabularis" +publish = false + +[[bin]] +name = "postgresql-plugin" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std"] } +tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } +deadpool-postgres = "0.14" +tokio-postgres-rustls = "0.13" +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +rustls-platform-verifier = "0.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.20", features = ["v4", "serde"] } +rust_decimal = { version = "1.36", features = ["db-tokio-postgres", "serde"] } +async-trait = "0.1" +log = "0.4" +base64 = "0.22.1" +infer = "0.16" + +[profile.release] +lto = true +codegen-units = 1 +strip = "symbols" diff --git a/src/binding.rs b/src/binding.rs new file mode 100644 index 0000000..96be353 --- /dev/null +++ b/src/binding.rs @@ -0,0 +1,387 @@ +//! Value binding for INSERT/UPDATE — converts JSON values into SQL fragments +//! and typed bind parameters, matching the built-in driver's binding cascade +//! exactly (`src-tauri/src/drivers/postgres/binding.rs`). +//! +//! Why the explicit `Type` matters: `tokio-postgres`'s `prepare_typed` lets +//! the caller pin a placeholder's wire type instead of letting the server +//! infer it from query context. When a bound value's natural Rust type +//! (e.g. `String`) doesn't match what the surrounding SQL implies (e.g. +//! `CAST($N AS uuid)`), the client-side check rejects the bind before the +//! value reaches PostgreSQL's own parser. The fix: emit `CAST($N AS )` +//! in the SQL text and pin the placeholder's `Type` to `TEXT` so tokio-postgres +//! doesn't fight the CAST. + +use rust_decimal::Decimal; +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; +use uuid::Uuid; + +pub type PgParam = Box; +pub type TypedPgParam = (PgParam, Type); + +pub struct BoundValue { + pub sql: String, + pub param: Option, +} + +impl std::fmt::Debug for BoundValue { + // `dyn ToSql + Sync` isn't Debug, so a derive won't work — show just the + // SQL fragment and whether a parameter is bound (sufficient for + // .unwrap_err() panic messages and test assertion failures). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoundValue") + .field("sql", &self.sql) + .field("param", &self.param.as_ref().map(|(_, ty)| ty.clone())) + .finish() + } +} + +#[derive(Default)] +pub struct BindOptions<'a> { + pub column_type: Option<&'a str>, + /// Schema-qualified, already-quoted enum type name (e.g. `"public"."mood"`) + /// when the target column is a PostgreSQL enum; `None` otherwise. Drives + /// the `CAST($N AS )` coercion in [`bind_pg_enum_string`]. + pub enum_type: Option<&'a str>, + pub allow_default: bool, +} + +const USE_DEFAULT_SENTINEL: &str = "__USE_DEFAULT__"; + +/// Normalize a column type string: strip a trailing `(...)` and uppercase. +/// e.g. `"varchar(255)"` -> `"VARCHAR"`. +fn extract_base_type(column_type: &str) -> String { + let base = column_type.split('(').next().unwrap_or(column_type); + base.trim().to_uppercase() +} + +/// Bind a JSON value to a SQL fragment + optional typed parameter. +pub fn bind_pg_value( + value: Value, + placeholder_idx: usize, + options: &BindOptions, +) -> Result { + let base_type = options.column_type.map(extract_base_type); + + // JSON/JSONB columns receiving a native JSON value (object/array/number/bool) + // must bind the value's own ToSql JSON encoding — a text CAST trips an OID + // mismatch for json/jsonb columns. + if let Some(ref bt) = base_type { + if (bt == "JSON" || bt == "JSONB") && !matches!(value, Value::String(_) | Value::Null) { + let ty = if bt == "JSONB" { Type::JSONB } else { Type::JSON }; + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(value), ty)), + }); + } + } + + match value { + Value::Number(n) => bind_pg_number(n, placeholder_idx), + Value::String(s) => bind_pg_string(&s, placeholder_idx, options, base_type.as_deref()), + Value::Bool(b) => Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(b), Type::BOOL)), + }), + Value::Null => Ok(BoundValue { + sql: "NULL".to_string(), + param: None, + }), + Value::Array(arr) => { + let literal = json_array_to_pg_literal(&arr)?; + Ok(BoundValue { + sql: literal, + param: None, + }) + } + Value::Object(_) => Err("Cannot bind a JSON object to a non-JSON column".to_string()), + } +} + +fn bind_pg_number(n: serde_json::Number, placeholder_idx: usize) -> Result { + if let Some(i) = n.as_i64() { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }) + } else if let Some(f) = n.as_f64() { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }) + } else { + Err("Unsupported numeric value".to_string()) + } +} + +fn bind_pg_string( + s: &str, + placeholder_idx: usize, + options: &BindOptions, + base_type: Option<&str>, +) -> Result { + // 1. DEFAULT sentinel (update only) + if options.allow_default && s == USE_DEFAULT_SENTINEL { + return Ok(BoundValue { + sql: "DEFAULT".to_string(), + param: None, + }); + } + + // 2. Blob wire format — must run before the boolean/numeric heuristics + // below, since a base64 blob string could otherwise look like a + // plausible (if garbage) numeric/boolean value for a mistyped column. + if let Some(bytes) = decode_blob_wire_format(s) { + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(bytes), Type::BYTEA)), + }); + } + + // 3. Enum column — always coerces through its own type. Any of the later + // shape-based heuristics (uuid-shaped, array-shaped strings) would + // otherwise misinterpret a label that merely looks like one of those. + if let Some(enum_type) = options.enum_type { + return Ok(bind_pg_enum_string(s, enum_type, placeholder_idx)); + } + + // 4. Boolean column + if matches!(base_type, Some("BOOLEAN") | Some("BOOL")) { + let lower = s.trim().to_lowercase(); + let b = match lower.as_str() { + "true" | "t" | "yes" | "y" | "on" | "1" => true, + "false" | "f" | "no" | "n" | "off" | "0" => false, + _ => { + return Err(format!( + "Cannot bind '{}' as boolean for target type BOOLEAN", + s + )) + } + }; + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(b), Type::BOOL)), + }); + } + + // 5. Numeric column + if let Some(bt) = base_type { + match bt { + "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" | "SERIAL" + | "BIGSERIAL" => { + let i: i64 = s + .parse() + .map_err(|_| format!("Cannot bind '{}' as integer for target type {}", s, bt))?; + return Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }); + } + "NUMERIC" | "DECIMAL" => { + let d: Decimal = s + .parse() + .map_err(|_| format!("Cannot bind '{}' as numeric for target type {}", s, bt))?; + return Ok(BoundValue { + sql: format!("CAST(${} AS numeric)", placeholder_idx), + param: Some((Box::new(d), Type::NUMERIC)), + }); + } + "REAL" | "DOUBLE PRECISION" | "FLOAT4" | "FLOAT8" => { + let f: f64 = s + .parse() + .map_err(|_| format!("Cannot bind '{}' as float for target type {}", s, bt))?; + return Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }); + } + _ => {} + } + } + + // 6. Temporal column + if let Some(bt) = base_type { + let cast_target = match bt { + "TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => Some("timestamp"), + "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => Some("timestamptz"), + "DATE" => Some("date"), + "TIME" | "TIME WITHOUT TIME ZONE" => Some("time"), + "TIMETZ" | "TIME WITH TIME ZONE" => Some("timetz"), + "INTERVAL" => Some("interval"), + _ => None, + }; + if let Some(target) = cast_target { + return Ok(BoundValue { + sql: format!("CAST(${} AS {})", placeholder_idx, target), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }); + } + } + + // 7. UUID shape (value-based fallback, independent of column type) + if s.parse::().is_ok() { + return Ok(BoundValue { + sql: format!("CAST(${} AS uuid)", placeholder_idx), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }); + } + + // 8. PG array literal (JSON array embedded in a string, e.g. "[1,2,3]") + let trimmed = s.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + if let Ok(Value::Array(arr)) = serde_json::from_str::(trimmed) { + let literal = json_array_to_pg_literal(&arr)?; + return Ok(BoundValue { + sql: literal, + param: None, + }); + } + } + + // 9. Final fallback: plain TEXT + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }) +} + +/// Bind a value into an enum column via `CAST($N AS )`. +/// The placeholder is pinned to `TEXT` so tokio-postgres does not reject the +/// bound `String` client-side before the CAST resolves it server-side. +/// `qualified_enum` must already be quoted (see `quote_qualified_type` in +/// `client.rs`) so it cannot become a SQL-injection vector. +fn bind_pg_enum_string(s: &str, qualified_enum: &str, placeholder_idx: usize) -> BoundValue { + BoundValue { + sql: format!("CAST(${} AS {})", placeholder_idx, qualified_enum), + param: Some((Box::new(s.to_string()), Type::TEXT)), + } +} + +/// Decode the canonical BLOB wire format back to raw bytes. +/// +/// Expected format: `"BLOB:::"`. +/// Returns `None` if the string doesn't match, so it falls through to the +/// rest of the binding cascade as a plain string. Matches +/// `decode_blob_wire_format` in `src-tauri/src/drivers/common/blob.rs` +/// (this plugin doesn't yet support the `BLOB_FILE_REF:` variant since +/// that requires filesystem access outside the scope of value binding). +fn decode_blob_wire_format(value: &str) -> Option> { + let rest = value.strip_prefix("BLOB:")?; + // Skip the size field, then the mime field. + let after_size = rest.splitn(2, ':').nth(1)?; + let base64_data = after_size.splitn(2, ':').nth(1)?; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data).ok() +} + +/// Convert a JSON array to a PostgreSQL `ARRAY[...]` literal string. +/// Recursively handles nested arrays (multi-dimensional PG arrays). +fn json_array_to_pg_literal(arr: &[Value]) -> Result { + let mut parts = Vec::with_capacity(arr.len()); + for elem in arr { + let part = match elem { + Value::String(s) => format!("'{}'", s.replace('\'', "''")), + Value::Number(n) => n.to_string(), + Value::Bool(b) => if *b { "TRUE".to_string() } else { "FALSE".to_string() }, + Value::Null => "NULL".to_string(), + Value::Array(nested) => json_array_to_pg_literal(nested)?, + Value::Object(_) => return Err("Unsupported array element type".to_string()), + }; + parts.push(part); + } + Ok(format!("ARRAY[{}]", parts.join(", "))) +} + +/// Bind a WHERE-clause value from a PK map entry. Returns the SQL fragment +/// (may include a CAST) plus the typed parameter — stricter than +/// `bind_pg_value` for strings: UUID/integer string coercion is only applied +/// when the column's real type is confirmed (or unknown), matching +/// `build_pk_predicate` in the built-in driver. +pub fn bind_pk_value( + value: &Value, + placeholder_idx: usize, + column_type: Option<&str>, +) -> Result { + let base_type = column_type.map(extract_base_type); + + match value { + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }) + } else if let Some(f) = n.as_f64() { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }) + } else { + Err("Unsupported numeric PK value".to_string()) + } + } + Value::String(s) => { + let is_uuid_type = base_type.as_deref().map_or(true, |t| t == "UUID"); + if is_uuid_type { + if let Ok(uuid) = s.parse::() { + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(uuid), Type::UUID)), + }); + } + } + + let is_int_type = base_type.as_deref().map_or(true, |t| { + matches!( + t, + "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" + ) + }); + if is_int_type { + if let Ok(i) = s.parse::() { + return Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }); + } + } + + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(s.clone()), Type::TEXT)), + }) + } + _ => Err("Unsupported PK type".to_string()), + } +} + +/// Build a compound `WHERE` predicate from every entry of a pk_map, sorted +/// alphabetically by key for determinism (matches the builtin's composite-PK +/// ordering). Returns the predicate string (e.g. `"a" = $1 AND "b" = $2`) and +/// the typed parameters, starting at `placeholder_idx`. Shared by +/// update_record, delete_record, save_blob_to_file, and fetch_blob_as_data_url +/// — every method that identifies one row by primary key. +pub fn build_pk_map_predicate( + pk_map: &serde_json::Map, + column_types: &std::collections::HashMap, + placeholder_idx: usize, +) -> Result<(String, Vec), String> { + let mut keys: Vec<&String> = pk_map.keys().collect(); + keys.sort(); + + let mut predicates: Vec = Vec::with_capacity(keys.len()); + let mut owned_params: Vec = Vec::new(); + let mut idx = placeholder_idx; + + for key in keys { + let val = &pk_map[key]; + let pk_type = column_types.get(key).map(String::as_str); + let bound = bind_pk_value(val, idx, pk_type)?; + predicates.push(format!("\"{}\" = {}", key.replace('"', "\"\""), bound.sql)); + if let Some(param) = bound.param { + owned_params.push(param); + idx += 1; + } + } + + Ok((predicates.join(" AND "), owned_params)) +} diff --git a/src/binding_tests.rs b/src/binding_tests.rs new file mode 100644 index 0000000..278467d --- /dev/null +++ b/src/binding_tests.rs @@ -0,0 +1,313 @@ +//! Unit tests for `binding.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod binding_tests;`. + +use crate::binding::{bind_pg_value, bind_pk_value, BindOptions}; +use serde_json::json; + +mod bind_pg_value_tests { + use super::*; + + #[test] + fn number_binds_as_bigint_cast() { + let bound = bind_pg_value(json!(42), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + assert!(bound.param.is_some()); + } + + #[test] + fn float_number_binds_as_double_precision_cast() { + let bound = bind_pg_value(json!(1.5), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS double precision)"); + } + + #[test] + fn bool_binds_natively_without_cast() { + let bound = bind_pg_value(json!(true), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn null_binds_as_inline_keyword_with_no_parameter() { + let bound = bind_pg_value(json!(null), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "NULL"); + assert!(bound.param.is_none()); + } + + #[test] + fn array_binds_as_inline_literal_with_no_parameter() { + let bound = bind_pg_value(json!([1, 2, 3]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[1, 2, 3]"); + assert!(bound.param.is_none()); + } + + #[test] + fn nested_array_binds_recursively() { + let bound = bind_pg_value(json!([[1, 2], [3, 4]]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[ARRAY[1, 2], ARRAY[3, 4]]"); + } + + #[test] + fn string_array_escapes_single_quotes() { + let bound = bind_pg_value(json!(["it's", "ok"]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY['it''s', 'ok']"); + } + + #[test] + fn object_without_json_column_type_is_rejected() { + let err = bind_pg_value(json!({"a": 1}), 1, &BindOptions::default()).unwrap_err(); + assert!(err.contains("Cannot bind a JSON object")); + } + + #[test] + fn object_with_jsonb_column_type_binds_natively() { + let options = BindOptions { + column_type: Some("jsonb"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!({"a": 1}), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn json_string_value_does_not_take_native_json_path() { + // A JSON *string* (not object/array) still goes through the generic + // string cascade even when the column is jsonb — matches the builtin's + // "value is neither String nor Null" gate. + let options = BindOptions { + column_type: Some("jsonb"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("{\"a\":1}"), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn default_sentinel_only_honored_when_allow_default_is_true() { + let options = BindOptions { + column_type: None, + enum_type: None, + allow_default: true, + }; + let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); + assert_eq!(bound.sql, "DEFAULT"); + assert!(bound.param.is_none()); + } + + #[test] + fn default_sentinel_ignored_on_insert_allow_default_false() { + let options = BindOptions { + column_type: None, + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); + // Falls through to the plain TEXT fallback, not treated as DEFAULT. + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn blob_wire_format_decodes_to_bytea_before_other_heuristics() { + // "yv66vg==" is base64 for [0xCA, 0xFE, 0xBA, 0xBE]. + let bound = bind_pg_value( + json!("BLOB:4:application/octet-stream:yv66vg=="), + 1, + &BindOptions::default(), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn enum_column_binds_with_qualified_cast() { + let options = BindOptions { + column_type: None, + enum_type: Some("\"test_schema\".\"mood\""), + allow_default: false, + }; + let bound = bind_pg_value(json!("sad"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS \"test_schema\".\"mood\")"); + assert!(bound.param.is_some()); + } + + #[test] + fn enum_column_takes_precedence_over_uuid_shape() { + // A value that happens to look like a UUID must still bind through + // the enum CAST if the column is an enum — the enum step runs before + // the UUID-shape heuristic in the cascade. + let options = BindOptions { + column_type: None, + enum_type: Some("\"public\".\"status\""), + allow_default: false, + }; + let bound = bind_pg_value( + json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + &options, + ) + .unwrap(); + assert_eq!(bound.sql, "CAST($1 AS \"public\".\"status\")"); + } + + #[test] + fn boolean_column_accepts_common_truthy_strings() { + let options = BindOptions { + column_type: Some("boolean"), + enum_type: None, + allow_default: false, + }; + for truthy in ["true", "t", "yes", "y", "on", "1", "TRUE"] { + let bound = bind_pg_value(json!(truthy), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1", "input: {truthy}"); + } + } + + #[test] + fn boolean_column_rejects_invalid_string() { + let options = BindOptions { + column_type: Some("boolean"), + enum_type: None, + allow_default: false, + }; + let err = bind_pg_value(json!("maybe"), 1, &options).unwrap_err(); + assert!(err.contains("boolean")); + } + + #[test] + fn integer_column_string_binds_as_bigint_cast() { + let options = BindOptions { + column_type: Some("integer"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("42"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn integer_column_rejects_non_numeric_string() { + let options = BindOptions { + column_type: Some("integer"), + enum_type: None, + allow_default: false, + }; + let err = bind_pg_value(json!("not-a-number"), 1, &options).unwrap_err(); + assert!(err.contains("integer")); + } + + #[test] + fn numeric_column_string_binds_as_numeric_cast() { + let options = BindOptions { + column_type: Some("numeric"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("12345.67"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS numeric)"); + } + + #[test] + fn timestamp_column_string_binds_with_timestamp_cast() { + let options = BindOptions { + column_type: Some("timestamp"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("2026-01-15 14:30:00"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS timestamp)"); + } + + #[test] + fn timestamptz_column_string_binds_with_timestamptz_cast() { + let options = BindOptions { + column_type: Some("timestamptz"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("2026-01-15 14:30:00+00"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS timestamptz)"); + } + + #[test] + fn uuid_shaped_string_binds_with_uuid_cast_regardless_of_column_type() { + let bound = bind_pg_value( + json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + &BindOptions::default(), + ) + .unwrap(); + assert_eq!(bound.sql, "CAST($1 AS uuid)"); + } + + #[test] + fn array_literal_embedded_in_string_is_parsed_as_pg_array() { + let bound = bind_pg_value(json!("[1,2,3]"), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[1, 2, 3]"); + assert!(bound.param.is_none()); + } + + #[test] + fn plain_string_falls_through_to_text_binding() { + let bound = bind_pg_value(json!("hello world"), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } +} + +mod bind_pk_value_tests { + use super::*; + + #[test] + fn integer_pk_binds_as_bigint_cast() { + let bound = bind_pk_value(&json!(42), 1, None).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn uuid_string_pk_binds_natively_when_column_type_confirmed_uuid() { + let bound = bind_pk_value( + &json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + Some("uuid"), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn uuid_shaped_string_pk_binds_as_text_when_column_type_is_not_uuid() { + // Stricter than the general bind_pg_value cascade: a uuid-*shaped* + // string targeting a confirmed non-uuid column must bind as TEXT. + let bound = bind_pk_value( + &json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + Some("varchar"), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + // (still bound as TEXT — no CAST — since the column type is known + // and confirmed not to be uuid) + } + + #[test] + fn integer_shaped_string_pk_binds_as_bigint_when_column_type_confirmed_integer() { + let bound = bind_pk_value(&json!("42"), 1, Some("integer")).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn plain_string_pk_falls_back_to_text() { + let bound = bind_pk_value(&json!("abc"), 1, None).unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn object_pk_is_rejected() { + let err = bind_pk_value(&json!({"a": 1}), 1, None).unwrap_err(); + assert!(err.contains("Unsupported PK type")); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..58d7e08 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,291 @@ +//! PostgreSQL connection pool management via deadpool-postgres. +//! +//! Provides pool construction with optional TLS (via rustls), a process-wide +//! cache keyed by connection identity, and query helpers for common patterns +//! (single-column string queries, parameterized queries). +//! +//! # Pool caching +//! +//! Every RPC call originally built a brand-new `Pool` (connect, run one +//! query, discard) — noted as a Sprint 1 TODO ("Pool caching by connection +//! key will be added in Sprint 2") that was never followed up. Besides being +//! wasteful, a fresh TCP connect on every single call has no retry margin: a +//! transient connection hiccup on one call (e.g. a setup step in a test) is +//! silently swallowed by the caller and never retried, unlike a persistent +//! pool where a single connection failure doesn't affect already-established +//! connections. Caching by `host:port:database:user` (matches the builtin's +//! `build_connection_key` pattern in `src-tauri/src/pool_manager.rs`, minus +//! the TLS/connection_id refinements that plugin doesn't need yet) closes +//! that gap. + +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime}; +use tokio_postgres::types::{ToSql, Type}; +use tokio_postgres::{NoTls, Row}; +use tokio_postgres_rustls::MakeRustlsConnect; + +use crate::models::ConnectionParams; + +static POOLS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Build a connection pool from the given params and verify connectivity +/// by acquiring one client and running `SELECT 1`. +pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { + let pool = get_or_create_pool(params)?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + client + .query_one("SELECT 1", &[]) + .await + .map_err(|e| format!("Query failed: {e}"))?; + Ok(()) +} + +/// Run a query and extract a single text column from each row. +/// Used for schema discovery methods that return `Vec`. +pub async fn query_strings( + params: &ConnectionParams, + query: &str, + query_params: &[&(dyn ToSql + Sync)], + column: &str, +) -> Result, String> { + let pool = get_or_create_pool(params)?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let rows = client + .query(query, query_params) + .await + .map_err(|e| format!("Query failed: {e}"))?; + + let results = rows + .iter() + .map(|r| r.try_get::<_, String>(column).unwrap_or_default()) + .collect(); + Ok(results) +} + +/// Run a query and return the raw rows for caller-side mapping. +pub async fn query_rows( + params: &ConnectionParams, + query: &str, + query_params: &[&(dyn ToSql + Sync)], +) -> Result, String> { + let pool = get_or_create_pool(params)?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + client + .query(query, query_params) + .await + .map_err(|e| format!("Query failed: {e}")) +} + +/// Execute a statement with explicit per-placeholder wire types, pinned via +/// `prepare_typed`. Required for `CAST($N AS X)`-style placeholders where +/// letting the server infer the type from query context would reject the +/// bind before PostgreSQL's own parser sees the value. Returns affected rows. +pub async fn execute_typed( + params: &ConnectionParams, + query: &str, + typed_params: &[(&(dyn ToSql + Sync), Type)], +) -> Result { + let pool = get_or_create_pool(params)?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let types: Vec = typed_params.iter().map(|(_, t)| t.clone()).collect(); + let stmt = client + .prepare_typed(query, &types) + .await + .map_err(|e| format!("Prepare failed: {e}"))?; + let values: Vec<&(dyn ToSql + Sync)> = typed_params.iter().map(|(v, _)| *v).collect(); + client + .execute(&stmt, &values) + .await + .map_err(|e| format!("Execute failed: {e}")) +} + +/// Run a SELECT with explicit per-placeholder wire types (same rationale as +/// `execute_typed`) and return the resulting rows. +pub async fn query_typed( + params: &ConnectionParams, + query: &str, + typed_params: &[(&(dyn ToSql + Sync), Type)], +) -> Result, String> { + let pool = get_or_create_pool(params)?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let types: Vec = typed_params.iter().map(|(_, t)| t.clone()).collect(); + let stmt = client + .prepare_typed(query, &types) + .await + .map_err(|e| format!("Prepare failed: {e}"))?; + let values: Vec<&(dyn ToSql + Sync)> = typed_params.iter().map(|(v, _)| *v).collect(); + client + .query(&stmt, &values) + .await + .map_err(|e| format!("Query failed: {e}")) +} + +/// Fetch data types for every column in a table as a name -> type map. +/// Used by insert to resolve type-aware binding for all columns in one query. +pub async fn get_column_types_map( + params: &ConnectionParams, + table: &str, + schema: &str, +) -> Result, String> { + let query = r#" + SELECT + column_name, + CASE + WHEN data_type = 'USER-DEFINED' THEN udt_name + ELSE data_type + END AS resolved_type + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + "#; + let rows = query_rows(params, query, &[&schema, &table]).await?; + Ok(rows + .iter() + .filter_map(|r| { + let name: String = r.try_get("column_name").ok()?; + let ty: String = r.try_get("resolved_type").ok()?; + Some((name, ty)) + }) + .collect()) +} + +/// Fetch the schema-qualified, quoted enum type name for every enum column +/// in a table (e.g. `current_mood -> "test_schema"."mood"`). Columns not +/// backed by an enum type are absent from the map. +pub async fn get_enum_column_types( + params: &ConnectionParams, + schema: &str, + table: &str, +) -> Result, String> { + let query = "SELECT a.attname::text AS column_name, \ + tn.nspname::text AS type_schema, t.typname::text AS type_name \ + FROM pg_attribute a \ + JOIN pg_class c ON c.oid = a.attrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_type t ON t.oid = a.atttypid \ + JOIN pg_namespace tn ON tn.oid = t.typnamespace \ + WHERE n.nspname = $1 AND c.relname = $2 \ + AND a.attnum > 0 AND NOT a.attisdropped AND t.typtype = 'e'"; + + let rows = query_rows(params, query, &[&schema, &table]).await?; + Ok(rows + .iter() + .filter_map(|r| { + let col: String = r.try_get("column_name").ok()?; + let type_schema: String = r.try_get("type_schema").ok()?; + let type_name: String = r.try_get("type_name").ok()?; + Some((col, quote_qualified_type(&type_schema, &type_name))) + }) + .collect()) +} + +/// Quote a schema-qualified type name (e.g. `"public"."mood"`) so it can be +/// spliced into a `CAST($N AS ...)` without becoming an injection vector. +fn quote_qualified_type(type_schema: &str, type_name: &str) -> String { + format!( + "\"{}\".\"{}\"", + type_schema.replace('"', "\"\""), + type_name.replace('"', "\"\""), + ) +} + +/// Get the cached pool for these connection params, creating and caching one +/// on first use. Public for use by query handlers that need direct pool +/// access (e.g. to acquire one client for a multi-statement batch). +pub fn build_pool_pub(params: &ConnectionParams) -> Result { + get_or_create_pool(params) +} + +/// Identifies a connection target for pool-cache purposes. +/// Matches on host:port:database:user — sufficient for this plugin's scope +/// (no per-connection TLS-mode/connection_id refinement, unlike the builtin). +fn connection_key(params: &ConnectionParams) -> String { + format!( + "{}:{}:{}:{}", + params.host.as_deref().unwrap_or(""), + params.port.unwrap_or(5432), + params.database.as_deref().unwrap_or(""), + params.username.as_deref().unwrap_or(""), + ) +} + +/// Return the cached pool for this connection's identity, or build and cache +/// a new one if this is the first request for that identity. +fn get_or_create_pool(params: &ConnectionParams) -> Result { + let key = connection_key(params); + + { + let pools = POOLS.lock().map_err(|_| "pool cache lock poisoned".to_string())?; + if let Some(pool) = pools.get(&key) { + return Ok(pool.clone()); + } + } + + let pool = build_pool(params)?; + let mut pools = POOLS.lock().map_err(|_| "pool cache lock poisoned".to_string())?; + // Another call may have raced us to create this pool between the read + // above and this write — keep whichever is already cached. + Ok(pools.entry(key).or_insert(pool).clone()) +} + +/// Build a deadpool-postgres pool for the given connection parameters. +fn build_pool(params: &ConnectionParams) -> Result { + let mut cfg = Config::new(); + cfg.host = params.host.clone(); + cfg.port = params.port; + cfg.dbname = params.database.clone(); + cfg.user = params.username.clone(); + cfg.password = params.password.clone(); + cfg.manager = Some(ManagerConfig { + recycling_method: RecyclingMethod::Fast, + }); + + if needs_tls(params) { + let tls_config = build_tls_connector()?; + cfg.create_pool(Some(Runtime::Tokio1), MakeRustlsConnect::new(tls_config)) + .map_err(|e| format!("Pool creation failed (TLS): {e}")) + } else { + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| format!("Pool creation failed: {e}")) + } +} + +/// Determine whether TLS should be used based on ssl_mode. +fn needs_tls(params: &ConnectionParams) -> bool { + matches!( + params.ssl_mode.as_deref(), + Some("require" | "verify-ca" | "verify-full") + ) +} + +/// Build a rustls ClientConfig using the platform certificate verifier. +fn build_tls_connector() -> Result { + use rustls_platform_verifier::BuilderVerifierExt; + + let config = rustls::ClientConfig::builder() + .with_platform_verifier() + .map_err(|e| format!("Failed to build platform TLS verifier: {e}"))? + .with_no_client_auth(); + Ok(config) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod client_tests; + diff --git a/src/client_tests.rs b/src/client_tests.rs new file mode 100644 index 0000000..1dbccf6 --- /dev/null +++ b/src/client_tests.rs @@ -0,0 +1,79 @@ +//! Unit tests for `client.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod client_tests;`. + +use super::{connection_key, get_or_create_pool, POOLS}; +use crate::models::ConnectionParams; + +fn params(host: &str, port: u16, db: &str, user: &str) -> ConnectionParams { + ConnectionParams { + driver: Some("postgres-plugin".to_string()), + host: Some(host.to_string()), + port: Some(port), + database: Some(db.to_string()), + username: Some(user.to_string()), + password: None, + ssl_mode: None, + ssl_ca: None, + ssl_cert: None, + ssl_key: None, + connection_string: None, + } +} + +#[test] +fn connection_key_differs_by_database() { + let a = connection_key(¶ms("localhost", 5432, "db1", "postgres")); + let b = connection_key(¶ms("localhost", 5432, "db2", "postgres")); + assert_ne!(a, b, "different databases must not share a cache key"); +} + +#[test] +fn connection_key_differs_by_host() { + let a = connection_key(¶ms("host1", 5432, "db", "postgres")); + let b = connection_key(¶ms("host2", 5432, "db", "postgres")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_differs_by_port() { + let a = connection_key(¶ms("localhost", 5432, "db", "postgres")); + let b = connection_key(¶ms("localhost", 5433, "db", "postgres")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_differs_by_user() { + let a = connection_key(¶ms("localhost", 5432, "db", "alice")); + let b = connection_key(¶ms("localhost", 5432, "db", "bob")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_is_stable_for_identical_params() { + let a = connection_key(¶ms("localhost", 5432, "db", "postgres")); + let b = connection_key(¶ms("localhost", 5432, "db", "postgres")); + assert_eq!(a, b); +} + +#[test] +fn get_or_create_pool_reuses_cached_entry_for_identical_params() { + // deadpool's Pool::new is lazy (no connection attempt at creation + // time), so this exercises only the cache bookkeeping, not real + // connectivity. Use a key unlikely to collide with other tests + // running in the same process. + let p = params("cache-test-host-unique", 5432, "db", "user"); + let key = connection_key(&p); + + let before = POOLS.lock().unwrap().len(); + get_or_create_pool(&p).expect("first call creates and caches a pool"); + let after_first = POOLS.lock().unwrap().len(); + assert_eq!(after_first, before + 1, "first call should insert one entry"); + assert!(POOLS.lock().unwrap().contains_key(&key)); + + get_or_create_pool(&p).expect("second call should hit the cache"); + let after_second = POOLS.lock().unwrap().len(); + assert_eq!( + after_second, after_first, + "second call with identical params must not create a new entry" + ); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8c375a1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,22 @@ +//! Plugin error types. + +use std::fmt; + +#[derive(Debug)] +pub enum PluginError { + Connection(String), + Query(String), + Internal(String), +} + +impl fmt::Display for PluginError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connection(msg) => write!(f, "connection error: {msg}"), + Self::Query(msg) => write!(f, "query error: {msg}"), + Self::Internal(msg) => write!(f, "internal error: {msg}"), + } + } +} + +impl std::error::Error for PluginError {} diff --git a/src/extract.rs b/src/extract.rs new file mode 100644 index 0000000..96ea28c --- /dev/null +++ b/src/extract.rs @@ -0,0 +1,550 @@ +//! Value extraction from tokio-postgres rows to serde_json::Value. +//! +//! Replicates the exact type mapping of the built-in driver's +//! `src-tauri/src/drivers/postgres/extract/` system. Every PG type must +//! produce byte-identical JSON to the builtin — the parity tests enforce this. + +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use rust_decimal::Decimal; +use serde_json::Value as JsonValue; +use tokio_postgres::types::{FromSql, Kind, Type}; +use tokio_postgres::Row; +use uuid::Uuid; + +/// JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). +const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// Extract a single column value from a row as a JSON value. +/// Matches the builtin driver's extraction behavior exactly. +pub fn extract_value(row: &Row, index: usize) -> JsonValue { + let col_type = row.columns()[index].type_().clone(); + + // NULL check: try to get as Option first + match col_type { + ref t if *t == Type::BOOL => try_extract::(row, index, |v| JsonValue::Bool(v)), + ref t if *t == Type::INT2 => try_extract::(row, index, |v| JsonValue::from(v)), + ref t if *t == Type::INT4 => try_extract::(row, index, |v| JsonValue::from(v)), + ref t if *t == Type::INT8 => try_extract::(row, index, |v| i64_to_json(v)), + ref t if *t == Type::FLOAT4 => try_extract::(row, index, |v| { + serde_json::Number::from_f64(v as f64) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }), + ref t if *t == Type::FLOAT8 => try_extract::(row, index, |v| { + serde_json::Number::from_f64(v) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }), + ref t if *t == Type::NUMERIC => try_extract::(row, index, |v| { + JsonValue::String(v.to_string()) + }), + ref t if *t == Type::TEXT || *t == Type::VARCHAR || *t == Type::BPCHAR || *t == Type::NAME => { + try_extract::(row, index, JsonValue::String) + } + ref t if *t == Type::UUID => try_extract::(row, index, |v| { + JsonValue::String(v.to_string()) + }), + ref t if *t == Type::DATE => try_extract::(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d").to_string()) + }), + ref t if *t == Type::TIME => try_extract::(row, index, |v| { + JsonValue::String(v.format("%H:%M:%S").to_string()) + }), + ref t if *t == Type::TIMETZ => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INTERVAL => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::TIMESTAMP => try_extract::(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string()) + }), + ref t if *t == Type::TIMESTAMPTZ => { + try_extract::>(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string()) + }) + } + ref t if *t == Type::JSON || *t == Type::JSONB => { + try_extract::(row, index, |v| v) + } + ref t if *t == Type::BYTEA => try_extract::>(row, index, |v| { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v); + JsonValue::String(format!( + "BLOB:{}:application/octet-stream:{}", + v.len(), + b64 + )) + }), + ref t if *t == Type::INET || *t == Type::CIDR => { + try_extract::(row, index, JsonValue::from) + } + ref t if *t == Type::MACADDR => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::OID => try_extract::(row, index, |v| JsonValue::from(v)), + ref t if *t == Type::INT4_RANGE || *t == Type::INT8_RANGE || *t == Type::NUM_RANGE + || *t == Type::TS_RANGE || *t == Type::TSTZ_RANGE || *t == Type::DATE_RANGE => + { + try_extract_range(row, index) + } + ref t if *t == Type::INT2_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::from).collect()) + }), + ref t if *t == Type::INT4_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::from).collect()) + }), + ref t if *t == Type::INT8_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(i64_to_json).collect()) + }), + ref t if *t == Type::TEXT_ARRAY || *t == Type::VARCHAR_ARRAY => { + try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::String).collect()) + }) + } + ref t if *t == Type::FLOAT4_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array( + v.into_iter() + .map(|f| { + serde_json::Number::from_f64(f as f64) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .collect(), + ) + }), + ref t if *t == Type::FLOAT8_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array( + v.into_iter() + .map(|f| { + serde_json::Number::from_f64(f) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .collect(), + ) + }), + ref t if *t == Type::BOOL_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::Bool).collect()) + }), + // For types not explicitly handled (ranges, composites, geometric, etc.), + // fall back to text representation via the Display trait on the raw bytes. + _ => { + // Try as string — many types have text representations + match row.try_get::<_, String>(index) { + Ok(s) => JsonValue::String(s), + Err(_) => JsonValue::Null, + } + } + } +} + +/// Safely convert i64 to JSON: numbers within JS safe integer range are +/// JSON numbers; larger values become JSON strings to prevent precision loss. +fn i64_to_json(v: i64) -> JsonValue { + if v.abs() <= JS_MAX_SAFE_INTEGER { + JsonValue::from(v) + } else { + JsonValue::String(v.to_string()) + } +} + +/// Helper: try to extract a typed value from the row, returning JsonValue::Null +/// on any failure (NULL column, type mismatch, etc.). +fn try_extract<'a, T>( + row: &'a Row, + index: usize, + map: impl FnOnce(T) -> JsonValue, +) -> JsonValue +where + T: tokio_postgres::types::FromSql<'a>, +{ + match row.try_get::<_, Option>(index) { + Ok(Some(v)) => map(v), + Ok(None) => JsonValue::Null, + Err(_) => { + // Type mismatch — try string fallback + match row.try_get::<_, Option>(index) { + Ok(Some(s)) => JsonValue::String(s), + _ => JsonValue::Null, + } + } + } +} + +/// Extract a range-typed column (INT4RANGE, TSRANGE, etc.) using the generic +/// `Type::kind()` dispatch (matches the builtin's `Kind::Range(subtype)` +/// handling) rather than per-range-type constants, since range subtypes are +/// resolved dynamically from the column's element type. +fn try_extract_range(row: &Row, index: usize) -> JsonValue { + match row.try_get::<_, Option>(index) { + Ok(Some(v)) => JsonValue::String(v.0), + Ok(None) => JsonValue::Null, + Err(_) => JsonValue::Null, + } +} + +/// Wraps the raw range wire format: 1 flag byte, then 0-2 length-prefixed +/// bound values (each a 4-byte big-endian length followed by that many +/// bytes), formatted as `"[lower, upper)"` (bracket/paren per bound +/// inclusivity) matching `src-tauri/src/drivers/postgres/extract/range.rs`. +struct RangeValue(String); + +impl<'a> FromSql<'a> for RangeValue { + fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { + let subtype = match ty.kind() { + Kind::Range(t) => t.clone(), + _ => return Err("expected a range type".into()), + }; + + if raw.is_empty() { + return Err("empty range buffer".into()); + } + let flag = raw[0]; + let mut buf = &raw[1..]; + + // RANGE_EMPTY flag bit 0 + if (flag & 1) == 1 { + return Ok(Self("empty".to_string())); + } + + let lower_char = if (flag & (1 << 1)) == 0 { '(' } else { '[' }; + let upper_char = if (flag & (1 << 2)) == 0 { ')' } else { ']' }; + + let mut out = String::new(); + out.push(lower_char); + + // RANGE_LB_INF flag bit 3 — lower bound is unbounded (nothing pushed). + if flag & (1 << 3) == 0 { + // A present-but-unextractable lower bound short-circuits the + // whole range to "null, null" and returns immediately — matches + // the builtin's early-return on lower-bound extraction failure. + match extract_range_bound(&subtype, &mut buf) { + Some(s) => out.push_str(&s), + None => { + out.push_str("null, null"); + out.push(upper_char); + return Ok(Self(out)); + } + } + } + out.push_str(", "); + + // RANGE_UB_INF flag bit 4 — upper bound is unbounded (nothing pushed). + if flag & (1 << 4) == 0 { + if let Some(s) = extract_range_bound(&subtype, &mut buf) { + out.push_str(&s); + } else { + out.push_str("null"); + } + } + out.push(upper_char); + + Ok(Self(out)) + } + + fn accepts(ty: &Type) -> bool { + matches!(ty.kind(), Kind::Range(_)) + } +} + +/// Read one length-prefixed bound value from a range buffer and format it +/// the same way `extract_value` would for a plain column of that subtype. +fn extract_range_bound(subtype: &Type, buf: &mut &[u8]) -> Option { + if buf.len() < 4 { + return None; + } + let len = i32::from_be_bytes(buf[..4].try_into().ok()?); + *buf = &buf[4..]; + if len < 0 { + return None; + } + let len = len as usize; + if buf.len() < len { + return None; + } + let (value_buf, rest) = buf.split_at(len); + *buf = rest; + + let json = extract_simple_from_bytes(subtype, value_buf); + match json { + JsonValue::Null => None, + // Matches the builtin's `range.push_str(&val.to_string())`: calling + // `.to_string()` on a serde_json::Value quotes strings (producing + // `"2026-01-01 00:00:00"` inside the range) but leaves numbers bare + // (producing `1` not `"1"`) — do not special-case String here. + other => Some(other.to_string()), + } +} + +/// Format a raw byte buffer as JSON for the subset of simple PG types that +/// can appear as range bounds in this plugin's test corpus (integers, +/// numeric, date/timestamp). Falls back to Null for anything else. +fn extract_simple_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue { + match *ty { + Type::INT4 => i32::from_sql(ty, buf).map(JsonValue::from).unwrap_or(JsonValue::Null), + Type::INT8 => i64::from_sql(ty, buf).map(i64_to_json).unwrap_or(JsonValue::Null), + Type::NUMERIC => Decimal::from_sql(ty, buf) + .map(|v| JsonValue::String(v.to_string())) + .unwrap_or(JsonValue::Null), + Type::DATE => NaiveDate::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d").to_string())) + .unwrap_or(JsonValue::Null), + Type::TIMESTAMP => NaiveDateTime::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string())) + .unwrap_or(JsonValue::Null), + Type::TIMESTAMPTZ => chrono::DateTime::::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string())) + .unwrap_or(JsonValue::Null), + _ => JsonValue::Null, + } +} + +/// TIMETZ: time-of-day + UTC offset. Wire format: 8-byte microseconds since +/// midnight (i64, always non-negative), then a 4-byte signed offset in +/// seconds (positive = west of UTC, hence the sign flip below). Matches +/// `src-tauri/src/drivers/postgres/extract/advanced_types.rs::TimeTz`. +struct TimeTz { + hrs: u8, + mins: u8, + secs: u8, + microseconds: u32, + offset_sign: char, + offset_hrs: u8, + offset_mins: u8, + offset_secs: u8, +} + +impl<'a> FromSql<'a> for TimeTz { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 12 { + return Err(format!("expected at least 12 bytes for TIMETZ, got {}", raw.len()).into()); + } + let mut microseconds = i64::from_be_bytes(raw[0..8].try_into().unwrap()); + if microseconds < 0 { + return Err("microseconds must not be negative for TIMETZ".into()); + } + let hrs = (microseconds / (1_000_000 * 60 * 60)) as u8; + microseconds %= 1_000_000 * 60 * 60; + let mins = (microseconds / (1_000_000 * 60)) as u8; + microseconds %= 1_000_000 * 60; + let secs = (microseconds / 1_000_000) as u8; + let microseconds = (microseconds % 1_000_000) as u32; + + let mut timezone_offset = i32::from_be_bytes(raw[8..12].try_into().unwrap()); + let offset_sign = if timezone_offset.is_positive() { + '-' + } else { + timezone_offset = -timezone_offset; + '+' + }; + let offset_hrs = (timezone_offset / 3600) as u8; + let remainder = timezone_offset % 3600; + let offset_mins = (remainder / 60) as u8; + let offset_secs = (remainder % 60) as u8; + + Ok(Self { + hrs, + mins, + secs, + microseconds, + offset_sign, + offset_hrs, + offset_mins, + offset_secs, + }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::TIMETZ + } +} + +impl From for JsonValue { + fn from(v: TimeTz) -> Self { + let mut time = format!("{:02}:{:02}:{:02}", v.hrs, v.mins, v.secs); + if v.microseconds > 0 { + time.push('.'); + time.push_str(v.microseconds.to_string().trim_end_matches('0')); + } + time.push_str(&format!("{}{:02}", v.offset_sign, v.offset_hrs)); + if v.offset_mins > 0 { + time.push_str(&format!(":{:02}", v.offset_mins)); + } + if v.offset_secs > 0 { + time.push_str(&format!(":{:02}", v.offset_secs)); + } + JsonValue::String(time) + } +} + +/// INTERVAL: 8-byte microseconds, 4-byte days, 4-byte months (signed). +/// Matches `advanced_types.rs::Interval`. +struct Interval { + years: i32, + months: i8, + days: i32, + sign: char, + hours: u8, + minutes: u8, + seconds: u8, + microseconds: u32, +} + +impl<'a> FromSql<'a> for Interval { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 16 { + return Err(format!("expected 16 bytes for INTERVAL, got {}", raw.len()).into()); + } + let mut microseconds = i64::from_be_bytes(raw[0..8].try_into().unwrap()); + let mut days = i32::from_be_bytes(raw[8..12].try_into().unwrap()); + let mut months = i32::from_be_bytes(raw[12..16].try_into().unwrap()); + let mut years = 0; + + if !(-11..=11).contains(&months) { + years = months / 12; + months %= 12; + } + + let sign = if microseconds < 0 { + microseconds = -microseconds; + '-' + } else { + '+' + }; + + let mut hrs = microseconds / (1_000_000 * 60 * 60); + microseconds %= 1_000_000 * 60 * 60; + let mins = (microseconds / (1_000_000 * 60)) as u8; + microseconds %= 1_000_000 * 60; + let secs = (microseconds / 1_000_000) as u8; + let microseconds = (microseconds % 1_000_000) as u32; + + if !(-23..=23).contains(&hrs) { + days += (hrs / 24) as i32; + hrs %= 24; + } + + Ok(Self { + years, + months: months as i8, + days, + sign, + hours: hrs as u8, + minutes: mins, + seconds: secs, + microseconds, + }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::INTERVAL + } +} + +impl From for JsonValue { + fn from(v: Interval) -> Self { + let mut s = String::new(); + + if v.years != 0 { + let unit = if v.years == 1 || v.years == -1 { "year" } else { "years" }; + s.push_str(&format!("{} {} ", v.years, unit)); + } + if v.months != 0 { + let unit = if v.months == 1 || v.months == -1 { "month" } else { "months" }; + s.push_str(&format!("{} {} ", v.months, unit)); + } + if v.days != 0 { + let unit = if v.days == 1 || v.days == -1 { "day" } else { "days" }; + s.push_str(&format!("{} {} ", v.days, unit)); + } + if v.hours != 0 || v.minutes != 0 || v.seconds != 0 || v.microseconds != 0 { + if v.sign != '+' { + s.push(v.sign); + } + s.push_str(&format!("{:02}:{:02}:{:02}", v.hours, v.minutes, v.seconds)); + if v.microseconds != 0 { + s.push('.'); + s.push_str(v.microseconds.to_string().trim_end_matches('0')); + } + } + + JsonValue::String(s) + } +} + +/// INET/CIDR wire format: 1 byte family (2=IPv4, 3=IPv6), 1 byte netmask, +/// 1 byte is_cidr flag (ignored — INET and CIDR share this layout), 1 byte +/// address length, then the address bytes. Matches +/// `advanced_types.rs::CidrOrInet`. +struct CidrOrInet { + addr: std::net::IpAddr, + netmask: u8, +} + +impl<'a> FromSql<'a> for CidrOrInet { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 8 { + return Err("invalid buffer size for INET/CIDR".into()); + } + let family = raw[0]; + let netmask = raw[1]; + let len = raw[3]; + + match family { + 2 => { + if netmask > 32 || len != 4 { + return Err("invalid IPv4 INET/CIDR buffer".into()); + } + let octets: [u8; 4] = raw[4..8].try_into().unwrap(); + Ok(Self { + addr: std::net::IpAddr::from(octets), + netmask, + }) + } + 3 => { + if netmask > 128 || len != 16 || raw.len() < 20 { + return Err("invalid IPv6 INET/CIDR buffer".into()); + } + let bytes: [u8; 16] = raw[4..20].try_into().unwrap(); + Ok(Self { + addr: std::net::IpAddr::from(bytes), + netmask, + }) + } + _ => Err(format!("unexpected INET/CIDR family byte: {family}").into()), + } + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::INET || *ty == Type::CIDR + } +} + +impl From for JsonValue { + fn from(v: CidrOrInet) -> Self { + JsonValue::String(format!("{}/{}", v.addr, v.netmask)) + } +} + +/// MACADDR: exactly 6 raw bytes. Matches `advanced_types.rs::MacAddr`. +struct MacAddr { + bytes: [u8; 6], +} + +impl<'a> FromSql<'a> for MacAddr { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() != 6 { + return Err(format!("expected 6 bytes for MACADDR, got {}", raw.len()).into()); + } + let mut bytes = [0u8; 6]; + bytes.copy_from_slice(raw); + Ok(Self { bytes }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::MACADDR + } +} + +impl From for JsonValue { + fn from(v: MacAddr) -> Self { + JsonValue::String(format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3], v.bytes[4], v.bytes[5] + )) + } +} diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs new file mode 100644 index 0000000..96e614f --- /dev/null +++ b/src/handlers/blob.rs @@ -0,0 +1,96 @@ +//! BLOB (bytea) helpers — save_blob_to_file, fetch_blob_as_data_url. +//! +//! Mirrors the built-in driver's exact query shape +//! (`src-tauri/src/drivers/postgres/mod.rs::save_blob_column_to_file` / +//! `fetch_blob_column_as_data_url`) — a single-column SELECT filtered by the +//! row's primary key, using the same `build_pk_map_predicate` helper as +//! update_record/delete_record. + +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; + +use crate::binding::build_pk_map_predicate; +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let file_path = params.get("file_path").and_then(Value::as_str).unwrap_or(""); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await { + Ok(bytes) => match std::fs::write(file_path, bytes) { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e.to_string()), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await { + Ok(bytes) => ok_response(id, Value::from(encode_blob_full(&bytes))), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn fetch_blob_bytes( + conn_params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &serde_json::Map, + schema: &str, +) -> Result, String> { + let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + + let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; + let query = format!( + "SELECT \"{}\" FROM {} WHERE {}", + col_name.replace('"', "\"\""), + qualified, + predicate + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + let rows = client::query_typed(conn_params, &query, &typed_params).await?; + let row = rows.first().ok_or_else(|| "Row not found".to_string())?; + row.try_get::<_, Vec>(0).map_err(|e| e.to_string()) +} + +/// Encode raw bytes into the canonical BLOB wire format: +/// `"BLOB:::"`. MIME type is sniffed from the +/// content's magic bytes; unrecognized content falls back to +/// `application/octet-stream`. Matches `encode_blob_full` in +/// `src-tauri/src/drivers/common/blob.rs`. +fn encode_blob_full(data: &[u8]) -> String { + let mime_type = infer::get(data).map(|k| k.mime_type()).unwrap_or("application/octet-stream"); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data); + format!("BLOB:{}:{}:{}", data.len(), mime_type, b64) +} + +#[cfg(test)] +#[path = "blob_tests.rs"] +mod blob_tests; diff --git a/src/handlers/blob_tests.rs b/src/handlers/blob_tests.rs new file mode 100644 index 0000000..29bde76 --- /dev/null +++ b/src/handlers/blob_tests.rs @@ -0,0 +1,28 @@ +//! Unit tests for `blob.rs`'s pure encoding helper. Sibling test file per +//! repo convention (`.rules/rust.md` #4/#5) — loaded via +//! `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`. + +use super::encode_blob_full; + +#[test] +fn encodes_size_mime_and_base64() { + // 4 bytes (0xCA 0xFE 0xBA 0xBE) — not a recognized magic-byte format, so + // infer falls back to application/octet-stream. + let bytes = [0xCA, 0xFE, 0xBA, 0xBE]; + let wire = encode_blob_full(&bytes); + assert_eq!(wire, "BLOB:4:application/octet-stream:yv66vg=="); +} + +#[test] +fn empty_input_encodes_zero_size() { + let wire = encode_blob_full(&[]); + assert_eq!(wire, "BLOB:0:application/octet-stream:"); +} + +#[test] +fn sniffs_recognized_magic_bytes() { + // PNG signature. + let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let wire = encode_blob_full(&bytes); + assert!(wire.starts_with("BLOB:8:image/png:")); +} diff --git a/src/handlers/connection.rs b/src/handlers/connection.rs new file mode 100644 index 0000000..b5d2385 --- /dev/null +++ b/src/handlers/connection.rs @@ -0,0 +1,35 @@ +//! Connection lifecycle handlers: initialize, ping, test_connection, shutdown. + +use serde_json::Value; + +use crate::rpc::{ok_response, error_response}; +use crate::models::{ConnectionParams, inner_params}; +use crate::client; + +/// Receive plugin settings from the host. Currently a no-op. +pub async fn initialize(id: Value, _params: &Value) -> Value { + ok_response(id, Value::Null) +} + +/// Lightweight health check — verify we can reach the database. +pub async fn ping(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + match client::test_connection(&conn_params).await { + Ok(()) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Full connection test with error reporting. +pub async fn test_connection(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + match client::test_connection(&conn_params).await { + Ok(()) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Graceful shutdown — drain pools and exit. +pub async fn shutdown(id: Value, _params: &Value) -> Value { + ok_response(id, Value::Null) +} diff --git a/src/handlers/crud.rs b/src/handlers/crud.rs new file mode 100644 index 0000000..b8d4c45 --- /dev/null +++ b/src/handlers/crud.rs @@ -0,0 +1,188 @@ +//! CRUD operation handlers — insert_record, update_record, delete_record. +//! +//! Mirrors the built-in driver's SQL generation and binding exactly +//! (`src-tauri/src/drivers/postgres/mod.rs` insert/update/delete_record + +//! `binding.rs`) so both drivers produce identical affected_rows and +//! identical persisted data for the same inputs. + +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; + +use crate::binding::{bind_pg_value, build_pk_map_predicate, BindOptions}; +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +pub async fn insert_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let data = params + .get("data") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_insert(&conn_params, table, data, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_insert( + conn_params: &ConnectionParams, + table: &str, + data: serde_json::Map, + schema: &str, +) -> Result { + let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + + // Stable column order: iterate the map once into a Vec (matches the + // builtin's "lock in an arbitrary-but-consistent order" behavior). + let entries: Vec<(String, Value)> = data.into_iter().collect(); + + if entries.is_empty() { + let query = format!("INSERT INTO {} DEFAULT VALUES", qualified); + return client::execute_typed(conn_params, &query, &[]).await; + } + + let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table).await.unwrap_or_default(); + + let mut cols: Vec = Vec::with_capacity(entries.len()); + let mut sql_fragments: Vec = Vec::with_capacity(entries.len()); + let mut owned_params: Vec = Vec::new(); + let mut placeholder_idx = 1usize; + + for (col_name, val) in entries { + cols.push(format!("\"{}\"", col_name.replace('"', "\"\""))); + let column_type = column_types.get(&col_name).map(String::as_str); + let options = BindOptions { + column_type, + enum_type: enum_types.get(&col_name).map(String::as_str), + allow_default: false, + }; + let bound = bind_pg_value(val, placeholder_idx, &options)?; + sql_fragments.push(bound.sql); + if let Some(param) = bound.param { + owned_params.push(param); + placeholder_idx += 1; + } + } + + let query = format!( + "INSERT INTO {} ({}) VALUES ({})", + qualified, + cols.join(", "), + sql_fragments.join(", ") + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} + +pub async fn update_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let new_val = params.get("new_val").cloned().unwrap_or(Value::Null); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_update(&conn_params, table, &pk_map, col_name, new_val, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_update( + conn_params: &ConnectionParams, + table: &str, + pk_map: &serde_json::Map, + col_name: &str, + new_val: Value, + schema: &str, +) -> Result { + let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + + let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table).await.unwrap_or_default(); + + let options = BindOptions { + column_type: column_types.get(col_name).map(String::as_str), + enum_type: enum_types.get(col_name).map(String::as_str), + allow_default: true, + }; + let bound = bind_pg_value(new_val, 1, &options)?; + + let mut owned_params: Vec = Vec::new(); + let mut placeholder_idx = 1usize; + if let Some(param) = bound.param { + owned_params.push(param); + placeholder_idx = 2; + } + + let (predicate, pk_params) = build_pk_map_predicate(pk_map, &column_types, placeholder_idx)?; + owned_params.extend(pk_params); + + let query = format!( + "UPDATE {} SET \"{}\" = {} WHERE {}", + qualified, + col_name.replace('"', "\"\""), + bound.sql, + predicate + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} + +pub async fn delete_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_delete(&conn_params, table, &pk_map, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_delete( + conn_params: &ConnectionParams, + table: &str, + pk_map: &serde_json::Map, + schema: &str, +) -> Result { + let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + + let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + + let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; + + let query = format!("DELETE FROM {} WHERE {}", qualified, predicate); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} diff --git a/src/handlers/ddl.rs b/src/handlers/ddl.rs new file mode 100644 index 0000000..928c3b3 --- /dev/null +++ b/src/handlers/ddl.rs @@ -0,0 +1,327 @@ +//! DDL generation handlers — get_create_table_sql, get_add_column_sql, +//! get_alter_column_sql, get_create_index_sql, get_create_foreign_key_sql +//! (pure SQL string generation, no DB round-trip) plus drop_index and +//! drop_foreign_key (which execute against the database). +//! +//! Mirrors the built-in driver's DDL generation exactly +//! (`src-tauri/src/drivers/postgres/mod.rs` get_create_table_sql and +//! friends, `helpers.rs::is_implicit_cast_compatible`) so both drivers +//! produce byte-identical SQL for the same inputs. + +use serde_json::Value; + +use crate::client; +use crate::models::{inner_params, ColumnDefinition, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; +use crate::utils::identifiers::qualified; + +pub async fn get_create_table_sql(id: Value, params: &Value) -> Value { + let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let columns: Vec = params + .get("columns") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + ok_response(id, Value::from(vec![build_create_table_sql(table_name, &columns, schema)])) +} + +pub async fn get_add_column_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let column: Option = params + .get("column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + match column { + Some(column) => ok_response(id, Value::from(vec![build_add_column_sql(table, &column, schema)])), + None => error_response(id, -32602, "Invalid params: missing or malformed 'column'"), + } +} + +pub async fn get_alter_column_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let old_column: Option = params + .get("old_column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let new_column: Option = params + .get("new_column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + match (old_column, new_column) { + (Some(old_column), Some(new_column)) => { + match build_alter_column_sql(table, &old_column, &new_column, schema) { + Ok(stmts) => ok_response(id, Value::from(stmts)), + Err(e) => error_response(id, -32603, &e), + } + } + _ => error_response(id, -32602, "Invalid params: missing or malformed old_column/new_column"), + } +} + +pub async fn get_create_index_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let index_name = params.get("index_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let is_unique = params.get("is_unique").and_then(Value::as_bool).unwrap_or(false); + let columns: Vec = params + .get("columns") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + ok_response( + id, + Value::from(vec![build_create_index_sql(table, index_name, &columns, is_unique, schema)]), + ) +} + +pub async fn get_create_foreign_key_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); + let column = params.get("column").and_then(Value::as_str).unwrap_or(""); + let ref_table = params.get("ref_table").and_then(Value::as_str).unwrap_or(""); + let ref_column = params.get("ref_column").and_then(Value::as_str).unwrap_or(""); + let on_delete = params.get("on_delete").and_then(Value::as_str); + let on_update = params.get("on_update").and_then(Value::as_str); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + ok_response( + id, + Value::from(vec![build_create_foreign_key_sql( + table, fk_name, column, ref_table, ref_column, on_delete, on_update, schema, + )]), + ) +} + +pub async fn drop_index(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let index_name = params.get("index_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!("DROP INDEX {}", qualified(schema, index_name)); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn drop_foreign_key(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "ALTER TABLE {} DROP CONSTRAINT \"{}\"", + qualified(schema, table), + fk_name.replace('"', "\"\""), + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Render a column's declared type, substituting the appropriate serial +/// variant when the column is auto-increment (SERIAL/BIGSERIAL/SMALLSERIAL +/// cannot be combined with an explicit NOT NULL/DEFAULT clause the way a +/// plain integer type can). +fn resolve_column_type(column: &ColumnDefinition) -> String { + if !column.is_auto_increment { + return column.data_type.clone(); + } + let upper = column.data_type.to_uppercase(); + if upper.contains("BIGINT") || upper.contains("BIGSERIAL") { + "BIGSERIAL".to_string() + } else if upper.contains("SMALLINT") || upper.contains("SMALLSERIAL") { + "SMALLSERIAL".to_string() + } else { + "SERIAL".to_string() + } +} + +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +fn build_create_table_sql(table_name: &str, columns: &[ColumnDefinition], schema: &str) -> String { + let mut col_defs = Vec::with_capacity(columns.len()); + let mut pk_cols = Vec::new(); + + for col in columns { + let type_str = resolve_column_type(col); + let mut def = format!("{} {}", quote_ident(&col.name), type_str); + if !col.is_nullable && !col.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &col.default_value { + if !col.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + col_defs.push(def); + if col.is_pk { + pk_cols.push(quote_ident(&col.name)); + } + } + + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + + format!( + "CREATE TABLE {} (\n {}\n)", + qualified(schema, table_name), + col_defs.join(",\n ") + ) +} + +fn build_add_column_sql(table: &str, column: &ColumnDefinition, schema: &str) -> String { + let type_str = resolve_column_type(column); + let mut def = format!( + "ALTER TABLE {} ADD COLUMN {} {}", + qualified(schema, table), + quote_ident(&column.name), + type_str + ); + if !column.is_nullable && !column.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &column.default_value { + if !column.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + def +} + +/// Normalize a data type string for cast-compatibility comparison: +/// strip a trailing `(...)` and uppercase. E.g. `"varchar(255)"` -> `"VARCHAR"`. +fn extract_base_type(data_type: &str) -> String { + data_type.split('(').next().unwrap_or(data_type).trim().to_uppercase() +} + +/// Whether an ALTER COLUMN TYPE from `old_type` to `new_type` can rely on +/// PostgreSQL's implicit cast rather than needing an explicit `USING` clause. +fn is_implicit_cast_compatible(old_type: &str, new_type: &str) -> bool { + if old_type == new_type { + return true; + } + + const COMPATIBLE_GROUPS: &[&[&str]] = &[ + &["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], + &["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], + &["CHAR", "VARCHAR", "TEXT", "NAME", "CITEXT"], + &["TIMESTAMP", "TIMESTAMPTZ"], + &["TIME", "TIMETZ"], + &["JSON", "JSONB"], + &["BIT", "VARBIT"], + ]; + + COMPATIBLE_GROUPS + .iter() + .any(|group| group.contains(&old_type) && group.contains(&new_type)) +} + +fn build_alter_column_sql( + table: &str, + old_column: &ColumnDefinition, + new_column: &ColumnDefinition, + schema: &str, +) -> Result, String> { + let tbl = qualified(schema, table); + let old_name = quote_ident(&old_column.name); + let new_name = quote_ident(&new_column.name); + let mut stmts = Vec::new(); + + if old_column.name != new_column.name { + stmts.push(format!("ALTER TABLE {} RENAME COLUMN {} TO {}", tbl, old_name, new_name)); + } + + let col_ref = &new_name; + + if old_column.data_type != new_column.data_type { + let old_base = extract_base_type(&old_column.data_type); + let new_base = extract_base_type(&new_column.data_type); + + if is_implicit_cast_compatible(&old_base, &new_base) { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {}", + tbl, col_ref, new_column.data_type + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", + tbl, col_ref, new_column.data_type, col_ref, new_column.data_type + )); + } + } + + if old_column.is_nullable != new_column.is_nullable { + if new_column.is_nullable { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", tbl, col_ref)); + } else { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", tbl, col_ref)); + } + } + + if old_column.default_value != new_column.default_value { + if let Some(default) = &new_column.default_value { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", + tbl, col_ref, default + )); + } else { + stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", tbl, col_ref)); + } + } + + if stmts.is_empty() { + return Err("No changes detected".to_string()); + } + Ok(stmts) +} + +fn build_create_index_sql(table: &str, index_name: &str, columns: &[String], is_unique: bool, schema: &str) -> String { + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns.iter().map(|c| quote_ident(c)).collect(); + format!( + "CREATE {}INDEX {} ON {} ({})", + unique, + quote_ident(index_name), + qualified(schema, table), + cols.join(", ") + ) +} + +fn build_create_foreign_key_sql( + table: &str, + fk_name: &str, + column: &str, + ref_table: &str, + ref_column: &str, + on_delete: Option<&str>, + on_update: Option<&str>, + schema: &str, +) -> String { + let mut query = format!( + "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", + qualified(schema, table), + quote_ident(fk_name), + quote_ident(column), + qualified(schema, ref_table), + quote_ident(ref_column), + ); + if let Some(action) = on_delete { + query.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + query.push_str(&format!(" ON UPDATE {}", action)); + } + query +} + +#[cfg(test)] +#[path = "ddl_tests.rs"] +mod ddl_tests; diff --git a/src/handlers/ddl_tests.rs b/src/handlers/ddl_tests.rs new file mode 100644 index 0000000..3d26c8d --- /dev/null +++ b/src/handlers/ddl_tests.rs @@ -0,0 +1,282 @@ +//! Unit tests for `ddl.rs`'s pure SQL-builder functions. Sibling test file +//! per repo convention (`.rules/rust.md` #4/#5) — loaded via +//! `#[cfg(test)] #[path = "ddl_tests.rs"] mod ddl_tests;`. + +use super::{ + build_add_column_sql, build_alter_column_sql, build_create_foreign_key_sql, + build_create_index_sql, build_create_table_sql, is_implicit_cast_compatible, +}; +use crate::models::ColumnDefinition; + +fn column(name: &str, data_type: &str) -> ColumnDefinition { + ColumnDefinition { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + } +} + +mod create_table { + use super::*; + + #[test] + fn generates_quoted_qualified_table_with_columns() { + let columns = vec![ + ColumnDefinition { + name: "id".to_string(), + data_type: "SERIAL".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: None, + }, + column("name", "TEXT"), + ]; + let sql = build_create_table_sql("users", &columns, "public"); + assert!(sql.contains("CREATE TABLE \"public\".\"users\"")); + assert!(sql.contains("\"id\" SERIAL")); + assert!(sql.contains("PRIMARY KEY (\"id\")")); + } + + #[test] + fn auto_increment_column_skips_not_null_and_default() { + let columns = vec![ColumnDefinition { + name: "id".to_string(), + data_type: "INTEGER".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: Some("1".to_string()), + }]; + let sql = build_create_table_sql("t", &columns, "public"); + // is_auto_increment suppresses both NOT NULL and DEFAULT even though + // is_nullable is false and a default_value is set — matches builtin. + assert!(!sql.contains("NOT NULL")); + assert!(!sql.contains("DEFAULT")); + } + + #[test] + fn bigint_auto_increment_becomes_bigserial() { + let columns = vec![ColumnDefinition { + name: "id".to_string(), + data_type: "BIGINT".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: None, + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("\"id\" BIGSERIAL")); + } + + #[test] + fn non_nullable_non_auto_increment_column_gets_not_null() { + let columns = vec![ColumnDefinition { + name: "name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("\"name\" TEXT NOT NULL")); + } + + #[test] + fn default_value_is_spliced_in_verbatim() { + let columns = vec![ColumnDefinition { + name: "email".to_string(), + data_type: "VARCHAR(255)".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: Some("'unknown@example.com'".to_string()), + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("DEFAULT 'unknown@example.com'")); + } + + #[test] + fn no_primary_key_omits_pk_clause() { + let columns = vec![column("name", "TEXT")]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(!sql.contains("PRIMARY KEY")); + } +} + +mod add_column { + use super::*; + + #[test] + fn generates_alter_table_add_column() { + let col = ColumnDefinition { + name: "new_col".to_string(), + data_type: "INTEGER".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: Some("0".to_string()), + }; + let sql = build_add_column_sql("all_types", &col, "test_schema"); + assert!(sql.contains("ALTER TABLE \"test_schema\".\"all_types\" ADD COLUMN \"new_col\" INTEGER")); + assert!(sql.contains("DEFAULT 0")); + } +} + +mod alter_column { + use super::*; + + #[test] + fn rename_only_when_names_differ() { + let old = column("old_name", "TEXT"); + let new = column("new_name", "TEXT"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert_eq!(stmts.len(), 1); + assert!(stmts[0].contains("RENAME COLUMN \"old_name\" TO \"new_name\"")); + } + + #[test] + fn compatible_type_change_omits_using_clause() { + let old = column("col_text", "TEXT"); + let new = column("col_text", "VARCHAR(500)"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("TYPE VARCHAR(500)") && !s.contains("USING"))); + } + + #[test] + fn incompatible_type_change_adds_using_clause() { + let old = column("col", "TEXT"); + let new = column("col", "INTEGER"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("USING \"col\"::INTEGER"))); + } + + #[test] + fn nullable_to_not_nullable_sets_not_null() { + let mut old = column("col", "TEXT"); + old.is_nullable = true; + let mut new = column("col", "TEXT"); + new.is_nullable = false; + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("SET NOT NULL"))); + } + + #[test] + fn not_nullable_to_nullable_drops_not_null() { + let mut old = column("col", "TEXT"); + old.is_nullable = false; + let mut new = column("col", "TEXT"); + new.is_nullable = true; + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("DROP NOT NULL"))); + } + + #[test] + fn default_value_added_sets_default() { + let old = column("col", "TEXT"); + let mut new = column("col", "TEXT"); + new.default_value = Some("'x'".to_string()); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("SET DEFAULT 'x'"))); + } + + #[test] + fn default_value_removed_drops_default() { + let mut old = column("col", "TEXT"); + old.default_value = Some("'x'".to_string()); + let new = column("col", "TEXT"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("DROP DEFAULT"))); + } + + #[test] + fn no_changes_is_an_error() { + let old = column("col", "TEXT"); + let new = column("col", "TEXT"); + let err = build_alter_column_sql("t", &old, &new, "public").unwrap_err(); + assert_eq!(err, "No changes detected"); + } +} + +mod cast_compatibility { + use super::*; + + #[test] + fn identical_types_are_compatible() { + assert!(is_implicit_cast_compatible("TEXT", "TEXT")); + } + + #[test] + fn integer_family_is_compatible() { + assert!(is_implicit_cast_compatible("INTEGER", "BIGINT")); + } + + #[test] + fn string_family_is_compatible() { + assert!(is_implicit_cast_compatible("VARCHAR", "TEXT")); + } + + #[test] + fn cross_family_is_incompatible() { + assert!(!is_implicit_cast_compatible("TEXT", "INTEGER")); + } +} + +mod create_index { + use super::*; + + #[test] + fn multi_column_index() { + let sql = build_create_index_sql( + "all_types", + "idx_test", + &["col_text".to_string(), "col_int".to_string()], + false, + "test_schema", + ); + assert_eq!( + sql, + "CREATE INDEX \"idx_test\" ON \"test_schema\".\"all_types\" (\"col_text\", \"col_int\")" + ); + } + + #[test] + fn unique_index_adds_unique_keyword() { + let sql = build_create_index_sql("t", "idx", &["c".to_string()], true, "public"); + assert!(sql.starts_with("CREATE UNIQUE INDEX")); + } +} + +mod create_foreign_key { + use super::*; + + #[test] + fn basic_foreign_key_without_actions() { + let sql = build_create_foreign_key_sql( + "crud_scratch", + "fk_test", + "value", + "all_types", + "id", + None, + None, + "test_schema", + ); + assert_eq!( + sql, + "ALTER TABLE \"test_schema\".\"crud_scratch\" ADD CONSTRAINT \"fk_test\" FOREIGN KEY (\"value\") REFERENCES \"test_schema\".\"all_types\" (\"id\")" + ); + } + + #[test] + fn on_delete_and_on_update_actions_are_appended() { + let sql = build_create_foreign_key_sql( + "t", "fk", "c", "ref_t", "ref_c", Some("CASCADE"), Some("RESTRICT"), "public", + ); + assert!(sql.ends_with("ON DELETE CASCADE ON UPDATE RESTRICT")); + } +} diff --git a/src/handlers/metadata.rs b/src/handlers/metadata.rs new file mode 100644 index 0000000..38aae90 --- /dev/null +++ b/src/handlers/metadata.rs @@ -0,0 +1,768 @@ +//! Schema discovery and metadata handlers. + +use serde_json::{json, Value}; + +use crate::client; +use crate::models::{ConnectionParams, inner_params}; +use crate::rpc::{error_response, not_implemented, ok_response}; + +pub async fn get_databases(id: Value, params: &Value) -> Value { + let mut conn_params = ConnectionParams::from_value(inner_params(params)); + // Must connect to 'postgres' maintenance DB to list all databases. + conn_params.database = Some("postgres".to_string()); + + match client::query_strings( + &conn_params, + "SELECT datname::text FROM pg_database WHERE datistemplate = false ORDER BY datname", + &[], + "datname", + ) + .await + { + Ok(databases) => ok_response(id, json!(databases)), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_schemas(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + + match client::query_strings( + &conn_params, + "SELECT schema_name::text FROM information_schema.schemata \ + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \ + AND schema_name NOT LIKE 'pg_temp_%' \ + AND schema_name NOT LIKE 'pg_toast_temp_%' \ + ORDER BY schema_name", + &[], + "schema_name", + ) + .await + { + Ok(schemas) => ok_response(id, json!(schemas)), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_tables(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT table_name::text as name FROM information_schema.tables \ + WHERE table_schema = $1 AND table_type = 'BASE TABLE' \ + ORDER BY table_name ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let tables: Vec = names.into_iter().map(|n| json!({"name": n})).collect(); + ok_response(id, json!(tables)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT + c.column_name::text, + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, + c.is_nullable::text, + c.column_default::text, + c.is_identity::text, + c.character_maximum_length, + (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace tn ON tn.oid = t.typnamespace + WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_values, + EXISTS ( + SELECT 1 + FROM pg_constraint pk_con + JOIN pg_class pk_table ON pk_table.oid = pk_con.conrelid + JOIN pg_namespace pk_schema ON pk_schema.oid = pk_table.relnamespace + JOIN unnest(pk_con.conkey) AS pk_col(attnum) ON true + JOIN pg_attribute pk_att + ON pk_att.attrelid = pk_table.oid + AND pk_att.attnum = pk_col.attnum + AND NOT pk_att.attisdropped + WHERE pk_con.contype = 'p' + AND pk_schema.nspname = c.table_schema + AND pk_table.relname = c.table_name + AND pk_att.attname = c.column_name + ) AS is_pk + FROM information_schema.columns c + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let columns: Vec = rows.iter().map(row_to_table_column).collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +/// Map one `information_schema.columns`-shaped row (as queried by +/// `get_columns`/`get_view_columns`) to the host's `TableColumn` JSON shape. +fn row_to_table_column(r: &tokio_postgres::Row) -> Value { + let name: String = r.try_get("column_name").unwrap_or_default(); + let raw_data_type: String = r.try_get("data_type").unwrap_or_default(); + let enum_values: Option = r.try_get("enum_values").ok().flatten(); + let is_nullable_str: String = r.try_get("is_nullable").unwrap_or_default(); + let column_default: Option = r.try_get("column_default").ok().flatten(); + let is_identity: String = r.try_get("is_identity").unwrap_or_default(); + let char_max_len: Option = r + .try_get::<_, Option>("character_maximum_length") + .ok() + .flatten(); + let is_pk: bool = r.try_get("is_pk").unwrap_or(false); + + let data_type = match enum_values { + Some(ref vals) if !vals.is_empty() => format!("enum({})", vals), + _ => raw_data_type, + }; + + let is_auto_increment = is_identity == "YES" + || column_default.as_deref().map_or(false, |d| d.contains("nextval")); + + let is_nullable = is_nullable_str == "YES"; + + let default_value = column_default.as_deref().and_then(|d| { + if is_auto_increment || d.is_empty() || d == "NULL" || d.starts_with("NULL::") { + None + } else { + Some(d.to_string()) + } + }); + + let mut col = json!({ + "name": name, + "data_type": data_type, + "is_pk": is_pk, + "is_nullable": is_nullable, + "is_auto_increment": is_auto_increment, + }); + + if let Some(dv) = default_value { + col.as_object_mut().unwrap().insert("default_value".to_string(), json!(dv)); + } + if let Some(len) = char_max_len.and_then(|v| u64::try_from(v).ok()) { + col.as_object_mut() + .unwrap() + .insert("character_maximum_length".to_string(), json!(len)); + } + + col +} + +pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT + con.conname::text AS constraint_name, + src_att.attname::text AS column_name, + ref_nsp.nspname::text AS foreign_schema_name, + ref_cl.relname::text AS foreign_table_name, + ref_att.attname::text AS foreign_column_name, + CASE con.confupdtype + WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + END::text AS update_rule, + CASE con.confdeltype + WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + END::text AS delete_rule + FROM pg_constraint con + JOIN pg_class src_cl ON src_cl.oid = con.conrelid + JOIN pg_namespace src_nsp ON src_nsp.oid = src_cl.relnamespace + JOIN pg_class ref_cl ON ref_cl.oid = con.confrelid + JOIN pg_namespace ref_nsp ON ref_nsp.oid = ref_cl.relnamespace + JOIN unnest(con.conkey, con.confkey) AS cols(src_attnum, ref_attnum) ON true + JOIN pg_attribute src_att + ON src_att.attrelid = src_cl.oid + AND src_att.attnum = cols.src_attnum + AND NOT src_att.attisdropped + JOIN pg_attribute ref_att + ON ref_att.attrelid = ref_cl.oid + AND ref_att.attnum = cols.ref_attnum + AND NOT ref_att.attisdropped + WHERE con.contype = 'f' + AND con.conparentid = 0 + AND src_nsp.nspname = $1 + AND src_cl.relname = $2 + ORDER BY con.conname, cols.src_attnum + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let fks: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("constraint_name").unwrap_or_default(); + let column_name: String = r.try_get("column_name").unwrap_or_default(); + let ref_table: String = r.try_get("foreign_table_name").unwrap_or_default(); + let ref_column: String = r.try_get("foreign_column_name").unwrap_or_default(); + let on_update: Option = r.try_get("update_rule").ok().flatten(); + let on_delete: Option = r.try_get("delete_rule").ok().flatten(); + + json!({ + "name": name, + "column_name": column_name, + "ref_table": ref_table, + "ref_column": ref_column, + "on_delete": on_delete, + "on_update": on_update, + }) + }) + .collect(); + ok_response(id, json!(fks)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_indexes(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT + i.relname AS index_name, + COALESCE( + a.attname::text, + pg_get_indexdef(ix.indexrelid, k.n::int, true) + ) AS column_name, + ix.indisunique AS is_unique, + ix.indisprimary AS is_primary, + k.n::int AS seq_in_index, + (k.attnum = 0) AS is_expression + FROM + pg_class t + JOIN pg_namespace n ON t.relnamespace = n.oid + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + CROSS JOIN LATERAL unnest(string_to_array(ix.indkey::text, ' ')::int2[]) + WITH ORDINALITY AS k(attnum, n) + LEFT JOIN pg_attribute a + ON a.attrelid = t.oid + AND a.attnum = k.attnum + AND k.attnum <> 0 + WHERE + t.relkind IN ('r', 'm') + AND n.nspname = $1 + AND t.relname = $2 + ORDER BY + i.relname, + k.n + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let indexes: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("index_name").unwrap_or_default(); + let column_name: String = r.try_get("column_name").unwrap_or_default(); + let is_unique: bool = r.try_get("is_unique").unwrap_or(false); + let is_primary: bool = r.try_get("is_primary").unwrap_or(false); + let seq_in_index: i32 = r.try_get("seq_in_index").unwrap_or(1); + let is_expression: bool = r.try_get("is_expression").unwrap_or(false); + + json!({ + "name": name, + "column_name": column_name, + "is_unique": is_unique, + "is_primary": is_primary, + "seq_in_index": seq_in_index, + "is_expression": is_expression, + }) + }) + .collect(); + ok_response(id, json!(indexes)) + } + Err(e) => error_response(id, -32603, &e), + } +} +pub async fn get_views(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT viewname as name FROM pg_views WHERE schemaname = $1 ORDER BY viewname ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let views: Vec = names + .into_iter() + .map(|n| json!({"name": n, "definition": null})) + .collect(); + ok_response(id, json!(views)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_view_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let qualified = crate::utils::identifiers::qualified(schema, view_name); + + match client::query_rows( + &conn_params, + "SELECT pg_get_viewdef(($1::text)::regclass, true) as definition", + &[&qualified], + ) + .await + { + Ok(rows) => { + if let Some(row) = rows.first() { + let definition: String = row.try_get("definition").unwrap_or_default(); + let full = format!("CREATE OR REPLACE VIEW {} AS\n{}", qualified, definition); + ok_response(id, json!(full)) + } else { + error_response(id, -32603, "View not found") + } + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_view_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT + c.column_name::text, + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, + c.is_nullable::text, + c.column_default::text, + c.is_identity::text, + c.character_maximum_length, + (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace tn ON tn.oid = t.typnamespace + WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_values, + EXISTS ( + SELECT 1 + FROM pg_constraint pk_con + JOIN pg_class pk_table ON pk_table.oid = pk_con.conrelid + JOIN pg_namespace pk_schema ON pk_schema.oid = pk_table.relnamespace + JOIN unnest(pk_con.conkey) AS pk_col(attnum) ON true + JOIN pg_attribute pk_att + ON pk_att.attrelid = pk_table.oid + AND pk_att.attnum = pk_col.attnum + AND NOT pk_att.attisdropped + WHERE pk_con.contype = 'p' + AND pk_schema.nspname = c.table_schema + AND pk_table.relname = c.table_name + AND pk_att.attname = c.column_name + ) AS is_pk + FROM information_schema.columns c + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position + "#; + + match client::query_rows(&conn_params, query, &[&schema, &view_name]).await { + Ok(rows) => { + let columns: Vec = rows.iter().map(row_to_table_column).collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn create_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let definition = params.get("definition").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "CREATE VIEW {} AS {}", + crate::utils::identifiers::qualified(schema, view_name), + definition + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to create view: {}", e)), + } +} + +pub async fn alter_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let definition = params.get("definition").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "CREATE OR REPLACE VIEW {} AS {}", + crate::utils::identifiers::qualified(schema, view_name), + definition + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to alter view: {}", e)), + } +} + +pub async fn drop_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "DROP VIEW IF EXISTS {}", + crate::utils::identifiers::qualified(schema, view_name) + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to drop view: {}", e)), + } +} + +pub async fn get_materialized_views(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT matviewname as name FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let views: Vec = names + .into_iter() + .map(|n| json!({"name": n, "definition": null})) + .collect(); + ok_response(id, json!(views)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_materialized_view_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + // Materialized views are not exposed via information_schema.columns, so + // their columns must be read from the system catalog. + let query = r#" + SELECT + a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind = 'm' + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + "#; + + match client::query_rows(&conn_params, query, &[&schema, &view_name]).await { + Ok(rows) => { + let columns: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("column_name").unwrap_or_default(); + let data_type: String = r.try_get("data_type").unwrap_or_default(); + let not_null: bool = r.try_get("not_null").unwrap_or(false); + json!({ + "name": name, + "data_type": data_type, + "is_pk": false, + "is_nullable": !not_null, + "is_auto_increment": false, + }) + }) + .collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_materialized_view_definition(id: Value, _params: &Value) -> Value { not_implemented(id, "get_materialized_view_definition") } + +pub async fn refresh_materialized_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "REFRESH MATERIALIZED VIEW {}", + crate::utils::identifiers::qualified(schema, view_name) + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to refresh materialized view: {}", e)), + } +} + +pub async fn get_routines(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + // PG 11+ uses prokind; older versions use proisagg/proiswindow flags. + // CI runs PG 16, so we use the modern query. + let query = r#" + SELECT proname, prokind + FROM pg_proc + WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) + AND prokind IN ('f', 'p') + ORDER BY proname + "#; + + match client::query_rows(&conn_params, query, &[&schema]).await { + Ok(rows) => { + let routines: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("proname").unwrap_or_default(); + let prokind: i8 = r.try_get("prokind").unwrap_or(b'f' as i8); + let routine_type = if prokind as u8 as char == 'p' { + "PROCEDURE" + } else { + "FUNCTION" + }; + json!({ + "name": name, + "routine_type": routine_type, + "definition": null, + }) + }) + .collect(); + ok_response(id, json!(routines)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let routine_name = params.get("routine_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let return_type_query = r#" + SELECT data_type, routine_type + FROM information_schema.routines + WHERE routine_schema = $1 AND routine_name = $2 + LIMIT 1 + "#; + let routine_info = match client::query_rows(&conn_params, return_type_query, &[&schema, &routine_name]).await { + Ok(rows) => rows, + Err(e) => return error_response(id, -32603, &e), + }; + + let mut parameters: Vec = Vec::new(); + + if let Some(info) = routine_info.first() { + let routine_type: String = info.try_get("routine_type").unwrap_or_default(); + if routine_type == "FUNCTION" { + let data_type: String = info.try_get("data_type").unwrap_or_default(); + if !data_type.eq_ignore_ascii_case("void") && !data_type.eq_ignore_ascii_case("trigger") { + parameters.push(json!({ + "name": "", + "data_type": data_type, + "mode": "OUT", + "ordinal_position": 0, + })); + } + } + } + + let query = r#" + SELECT p.parameter_name, p.data_type, p.parameter_mode, p.ordinal_position + FROM information_schema.parameters p + JOIN information_schema.routines r ON p.specific_name = r.specific_name + WHERE r.routine_schema = $1 AND r.routine_name = $2 + ORDER BY p.ordinal_position + "#; + match client::query_rows(&conn_params, query, &[&schema, &routine_name]).await { + Ok(rows) => { + parameters.extend(rows.iter().map(|r| { + let name: Option = r.try_get("parameter_name").ok().flatten(); + let data_type: String = r.try_get("data_type").unwrap_or_default(); + let mode: String = r.try_get("parameter_mode").unwrap_or_default(); + let ordinal_position: i32 = r.try_get("ordinal_position").unwrap_or(0); + json!({ + "name": name.unwrap_or_default(), + "data_type": data_type, + "mode": mode, + "ordinal_position": ordinal_position, + }) + })); + ok_response(id, json!(parameters)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_routine_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let routine_name = params.get("routine_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT pg_get_functiondef(p.oid) as definition + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = $1 AND p.proname = $2 + LIMIT 1 + "#; + + match client::query_rows(&conn_params, query, &[&schema, &routine_name]).await { + Ok(rows) => match rows.first() { + Some(row) => { + let definition: String = row.try_get("definition").unwrap_or_default(); + ok_response(id, json!(definition)) + } + None => error_response(id, -32603, &format!("Routine '{}' not found", routine_name)), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_triggers(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT + t.trigger_name AS name, + t.event_object_table AS table_name, + string_agg(t.event_manipulation, ' OR ' ORDER BY t.event_manipulation) AS event, + t.action_timing AS timing + FROM information_schema.triggers t + WHERE t.trigger_schema = $1 + GROUP BY t.trigger_name, t.event_object_table, t.action_timing + ORDER BY t.trigger_name + "#; + + match client::query_rows(&conn_params, query, &[&schema]).await { + Ok(rows) => { + let triggers: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("name").unwrap_or_default(); + let table_name: String = r.try_get("table_name").unwrap_or_default(); + let event: String = r.try_get("event").unwrap_or_default(); + let timing: String = r.try_get("timing").unwrap_or_default(); + json!({ + "name": name, + "table_name": table_name, + "event": event, + "timing": timing, + "definition": null, + }) + }) + .collect(); + ok_response(id, json!(triggers)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_trigger_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_name = params.get("trigger_name").and_then(Value::as_str).unwrap_or(""); + let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = r#" + SELECT pg_get_triggerdef(t.oid, true) AS definition + FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = $1 + AND c.relname = $2 + AND n.nspname = $3 + AND NOT t.tgisinternal + LIMIT 1 + "#; + + match client::query_rows(&conn_params, query, &[&trigger_name, &table_name, &schema]).await { + Ok(rows) => match rows.first() { + Some(row) => { + let definition: String = row.try_get("definition").unwrap_or_default(); + ok_response(id, json!(definition)) + } + None => error_response(id, -32603, &format!("Trigger '{}' not found", trigger_name)), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn create_trigger(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_sql = params.get("trigger_sql").and_then(Value::as_str).unwrap_or(""); + + match client::execute_typed(&conn_params, trigger_sql, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to create trigger: {}", e)), + } +} + +pub async fn drop_trigger(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_name = params.get("trigger_name").and_then(Value::as_str).unwrap_or(""); + let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); + let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + + let query = format!( + "DROP TRIGGER IF EXISTS {} ON {}", + crate::utils::identifiers::quote_identifier(trigger_name), + crate::utils::identifiers::qualified(schema, table_name), + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to drop trigger: {}", e)), + } +} + +pub async fn get_schema_snapshot(id: Value, _params: &Value) -> Value { not_implemented(id, "get_schema_snapshot") } +pub async fn get_all_columns_batch(id: Value, _params: &Value) -> Value { not_implemented(id, "get_all_columns_batch") } +pub async fn get_all_foreign_keys_batch(id: Value, _params: &Value) -> Value { not_implemented(id, "get_all_foreign_keys_batch") } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..74e7401 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,8 @@ +//! Handler modules — each covers a logical domain of the RPC API. + +pub mod blob; +pub mod connection; +pub mod crud; +pub mod ddl; +pub mod metadata; +pub mod query; diff --git a/src/handlers/query.rs b/src/handlers/query.rs new file mode 100644 index 0000000..030ff4d --- /dev/null +++ b/src/handlers/query.rs @@ -0,0 +1,259 @@ +//! Query execution handlers. + +use deadpool_postgres::Object as PgClient; +use serde_json::{json, Value}; +use std::time::Instant; + +use crate::client; +use crate::extract::extract_value; +use crate::models::{ConnectionParams, inner_params}; +use crate::rpc::{error_response, ok_response}; + +pub async fn execute_query(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let query = params.get("query").and_then(Value::as_str).unwrap_or(""); + let limit = params.get("limit").and_then(Value::as_u64).map(|v| v as u32); + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; + let schema = params.get("schema").and_then(Value::as_str); + + match exec_query(&conn_params, query, limit, page, schema).await { + Ok(result) => ok_response(id, result), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn execute_query_batch(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let queries: Vec = params + .get("queries") + .and_then(Value::as_array) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + let limit = params.get("limit").and_then(Value::as_u64).map(|v| v as u32); + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; + let schema = params.get("schema").and_then(Value::as_str); + + // Acquire ONE connection for the entire batch (session state must survive) + let pool = match client::build_pool_pub(&conn_params) { + Ok(p) => p, + Err(e) => return error_response(id, -32603, &e), + }; + let pg_client = match pool.get().await { + Ok(c) => c, + Err(e) => return error_response(id, -32603, &format!("Connection failed: {e}")), + }; + + if let Some(s) = schema { + let set_path = format!("SET search_path TO \"{}\"", s.replace('"', "\"\"")); + if let Err(e) = pg_client.batch_execute(&set_path).await { + return error_response(id, -32603, &format!("Failed to set search_path: {e}")); + } + } + + let mut results: Vec = Vec::new(); + + for query in &queries { + let start = Instant::now(); + let outcome = exec_query_on_client(&pg_client, query, limit, page).await; + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + + match outcome { + Ok(result) => results.push(json!({ + "result": result, + "error": null, + "execution_time_ms": elapsed_ms, + })), + Err(e) => results.push(json!({ + "result": null, + "error": e, + "execution_time_ms": elapsed_ms, + })), + } + } + + ok_response(id, json!(results)) +} + +pub async fn explain_query(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let query = params.get("query").and_then(Value::as_str).unwrap_or(""); + let analyze = params.get("analyze").and_then(Value::as_bool).unwrap_or(false); + let schema = params.get("schema").and_then(Value::as_str); + + let explain_sql = if analyze { + format!("EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) {}", query) + } else { + format!("EXPLAIN (FORMAT JSON) {}", query) + }; + + match exec_query(&conn_params, &explain_sql, None, 1, schema).await { + Ok(result) => { + // The host wraps this in ExplainQueryOutput::Plan { plan: res } + // We just return the raw explain JSON from the first row/col + if let Some(rows) = result.get("rows").and_then(Value::as_array) { + if let Some(first_row) = rows.first().and_then(Value::as_array) { + if let Some(plan_json) = first_row.first() { + return ok_response(id, plan_json.clone()); + } + } + } + ok_response(id, result) + } + Err(e) => error_response(id, -32603, &e), + } +} + +/// Execute a SQL query and return a QueryResult-shaped JSON value. +async fn exec_query( + conn_params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, +) -> Result { + let pool = client::build_pool_pub(conn_params)?; + let pg_client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + + // Set search_path if schema is specified + if let Some(s) = schema { + let set_path = format!( + "SET search_path TO \"{}\"", + s.replace('"', "\"\"") + ); + pg_client + .batch_execute(&set_path) + .await + .map_err(|e| format!("Failed to set search_path: {e}"))?; + } + + exec_query_on_client(&pg_client, query, limit, page).await +} + +/// Execute a query on an existing client (used by both single and batch execution). +async fn exec_query_on_client( + pg_client: &PgClient, + query: &str, + limit: Option, + page: u32, +) -> Result { + // Check if the statement returns a result set + if !returns_result_set(query) { + let affected = pg_client + .execute(query, &[]) + .await + .map_err(|e| format!("{e}"))?; + return Ok(json!({ + "columns": [], + "rows": [], + "affected_rows": affected, + "truncated": false, + "pagination": null, + })); + } + + // Build paginated query — strips any existing LIMIT/OFFSET first so we + // never emit a query with two LIMIT clauses (which is a syntax error). + let (final_query, page_size) = if let Some(lim) = limit { + let paginated = crate::utils::pagination::build_paginated_query(query, lim, page); + (paginated, lim) + } else { + (query.to_string(), 0u32) + }; + + // Execute query + let rows = pg_client + .query(&final_query, &[]) + .await + .map_err(|e| format!("{e}"))?; + + if rows.is_empty() { + // Get columns from the statement if possible + let columns: Vec = if let Ok(stmt) = pg_client.prepare(&final_query).await { + stmt.columns().iter().map(|c| c.name().to_string()).collect() + } else { + vec![] + }; + + let pagination = if limit.is_some() { + Some(json!({ + "page": page, + "page_size": page_size, + "total_rows": null, + "has_more": false, + })) + } else { + None + }; + + return Ok(json!({ + "columns": columns, + "rows": [], + "affected_rows": 0, + "truncated": false, + "pagination": pagination, + })); + } + + // Extract columns from first row + let columns: Vec = rows[0] + .columns() + .iter() + .map(|c| c.name().to_string()) + .collect(); + + // Determine has_more and truncate + let has_more = limit.is_some() && rows.len() > page_size as usize; + let result_rows = if has_more { + &rows[..page_size as usize] + } else { + &rows[..] + }; + + // Extract row values + let json_rows: Vec = result_rows + .iter() + .map(|row| { + let values: Vec = (0..row.columns().len()) + .map(|i| extract_value(row, i)) + .collect(); + Value::Array(values) + }) + .collect(); + + let pagination = if limit.is_some() { + Some(json!({ + "page": page, + "page_size": page_size, + "total_rows": null, + "has_more": has_more, + })) + } else { + None + }; + + Ok(json!({ + "columns": columns, + "rows": json_rows, + "affected_rows": 0, + "truncated": has_more, + "pagination": pagination, + })) +} + +/// Check if a SQL statement returns a result set (SELECT, WITH, SHOW, etc.) +fn returns_result_set(query: &str) -> bool { + let trimmed = query.trim_start(); + let upper = trimmed.to_uppercase(); + upper.starts_with("SELECT") + || upper.starts_with("WITH") + || upper.starts_with("SHOW") + || upper.starts_with("EXPLAIN") + || upper.starts_with("DESCRIBE") + || upper.starts_with("VALUES") + || upper.starts_with("TABLE") + || upper.starts_with("PRAGMA") + || upper.starts_with("CALL") +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c77e61d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,55 @@ +//! PostgreSQL plugin for Tabularis — JSON-RPC driver over stdin/stdout. +//! +//! # Protocol +//! +//! Reads newline-delimited JSON-RPC 2.0 requests from stdin and writes +//! responses (one JSON object per line) to stdout. All handler logic is +//! async (tokio) since the database pool requires an async runtime. +#![allow(dead_code)] + +use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; + +mod binding; +#[cfg(test)] +mod binding_tests; +mod client; +mod error; +mod extract; +mod handlers; +mod models; +mod rpc; +mod utils; + +#[tokio::main] +async fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin); + let mut out = stdout; + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let response = rpc::handle_line(trimmed).await; + let mut body = match serde_json::to_string(&response) { + Ok(s) => s, + Err(err) => format!( + "{{\"jsonrpc\":\"2.0\",\"error\":{{\"code\":-32603,\"message\":\"serialization failed: {err}\"}},\"id\":null}}" + ), + }; + body.push('\n'); + if out.write_all(body.as_bytes()).await.is_err() { + break; + } + let _ = out.flush().await; + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..95cf497 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,70 @@ +//! Shared request/response shapes. +//! +//! Mirrors the `ConnectionParams` struct the host sends. Fields are optional +//! since different database types leave different fields blank. + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub struct ConnectionParams { + pub driver: Option, + pub host: Option, + pub port: Option, + pub database: Option, + pub username: Option, + pub password: Option, + pub ssl_mode: Option, + pub ssl_ca: Option, + pub ssl_cert: Option, + pub ssl_key: Option, + pub connection_string: Option, +} + +impl ConnectionParams { + pub fn from_value(value: &Value) -> Self { + let obj = value.as_object(); + let get_str = |k: &str| { + obj.and_then(|o| o.get(k)) + .and_then(Value::as_str) + .map(str::to_string) + }; + let port = obj + .and_then(|o| o.get("port")) + .and_then(Value::as_u64) + .and_then(|p| u16::try_from(p).ok()); + + Self { + driver: get_str("driver"), + host: get_str("host"), + port, + database: get_str("database"), + username: get_str("username"), + password: get_str("password"), + ssl_mode: get_str("ssl_mode"), + ssl_ca: get_str("ssl_ca"), + ssl_cert: get_str("ssl_cert"), + ssl_key: get_str("ssl_key"), + connection_string: get_str("connection_string"), + } + } +} + +/// Extract the nested `params` object every RPC method receives. +/// Tabularis wraps connection params in `params.params`. +pub fn inner_params(value: &Value) -> &Value { + value.get("params").unwrap_or(value) +} + +/// Mirrors `crate::models::ColumnDefinition` on the host — a single column's +/// shape for DDL generation (CREATE TABLE, ADD COLUMN, ALTER COLUMN). +#[derive(Debug, Clone, Deserialize)] +pub struct ColumnDefinition { + pub name: String, + pub data_type: String, + pub is_nullable: bool, + pub is_pk: bool, + pub is_auto_increment: bool, + pub default_value: Option, +} + diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..e30ae0b --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,109 @@ +//! JSON-RPC dispatch and response helpers. + +use serde_json::{json, Value}; + +use crate::handlers; + +/// Parse one JSON-RPC line and return the response value. Never panics — +/// parse errors and method failures are surfaced as JSON-RPC error responses. +pub async fn handle_line(line: &str) -> Value { + let request: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(err) => return error_response(Value::Null, -32700, &format!("parse error: {err}")), + }; + + let id = request.get("id").cloned().unwrap_or(Value::Null); + let method = request + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let params = request.get("params").cloned().unwrap_or(Value::Null); + + match method.as_str() { + // Connection lifecycle + "initialize" => handlers::connection::initialize(id, ¶ms).await, + "ping" => handlers::connection::ping(id, ¶ms).await, + "test_connection" => handlers::connection::test_connection(id, ¶ms).await, + "shutdown" => handlers::connection::shutdown(id, ¶ms).await, + + // Metadata — stubs for future sprints + "get_databases" => handlers::metadata::get_databases(id, ¶ms).await, + "get_schemas" => handlers::metadata::get_schemas(id, ¶ms).await, + "get_tables" => handlers::metadata::get_tables(id, ¶ms).await, + "get_columns" => handlers::metadata::get_columns(id, ¶ms).await, + "get_foreign_keys" => handlers::metadata::get_foreign_keys(id, ¶ms).await, + "get_indexes" => handlers::metadata::get_indexes(id, ¶ms).await, + "get_views" => handlers::metadata::get_views(id, ¶ms).await, + "get_view_definition" => handlers::metadata::get_view_definition(id, ¶ms).await, + "get_view_columns" => handlers::metadata::get_view_columns(id, ¶ms).await, + "get_materialized_views" => handlers::metadata::get_materialized_views(id, ¶ms).await, + "get_materialized_view_columns" => handlers::metadata::get_materialized_view_columns(id, ¶ms).await, + "get_materialized_view_definition" => handlers::metadata::get_materialized_view_definition(id, ¶ms).await, + "refresh_materialized_view" => handlers::metadata::refresh_materialized_view(id, ¶ms).await, + "get_routines" => handlers::metadata::get_routines(id, ¶ms).await, + "get_routine_parameters" => handlers::metadata::get_routine_parameters(id, ¶ms).await, + "get_routine_definition" => handlers::metadata::get_routine_definition(id, ¶ms).await, + "get_triggers" => handlers::metadata::get_triggers(id, ¶ms).await, + "get_trigger_definition" => handlers::metadata::get_trigger_definition(id, ¶ms).await, + "get_schema_snapshot" => handlers::metadata::get_schema_snapshot(id, ¶ms).await, + "get_all_columns_batch" => handlers::metadata::get_all_columns_batch(id, ¶ms).await, + "get_all_foreign_keys_batch" => handlers::metadata::get_all_foreign_keys_batch(id, ¶ms).await, + + // View mutation + "create_view" => handlers::metadata::create_view(id, ¶ms).await, + "alter_view" => handlers::metadata::alter_view(id, ¶ms).await, + "drop_view" => handlers::metadata::drop_view(id, ¶ms).await, + "create_trigger" => handlers::metadata::create_trigger(id, ¶ms).await, + "drop_trigger" => handlers::metadata::drop_trigger(id, ¶ms).await, + + // Query execution + "execute_query" => handlers::query::execute_query(id, ¶ms).await, + "execute_query_batch" => handlers::query::execute_query_batch(id, ¶ms).await, + "explain_query" => handlers::query::explain_query(id, ¶ms).await, + + // CRUD + "insert_record" => handlers::crud::insert_record(id, ¶ms).await, + "update_record" => handlers::crud::update_record(id, ¶ms).await, + "delete_record" => handlers::crud::delete_record(id, ¶ms).await, + + // DDL + "get_create_table_sql" => handlers::ddl::get_create_table_sql(id, ¶ms).await, + "get_add_column_sql" => handlers::ddl::get_add_column_sql(id, ¶ms).await, + "get_alter_column_sql" => handlers::ddl::get_alter_column_sql(id, ¶ms).await, + "get_create_index_sql" => handlers::ddl::get_create_index_sql(id, ¶ms).await, + "get_create_foreign_key_sql" => handlers::ddl::get_create_foreign_key_sql(id, ¶ms).await, + "drop_index" => handlers::ddl::drop_index(id, ¶ms).await, + "drop_foreign_key" => handlers::ddl::drop_foreign_key(id, ¶ms).await, + + // BLOB + "save_blob_to_file" => handlers::blob::save_blob_to_file(id, ¶ms).await, + "fetch_blob_as_data_url" => handlers::blob::fetch_blob_as_data_url(id, ¶ms).await, + + other => not_implemented(id, other), + } +} + +pub fn ok_response(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "result": result, + "id": id, + }) +} + +pub fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "error": { "code": code, "message": message }, + "id": id, + }) +} + +pub fn not_implemented(id: Value, method: &str) -> Value { + error_response( + id, + -32601, + &format!("Method not found (-32601): '{method}' is not implemented"), + ) +} diff --git a/src/utils/identifiers.rs b/src/utils/identifiers.rs new file mode 100644 index 0000000..c9e6cc7 --- /dev/null +++ b/src/utils/identifiers.rs @@ -0,0 +1,11 @@ +//! SQL identifier quoting utilities. + +/// Quote a SQL identifier with double quotes, escaping any embedded quotes. +pub fn quote_identifier(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Produce a schema-qualified identifier: "schema"."name". +pub fn qualified(schema: &str, name: &str) -> String { + format!("{}.{}", quote_identifier(schema), quote_identifier(name)) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..b7e27d7 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,6 @@ +//! Utility modules. + +pub mod identifiers; +pub mod pagination; +#[cfg(test)] +mod pagination_tests; diff --git a/src/utils/pagination.rs b/src/utils/pagination.rs new file mode 100644 index 0000000..145340c --- /dev/null +++ b/src/utils/pagination.rs @@ -0,0 +1,120 @@ +//! Pagination math for LIMIT/OFFSET queries. +//! +//! `build_paginated_query` mirrors the builtin driver's behavior in +//! `src-tauri/src/drivers/common/query.rs`: strip any trailing user-supplied +//! `LIMIT`/`OFFSET`, honor the user's LIMIT as a cap across pages, and append +//! the plugin's own pagination clause. ORDER BY is left in place (not wrapped +//! in a subquery) so table-qualified column references stay valid. +//! +//! This is a simpler whitespace/token scan than the builtin's full quote- and +//! comment-aware tokenizer — it correctly handles the common case (a plain +//! trailing `LIMIT n` / `LIMIT n OFFSET m`) but does not defend against SQL +//! comments after the clause or identifiers that literally are `LIMIT`/`OFFSET` +//! tokens inside quotes. Sufficient for the current parity test corpus; +//! revisit if a query pattern breaks this. + +/// Compute the SQL LIMIT and OFFSET for a given page and page size. +/// Pages are 1-indexed. +pub fn limit_offset(page: u32, page_size: u32) -> (u32, u32) { + let offset = (page.saturating_sub(1)) * page_size; + (page_size, offset) +} + +/// Split a query into whitespace-separated tokens, tracking each token's +/// starting byte offset in the original string. +fn tokenize_with_pos(sql: &str) -> Vec<(&str, usize)> { + let mut tokens = Vec::new(); + let mut idx = 0; + for part in sql.split_whitespace() { + // Find this token's actual position (split_whitespace doesn't give us + // offsets directly). + let start = sql[idx..].find(part).map(|p| idx + p).unwrap_or(idx); + idx = start + part.len(); + tokens.push((part, start)); + } + tokens +} + +/// Strip a trailing `LIMIT ` and/or `OFFSET ` clause from the query, +/// returning the query text with that clause removed. +fn strip_limit_offset(query: &str) -> String { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let mut end = tokens.len(); + + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "OFFSET" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "LIMIT" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end == tokens.len() { + return trimmed.to_string(); + } + + trimmed[..tokens[end].1].trim_end().to_string() +} + +/// Extract the numeric value from a trailing `LIMIT` clause, if present. +fn extract_user_limit(query: &str) -> Option { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let len = tokens.len(); + + let mut end = len; + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "OFFSET" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end >= 2 && tokens[end - 2].0.to_uppercase() == "LIMIT" { + return tokens[end - 1].0.parse().ok(); + } + + None +} + +/// Extract the numeric value from a trailing `OFFSET` clause, if present. +fn extract_user_offset(query: &str) -> Option { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let end = tokens.len(); + + if end >= 2 && tokens[end - 2].0.to_uppercase() == "OFFSET" { + return tokens[end - 1].0.parse().ok(); + } + + None +} + +/// Build a paginated query: strip any user-supplied LIMIT/OFFSET and append +/// this page's clause. A user LIMIT caps the total rows returned across all +/// pages; a user OFFSET is added to the per-page offset. +pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { + let page_offset = limit_offset(page, page_size).1; + let user_limit = extract_user_limit(query); + let user_offset = extract_user_offset(query).unwrap_or(0); + let base = strip_limit_offset(query); + + let fetch_count = match user_limit { + Some(ul) => { + let remaining = ul.saturating_sub(page_offset); + remaining.min(page_size + 1) + } + None => page_size + 1, + }; + + let offset = user_offset.saturating_add(page_offset); + + format!("{} LIMIT {} OFFSET {}", base, fetch_count, offset) +} diff --git a/src/utils/pagination_tests.rs b/src/utils/pagination_tests.rs new file mode 100644 index 0000000..78498d3 --- /dev/null +++ b/src/utils/pagination_tests.rs @@ -0,0 +1,70 @@ +//! Unit tests for `pagination.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod pagination_tests;`. + +use crate::utils::pagination::{build_paginated_query, limit_offset}; + +#[test] +fn limit_offset_computes_zero_based_offset_from_one_indexed_page() { + assert_eq!(limit_offset(1, 10), (10, 0)); + assert_eq!(limit_offset(2, 10), (10, 10)); + assert_eq!(limit_offset(3, 5), (5, 10)); +} + +#[test] +fn build_paginated_query_appends_limit_offset_when_none_present() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id", 10, 1); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 0"); +} + +#[test] +fn build_paginated_query_page_two_uses_correct_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id", 10, 2); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 10"); +} + +#[test] +fn build_paginated_query_strips_existing_trailing_limit() { + // Without stripping, this would produce two LIMIT clauses (a syntax + // error) — this is the regression this module exists to prevent. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 100, 1); + assert_eq!( + sql.matches("LIMIT").count(), + 1, + "must not contain two LIMIT clauses: {sql}" + ); +} + +#[test] +fn build_paginated_query_honors_user_limit_as_a_cap_across_pages() { + // User asked for at most 5 rows total. Page 1 with page_size=100 should + // fetch min(5, 101) = 5, not 101. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 100, 1); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 0"); +} + +#[test] +fn build_paginated_query_user_limit_cap_shrinks_on_later_pages() { + // User LIMIT 5, page_size 2, page 3 -> offset 4, remaining = 5-4 = 1. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 2, 3); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 1 OFFSET 4"); +} + +#[test] +fn build_paginated_query_strips_existing_limit_and_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 3", 100, 1); + // User OFFSET 3 is preserved and added to the page offset (0 on page 1). + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 3"); +} + +#[test] +fn build_paginated_query_adds_user_offset_to_page_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id OFFSET 3", 10, 2); + // page 2 offset = 10, plus user offset 3 = 13. + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 13"); +} + +#[test] +fn build_paginated_query_ignores_trailing_semicolon() { + let sql = build_paginated_query("SELECT * FROM t;", 10, 1); + assert_eq!(sql, "SELECT * FROM t LIMIT 11 OFFSET 0"); +} From a2fc70e1be6e750f89981d4c06211fb254fd0652 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 14:38:18 -0400 Subject: [PATCH 02/10] Import postgres-plugin design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copies the 8 planning documents that shaped this migration (phase docs, both migration-plan variants, and the feature-gap audit feeding Phase 2) from tabularis's .github/planning/ into docs/planning/, flattened out of the nested postgres-plugin/ subdirectory. No other Tabularis plugin repo carries a planning-docs directory, but this content is specific to the migration and belongs alongside the source it describes rather than only as a link back into the monorepo. Fixes one dead relative link (postgres-plugin/README.md pointed at a nonexistent postgres-plugin-migration-alt.md — a stale rename artifact; repointed to the actual postgres-plugin-migration.md) and carries forward the source directory's own scoped .markdownlint.json (MD013/MD024/MD060 overrides), since these docs were never written against the repo's stricter default ruleset. --- docs/planning/.markdownlint.json | 5 + docs/planning/00-prerequisites.md | 201 +++ docs/planning/01-phase-0-baseline-tests.md | 471 +++++++ docs/planning/02-phase-1-plugin-build.md | 515 +++++++ docs/planning/03-phase-2-issue-16.md | 344 +++++ docs/planning/04-phase-3-deprecate-builtin.md | 140 ++ docs/planning/README.md | 35 + docs/planning/postgres-improvements.md | 1238 +++++++++++++++++ .../postgres-plugin-migration-original.md | 925 ++++++++++++ docs/planning/postgres-plugin-migration.md | 533 +++++++ 10 files changed, 4407 insertions(+) create mode 100644 docs/planning/.markdownlint.json create mode 100644 docs/planning/00-prerequisites.md create mode 100644 docs/planning/01-phase-0-baseline-tests.md create mode 100644 docs/planning/02-phase-1-plugin-build.md create mode 100644 docs/planning/03-phase-2-issue-16.md create mode 100644 docs/planning/04-phase-3-deprecate-builtin.md create mode 100644 docs/planning/README.md create mode 100644 docs/planning/postgres-improvements.md create mode 100644 docs/planning/postgres-plugin-migration-original.md create mode 100644 docs/planning/postgres-plugin-migration.md diff --git a/docs/planning/.markdownlint.json b/docs/planning/.markdownlint.json new file mode 100644 index 0000000..7bf87b1 --- /dev/null +++ b/docs/planning/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD013": false, + "MD024": { "siblings_only": true }, + "MD060": false +} diff --git a/docs/planning/00-prerequisites.md b/docs/planning/00-prerequisites.md new file mode 100644 index 0000000..511e9d3 --- /dev/null +++ b/docs/planning/00-prerequisites.md @@ -0,0 +1,201 @@ +# Prerequisites — Tabularis Core PRs + +**Must be merged before Phase 0 testing or Phase 1 building can begin.** + +## Overview + +Three changes to the Tabularis host's `RpcDriver` adapter are required to enable +full feature parity for any PostgreSQL plugin. Without these, certain tests in +Phase 0 will always fail when pointed at a plugin driver, making parity +verification impossible. + +These are small, non-breaking additions to existing code. They follow the same +patterns already used for other forwarded methods (triggers, views, etc.). + +--- + +## PR 1: Forward BLOB Methods + +### What + +Extend `RpcDriver` in `src-tauri/src/plugins/driver.rs` to forward: + +- `save_blob_to_file(params, table, column, pk_column, pk_value, file_path, schema)` +- `fetch_blob_as_data_url(params, table, column, pk_column, pk_value, schema)` + +### Current Behavior + +These methods inherit the trait default which returns: + +```rust +Err("BLOB file export not supported by this driver".into()) +``` + +### Proposed Implementation + +```rust +async fn save_blob_to_file(&self, params: &ConnectionParams, table: &str, + column: &str, pk_column: &str, pk_value: &str, + file_path: &str, schema: Option<&str>) -> Result<(), String> +{ + // Plugin returns base64-encoded blob data + let res = self.process.call("save_blob_to_file", json!({ + "params": params, "table": table, "column": column, + "pk_column": pk_column, "pk_value": pk_value, + "file_path": file_path, "schema": schema + })).await?; + Ok(()) // Plugin writes to file_path directly (local process) +} + +async fn fetch_blob_as_data_url(&self, params: &ConnectionParams, table: &str, + column: &str, pk_column: &str, pk_value: &str, + schema: Option<&str>) -> Result +{ + let res = self.process.call("fetch_blob_as_data_url", json!({ + "params": params, "table": table, "column": column, + "pk_column": pk_column, "pk_value": pk_value, "schema": schema + })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) +} +``` + +### Testing + +- Verify existing BLOB tests pass with built-in driver (unchanged behavior) +- Verify a plugin returning base64 data works end-to-end + +### Risk + +None — purely additive. Existing plugins that don't implement these methods +will return `-32601` and the host falls back to the existing "not supported" error. + +--- + +## PR 2: Forward Materialized View Methods + +### What + +Extend `RpcDriver` to forward: + +- `get_materialized_views(params, schema)` +- `get_materialized_view_columns(params, view_name, schema)` +- `get_materialized_view_definition(params, view_name, schema)` +- `refresh_materialized_view(params, view_name, schema)` + +### Current Behavior + +These inherit defaults returning `Ok(vec![])` or +`Err("Materialized views are not supported...")`. + +### Proposed Implementation + +Same pattern as `get_views`, `get_triggers`, etc. — straightforward JSON-RPC +forwarding with `serde_json::from_value` deserialization. + +### Risk + +None — same pattern as existing forwarded methods. + +--- + +## PR 3: Resolve `map_inferred_type` from Plugin Manifest + +### What + +The `map_inferred_type` method is **synchronous** (`fn`, not `async fn`) so it +cannot issue an RPC call. Currently returns the input unchanged for plugin drivers. + +The built-in PG driver maps: `DATETIME` → `TIMESTAMP`, `JSON` → `JSONB`. + +### Proposed Solution + +Add an optional `type_mappings` field to `PluginManifest`: + +```rust +// In driver_trait.rs, add to PluginManifest: +pub type_mappings: Option>, +``` + +The `RpcDriver` stores these at construction time and applies them in +`map_inferred_type`: + +```rust +fn map_inferred_type(&self, kind: &str) -> String { + if let Some(mappings) = &self.manifest.type_mappings { + if let Some(mapped) = mappings.get(&kind.to_uppercase()) { + return mapped.clone(); + } + } + kind.to_string() +} +``` + +Plugin manifest declares: + +```json +{ + "type_mappings": { + "DATETIME": "TIMESTAMP", + "JSON": "JSONB" + } +} +``` + +### Risk + +Low — new optional field. Existing plugins without it behave unchanged. + +--- + +## Approach + +### Option A: One Combined PR + +Submit all three changes in a single PR titled: +"feat(plugins): extend RpcDriver for BLOB, materialized views, and type mappings" + +**Pros:** One review cycle, atomic merge, single CI run. +**Cons:** Larger diff, harder to review. + +### Option B: Three Separate PRs + +Submit sequentially, each small and focused. + +**Pros:** Easy to review, bisectable, can merge independently. +**Cons:** Three review cycles. + +### Recommendation + +**Option A** — These are all small, non-breaking additions with zero risk of +conflict. A single PR with clear commit separation (one commit per feature) +gives the reviewer full context of why these are needed (PostgreSQL plugin +migration) without the overhead of three separate review cycles. + +--- + +## Checkpoint: CP-1 + +**When:** After the prerequisites PR is merged into `main`. + +**Verify:** + +- [ ] `cargo test` passes (no regressions in existing drivers) +- [ ] Existing plugin drivers (DuckDB, D1) still work (methods return -32601 gracefully) +- [ ] No changes to MySQL or SQLite drivers +- [ ] New trait fields are `Option` / backward-compatible + +**Communicate to team:** + +- Prerequisites are in place +- Phase 0 can begin (test suite development) +- No user-facing changes yet + +--- + +## Definition of Done + +- [ ] PR merged to `main` +- [ ] CI green +- [ ] No existing test regressions +- [ ] CHANGELOG entry added (under "Plugin System" section) +- [ ] Core team acknowledged at CP-1 diff --git a/docs/planning/01-phase-0-baseline-tests.md b/docs/planning/01-phase-0-baseline-tests.md new file mode 100644 index 0000000..fd42086 --- /dev/null +++ b/docs/planning/01-phase-0-baseline-tests.md @@ -0,0 +1,471 @@ +# Phase 0 — Baseline Test Suite + +**Goal:** Create the comprehensive test infrastructure that proves the built-in +PostgreSQL driver's behavior, establishing the specification that the plugin must +match. This is the foundation of our zero-regression guarantee. + +**Mantra:** _If it isn't tested, it doesn't exist. If it passes on both drivers, +they are equivalent by construction._ + +--- + +## Why This Phase Exists + +| Today's Coverage | What's Missing | +| ---------------- | -------------- | +| 162 unit tests for value extraction | Zero tests for 36 public API methods | +| 96 unit tests for parameter binding | Zero integration tests running in CI | +| 4 integration tests (all `#[ignore]`) | Zero golden file / snapshot tests | +| No parity harness | No multi-database test scenarios | + +Without Phase 0, we have no way to prove the plugin matches the built-in driver. +We'd be shipping on trust, not evidence. + +--- + +## Deliverables (in order) + +### 0.1: CI PostgreSQL Service + +**What:** Add a PostgreSQL 16 service container to the GitHub Actions CI workflow. + +**Why:** Integration tests must run automatically on every PR. Today they're all +`#[ignore]` because no PG instance exists in CI. + +**How:** + +```yaml +# .github/workflows/ci.yml — add to the rust test job +services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: tabularis_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +**Also:** + +- Create a second test database: `tabularis_test_secondary` +- Add CI step that runs the seed script before tests +- Set environment variable `TABULARIS_TEST_PG=1` so integration tests detect PG is available +- Remove `#[ignore]` from existing 4 integration tests + +**Verify:** CI passes with the 4 existing integration tests (currently un-run). + +--- + +### 0.2: Test Database Seed Script + +**What:** A repeatable SQL script that creates all tables, types, views, +functions, triggers, and indexes needed by the test suite. + +**Why:** Tests need a known schema state. The seed script is the single source of +truth for what exists in the test database. + +**File:** `tests/fixtures/postgres_seed.sql` + +**Contents must include:** + +```sql +-- Core type coverage table +CREATE TABLE test_schema.all_types ( + id SERIAL PRIMARY KEY, + col_text TEXT, col_varchar VARCHAR(255), + col_int INTEGER, col_bigint BIGINT, + col_float REAL, col_double DOUBLE PRECISION, + col_numeric NUMERIC(10,2), col_bool BOOLEAN, + col_date DATE, col_time TIME, + col_timestamp TIMESTAMP, col_timestamptz TIMESTAMPTZ, + col_uuid UUID DEFAULT gen_random_uuid(), + col_json JSON, col_jsonb JSONB, + col_bytea BYTEA, col_inet INET, col_cidr CIDR, + col_macaddr MACADDR, + col_int_array INTEGER[], col_text_array TEXT[], + col_int4range INT4RANGE, col_tsrange TSRANGE +); + +-- Enum type +CREATE TYPE test_schema.mood AS ENUM ('happy', 'sad', 'neutral'); +CREATE TABLE test_schema.with_enum ( + id SERIAL PRIMARY KEY, + current_mood test_schema.mood +); + +-- Foreign key relationships (single and composite PK) +CREATE TABLE test_schema.orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES test_schema.all_types(id) ON DELETE CASCADE, + amount NUMERIC(10,2) +); +CREATE TABLE test_schema.order_items ( + order_id INTEGER, item_no INTEGER, + product TEXT, + PRIMARY KEY (order_id, item_no), + FOREIGN KEY (order_id) REFERENCES test_schema.orders(id) +); + +-- Indexes (btree, unique, partial, composite) +CREATE INDEX idx_all_types_text ON test_schema.all_types (col_text); +CREATE UNIQUE INDEX idx_all_types_uuid ON test_schema.all_types (col_uuid); +CREATE INDEX idx_orders_amount_positive ON test_schema.orders (amount) + WHERE amount > 0; + +-- Views +CREATE VIEW test_schema.active_users AS + SELECT id, col_text AS name FROM test_schema.all_types WHERE col_bool = true; + +-- Materialized views +CREATE MATERIALIZED VIEW test_schema.user_stats AS + SELECT COUNT(*) as total FROM test_schema.all_types; + +-- Functions and procedures +CREATE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER) + RETURNS INTEGER LANGUAGE SQL AS $$ SELECT a + b $$; + +CREATE FUNCTION test_schema.get_user(p_id INTEGER) + RETURNS TABLE(id INTEGER, name TEXT) LANGUAGE SQL AS $$ + SELECT id, col_text FROM test_schema.all_types WHERE id = p_id +$$; + +-- Overloaded function (same name, different args) +CREATE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER, c INTEGER) + RETURNS INTEGER LANGUAGE SQL AS $$ SELECT a + b + c $$; + +CREATE PROCEDURE test_schema.reset_data() LANGUAGE SQL AS $$ + DELETE FROM test_schema.order_items; + DELETE FROM test_schema.orders; +$$; + +-- Triggers +CREATE FUNCTION test_schema.audit_trigger_fn() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'Row modified in %', TG_TABLE_NAME; + RETURN NEW; +END $$; + +CREATE TRIGGER trg_audit AFTER UPDATE ON test_schema.all_types + FOR EACH ROW EXECUTE FUNCTION test_schema.audit_trigger_fn(); + +-- Cross-schema FK (for ref_schema testing) +CREATE SCHEMA IF NOT EXISTS other_schema; +CREATE TABLE other_schema.lookup ( + code TEXT PRIMARY KEY, label TEXT +); +CREATE TABLE test_schema.with_cross_schema_fk ( + id SERIAL PRIMARY KEY, + lookup_code TEXT REFERENCES other_schema.lookup(code) +); + +-- SECONDARY DATABASE (for multi-database testing) +-- Must be created via separate connection to maintenance DB +-- CREATE DATABASE tabularis_test_secondary; +-- Then connect to it and run: +-- CREATE SCHEMA secondary_schema; +-- CREATE TABLE secondary_schema.remote_data (id SERIAL PRIMARY KEY, value TEXT); +``` + +**Seed runner script:** `tests/fixtures/run_seed.sh` + +```bash +#!/bin/bash +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -d tabularis_test \ + -f tests/fixtures/postgres_seed.sql + +# Create secondary database +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -c \ + "SELECT 'exists' FROM pg_database WHERE datname='tabularis_test_secondary'" \ + | grep -q exists || \ +PGPASSWORD=test createdb -h localhost -p 54320 -U postgres tabularis_test_secondary + +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -d tabularis_test_secondary -c " + CREATE SCHEMA IF NOT EXISTS secondary_schema; + CREATE TABLE IF NOT EXISTS secondary_schema.remote_data ( + id SERIAL PRIMARY KEY, value TEXT + ); + INSERT INTO secondary_schema.remote_data (value) + SELECT 'row_' || g FROM generate_series(1, 5) g + ON CONFLICT DO NOTHING; +" +``` + +--- + +### 0.3: Parity Test Harness + +**What:** A test infrastructure that runs identical assertions against two +different driver implementations. + +**Why:** This is how we mechanically prove the plugin matches the built-in driver. +In Phase 0, only the built-in driver fills it. In Phase 1, the plugin is added. + +**Design:** + +```rust +// tests/parity/harness.rs + +use std::fmt::Debug; + +pub enum DriverTarget { + Builtin, // Uses the built-in postgres driver via Tauri commands + Plugin(String), // Uses the plugin driver (id = "postgres-plugin") +} + +pub struct ParityHarness { + targets: Vec, + pg_host: String, + pg_port: u16, + pg_user: String, + pg_password: String, + pg_database: String, +} + +impl ParityHarness { + /// Run a test function against all configured targets and assert identical results + pub async fn assert_parity(&self, method_name: &str, test_fn: F) + where + T: PartialEq + Debug + serde::Serialize, + F: Fn(DriverTarget) -> Fut, + Fut: std::future::Future>, + { + let results: Vec<_> = /* run test_fn against each target */; + // Compare all results pairwise + for window in results.windows(2) { + assert_eq!(window[0], window[1], + "Parity failure in '{}': targets returned different results", + method_name); + } + } +} +``` + +**Phase 0 usage:** Only `DriverTarget::Builtin` is registered. Tests pass +trivially (one result, nothing to compare). But the harness is ready for Phase 1 +to add `DriverTarget::Plugin`. + +**Phase 1 usage:** Both targets registered. Tests now compare outputs. + +--- + +### 0.4: Golden File Capture + +**What:** Run every public method against the seeded test database and save the +output as JSON files. These become the parity contract. + +**Why:** Golden files catch subtle differences that `assert_eq` on structs might +miss (field ordering, null vs absent, number precision). + +**Directory:** `tests/parity/golden/` + +**How to capture:** + +```rust +// tests/parity/capture_golden.rs (run once to generate golden files) +#[tokio::test] +#[ignore] // Only run manually to regenerate golden files +async fn capture_golden_files() { + let harness = ParityHarness::builtin_only(); + + let tables = harness.get_tables("test_schema").await; + write_golden("get_tables.json", &tables); + + let columns = harness.get_columns("all_types", "test_schema").await; + write_golden("get_columns_all_types.json", &columns); + + // ... for every method +} +``` + +**Golden files to capture:** + +```text +src-tauri/tests/postgres_integration/golden/ +├── get_databases.json +├── get_schemas.json +├── get_tables.json +├── get_columns_all_types.json +├── get_columns_with_enum.json +├── get_indexes_all_types.json +├── get_foreign_keys_orders.json +├── get_foreign_keys_cross_schema.json +├── get_views.json +├── get_view_definition_active_users.json +├── get_view_columns_active_users.json +├── get_materialized_views.json +├── get_mv_definition.json ← captures known regclass error +├── get_mv_columns.json +├── get_routines.json +├── get_routine_parameters_add_numbers.json +├── get_routine_definition_add_numbers.json +├── get_triggers.json +├── get_trigger_definition_audit.json +├── execute_query_all_types.json +├── execute_query_with_pagination.json +├── explain_simple.json +├── explain_analyze.json +├── count_query.json +└── multi_db/ + ├── get_schemas_secondary.json + └── get_tables_secondary.json +``` + +**Note:** DDL golden files (`ddl/*.sql`) were removed from scope. DDL generation +produces SQL statements whose correctness depends on dialect and formatting — not +on byte-exact reproducibility. The `ddl_generation.rs` tests validate DDL output +structurally (contains correct keywords, types, constraints) which is the right +parity approach. Exact-match golden files for DDL would create brittle tests that +break on whitespace changes without catching real bugs. + +Similarly, `multi_db/get_databases.json` was dropped because `get_databases` is +server-wide (returns the same result regardless of which database you connect to) +— it's already captured at the top level. + +--- + +### 0.5: Integration Test Suite (55+ tests) + +**What:** Dedicated integration tests for every public method, organized by domain. + +**Structure:** + +```text +src-tauri/tests/postgres/ +├── mod.rs # Shared test setup, connection helpers +├── schema_discovery.rs # 4 tests +├── column_metadata.rs # 6 tests +├── foreign_keys.rs # 4 tests +├── indexes.rs # 4 tests +├── views.rs # 6 tests +├── materialized_views.rs # 4 tests +├── routines.rs # 6 tests +├── triggers.rs # 4 tests +├── crud.rs # 9 tests +├── ddl_generation.rs # 7 tests +├── explain.rs # 3 tests +├── blob.rs # 3 tests +├── query_execution.rs # 6 tests +└── multi_database.rs # 7 tests + ───────── + Total: 73 tests +``` + +(Note: 55 was a minimum estimate — full coverage is likely 70+.) + +**Each test follows this structure:** + +```rust +#[tokio::test] +async fn test_get_columns_all_types() { + let harness = test_harness().await; + + let columns = harness.get_columns("all_types", Some("test_schema")).await + .expect("get_columns should succeed"); + + // Structural assertions + assert_eq!(columns.len(), 24, "all_types has 24 columns"); + + // Specific column assertions + let id_col = columns.iter().find(|c| c.name == "id").unwrap(); + assert!(id_col.is_pk); + assert!(id_col.is_auto_increment); + assert_eq!(id_col.data_type, "integer"); + + let uuid_col = columns.iter().find(|c| c.name == "col_uuid").unwrap(); + assert_eq!(uuid_col.data_type, "uuid"); + assert!(!uuid_col.is_nullable); // has DEFAULT but NOT NULL isn't set... verify + + // Golden file comparison + harness.assert_matches_golden("get_columns_all_types.json", &columns); +} +``` + +--- + +### 0.6: Un-ignore Existing Tests + +**What:** Remove `#[ignore]` from the 4 existing integration tests and verify +they pass in CI with the new PG service. + +**Tests:** + +- `test_postgres_integration_flow` +- `test_postgres_batch_preserves_temp_table_and_transaction` +- `test_postgres_affected_rows_reported_correctly` +- `test_postgres_foreign_keys_via_pg_catalog` + +--- + +## Implementation Order + +```text +Week 1: + 0.1 — CI PG service (unblocks everything) + 0.2 — Seed script (needed by all tests) + 0.6 — Un-ignore existing tests (quick win, validates CI setup) + +Week 2: + 0.3 — Parity harness infrastructure + 0.5 — Write integration tests (start with schema_discovery, column_metadata) + +Week 3: + 0.5 — Continue integration tests (crud, ddl, query_execution, multi_database) + 0.4 — Capture golden files (can only run after tests exist) + +Week 4: + 0.5 — Remaining integration tests (views, MVs, routines, triggers, blob, explain) + Final verification — all tests green against built-in driver +``` + +--- + +## Checkpoint: CP-2 + +**When:** All Phase 0 deliverables complete. + +**Verify:** + +- [x] CI runs PG service and all integration tests pass +- [x] 70+ integration tests exist and are GREEN against built-in driver (102 tests) +- [x] Golden files captured for every public method (26 files) +- [x] Parity harness ready to accept a second driver target +- [x] Seed script is idempotent (can run multiple times without error) +- [x] Multi-database tests pass (secondary database accessible) +- [x] CI total time < 5 minutes (~10s test execution + ~6m build) + +**Communicate to team:** + +- Baseline is established — we have objective proof of how the built-in driver behaves +- Phase 1 can begin — the plugin will be built to pass these exact tests +- No user-facing changes — this is all internal test infrastructure +- Share the test count as the "parity contract" the plugin must satisfy + +--- + +## Ship / Release Gate + +**Phase 0 does NOT produce a shippable release.** It is purely internal +infrastructure. However, the CI improvements (PG service, un-ignored tests) DO +improve quality for ALL future PRs touching the PostgreSQL driver. This is value +delivered to the team even if the plugin migration never proceeds. + +--- + +## Definition of Done + +- [x] CI workflow includes PostgreSQL 16 service +- [x] Seed script exists and is run automatically in CI +- [x] 70+ integration tests written and passing (102 total) +- [x] Golden files captured and committed to repo (26 files) +- [x] Parity harness infrastructure committed +- [x] Existing 4 integration tests un-ignored and passing +- [x] Multi-database seed (secondary DB) working +- [x] All tests pass deterministically (sequential execution, 2 consecutive green runs) +- [ ] CP-2 sync completed with core team diff --git a/docs/planning/02-phase-1-plugin-build.md b/docs/planning/02-phase-1-plugin-build.md new file mode 100644 index 0000000..6da2970 --- /dev/null +++ b/docs/planning/02-phase-1-plugin-build.md @@ -0,0 +1,515 @@ +# Phase 1 — Plugin Build (TDD) + +**Goal:** Build the `postgres-plugin` executable that passes all 80 parity tests, +proving byte-for-byte parity with the built-in driver. Implementation follows +strict TDD: tests exist first (written RED), code is written to make them pass. + +**Mantra:** _80/80 green or it's not done. No exceptions, no "close enough."_ + +--- + +## Test Architecture + +The test suite has three layers, each serving a distinct purpose: + +| Layer | Count | What it proves | Runs against | +|-------|-------|----------------|--------------| +| **Parity tests** | 80 | Plugin output == builtin output (byte-perfect JSON comparison) | Both drivers via `ParityHarness` | +| **Baseline tests** | 72 | Builtin driver behavior hasn't regressed | Builtin only (direct `postgres::*` calls) | +| **Golden tests** | 26 | Builtin output matches committed snapshots (drift detection) | Builtin only | + +### Why 80 parity tests (not 102) + +The "102 tests" figure included all three layers. Parity tests only cover the +first layer because: + +1. **Golden tests (26) don't need parity equivalents.** Golden files compare + builtin output against static JSON files. `assert_parity()` is strictly + stronger — it's a live comparison of two running drivers. If the plugin + matches the builtin, it implicitly matches the golden files too. + +2. **Baseline tests (72) are the safety net, not the specification.** They + call the builtin directly via `postgres::get_tables(...)` — they cannot run + against the plugin (it speaks JSON-RPC, not Rust function calls). The parity + tests cover every scenario from the baseline by calling the same methods + through the `DatabaseDriver` trait. + +3. **Parity tests are MORE thorough.** They test additional edge cases beyond + the baseline (composite PKs, cross-schema FKs, NULL updates, batch session + state, etc.) — 80 scenarios covering all 72 baseline behaviors plus extras. + +### CP-4 Gate + +All three layers must pass: + +- 80/80 parity tests GREEN → plugin matches builtin byte-perfectly +- 72/72 baseline tests GREEN → builtin hasn't regressed +- 26/26 golden tests GREEN → no snapshot drift + +--- + +## Approach + +### The Red → Green Cadence + +At the start of Phase 1, point the parity harness at the plugin: + +```bash +cargo test --features parity +# Result: 0/55 GREEN, 55/55 RED (plugin binary doesn't exist) +``` + +Implementation proceeds sprint by sprint. After each sprint: + +```bash +cargo build --release +# Install plugin binary to local plugins directory +cargo test --features parity +# Result: N/55 GREEN — N must only increase, never decrease +``` + +**Rule:** If a previously-GREEN test goes RED, stop everything and fix it before +moving forward. No sprint is "done" with regressions. + +--- + +## Sprint Breakdown + +### Sprint 1: Foundation (Scaffold + Connection) + +**Build:** + +- `main.rs` — tokio runtime, stdin reader, stdout writer, JSON-RPC dispatch loop +- `rpc.rs` — method name → handler routing +- `models.rs` — `ConnectionParams` deserialization from JSON +- `pool.rs` — `deadpool-postgres` pool manager, keyed by `host:port:database:user` + +**Implement RPC methods:** + +- `initialize` — receive settings, acknowledge +- `ping` — acquire connection from pool, run `SELECT 1` +- `test_connection` — same as ping but with full error reporting +- `shutdown` — drain pools, exit cleanly + +**Critical decisions at this point:** + +- Pool configuration: max size, connection timeout, idle timeout +- SSL: `tokio-postgres-rustls` integration +- Startup script: `after_connect` hook that executes `params.startup_script` + +**Security considerations:** + +- `ConnectionParams.password` arrives in plaintext JSON. Store in memory only + for the duration needed to create the pool. Don't log it. +- `connection_string` may contain credentials embedded in URL. Parse carefully. +- SSL certificate paths (`ssl_ca`, `ssl_cert`, `ssl_key`) should be validated + (file exists, readable) before attempting connection. + +**Tests expected to go GREEN:** 3 (connection-related tests) + +--- + +### Sprint 2: Schema Discovery + +**Implement:** + +- `get_databases` — `SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate` +- `get_schemas` — `SELECT schema_name FROM information_schema.schemata WHERE ...` +- `get_tables` — query `pg_class` / `information_schema.tables` + +**Gotchas:** + +- Filter system schemas (`pg_catalog`, `information_schema`, `pg_toast`) +- Handle `schema` param being `None` (return all schemas' tables) vs `Some("public")` +- Table names must include `schema` qualification in responses where expected +- `get_databases` must work when connected to maintenance DB (`"postgres"`) + +**Tests expected to go GREEN:** 8 cumulative + +--- + +### Sprint 3: Column & Key Metadata + +**Implement:** + +- `get_columns` — `information_schema.columns` + PG catalog for extended info +- `get_indexes` — `pg_class` + `pg_index` + `pg_attribute` +- `get_foreign_keys` — `pg_constraint` with JOIN to get column names, ref table, actions + +**Port from built-in:** + +- `extract/` submodules (needed to correctly identify column types) +- Logic for detecting `SERIAL` → `is_auto_increment: true` +- Logic for parsing `character_maximum_length` + +**Gotchas:** + +- Enum type detection: must query `pg_type` + `pg_enum` to identify enum columns +- `default_value` for serial columns shows `nextval('seq')` — preserve as-is +- `ref_schema` in FK results — include from day one for multi-database support +- Composite indexes: `seq_in_index` must be correct for multi-column indexes + +**Security consideration:** + +- Column metadata queries should not expose system catalog internals beyond + what's needed. Don't return `pg_catalog` tables in `get_tables` responses. + +**Tests expected to go GREEN:** 18 cumulative + +--- + +### Sprint 4: Query Execution + +**Implement:** + +- `execute_query` — run arbitrary SQL, return `QueryResult` +- `execute_query_batch` — run multiple statements on SINGLE connection (session state) +- `count_query` — `SELECT COUNT(*) FROM (user_query) AS q` + +**Port from built-in:** + +- Full `extract/` subsystem (all PG types → `serde_json::Value` conversion) +- Pagination: `LIMIT {page_size} OFFSET {(page-1) * page_size}` +- `has_more` detection: query `page_size + 1` rows, return `page_size` + +**Critical: `execute_query_batch` session semantics** + +This is the highest-risk area for behavioral regression: + +```rust +// MUST use a SINGLE connection for the entire batch +let conn = pool.get().await?; +let mut results = vec![]; +for statement in statements { + let result = execute_on_conn(&conn, &statement, limit, page).await?; + results.push(result); +} +``` + +If each statement gets its own connection, `BEGIN`/`COMMIT`, temp tables, and +`SET` commands will break silently. This is a non-negotiable correctness +requirement. + +**Gotchas:** + +- DML statements (INSERT/UPDATE/DELETE) return `affected_rows`, not result set +- `SET` statements return empty result with `affected_rows: 0` +- Multiple result sets: PostgreSQL doesn't support this (unlike MySQL). Each + statement in a batch returns one result. +- Query cancellation: if the host aborts the RPC call mid-batch, the connection + should be returned to the pool (not leaked) + +**Security:** + +- Parameterized queries are NOT used here (user provides raw SQL). This is by + design — the app is a SQL editor. But ensure no metadata queries constructed + internally are injectable. + +**Tests expected to go GREEN:** 26 cumulative + +--- + +### Sprint 5: CRUD Operations + +**Implement:** + +- `insert_record` — generate `INSERT INTO ... VALUES (...)` with typed bindings +- `update_record` — generate `UPDATE ... SET ... WHERE pk = ...` with typed bindings +- `delete_record` — generate `DELETE FROM ... WHERE pk = ...` + +**Port from built-in:** + +- `binding.rs` — the most critical and complex piece: + - Enum column detection → `$N::enum_type` CAST syntax + - UUID string → UUID type binding + - JSON object/array → JSONB binding + - Array values → PostgreSQL array syntax + - Numeric string → appropriate numeric type + - Boolean string → PG boolean literals + - Temporal strings → timestamp/date/time with timezone handling + - DEFAULT sentinel → `DEFAULT` keyword in SQL + - NULL handling + +**This is the highest-risk sprint.** The binding system has subtle type-specific +behavior that, if wrong, causes silent data corruption. For example: + +- Missing enum CAST → PostgreSQL error "column X is of type mood but expression is text" +- Wrong numeric binding → silent precision loss +- Missing UUID detection → type mismatch error + +**Verification approach:** After implementing, run CRUD tests that: + +1. Insert a row with every type +2. Read it back via `execute_query` +3. Compare round-trip values + +**Gotchas:** + +- Composite PKs in WHERE clause: must handle multi-column keys correctly +- UUID PKs: must detect UUID format in PK value and bind as UUID type +- NULL in PK: should be rejected (PKs are NOT NULL by definition) +- Schema-qualified table names in generated SQL + +**Tests expected to go GREEN:** 35 cumulative + +--- + +### Sprint 6: Views & Materialized Views + +**Implement:** + +- `get_views` — query `pg_views` / `information_schema.views` +- `get_view_definition` — `pg_get_viewdef(oid)` +- `get_view_columns` — same as `get_columns` but for view +- `create_view` / `alter_view` / `drop_view` — DDL execution +- `get_materialized_views` — query `pg_matviews` +- `get_materialized_view_definition` — from `pg_matviews.definition` +- `get_materialized_view_columns` — from `pg_attribute` +- `refresh_materialized_view` — `REFRESH MATERIALIZED VIEW ...` + +**Gotchas:** + +- `alter_view` in PG is `CREATE OR REPLACE VIEW` (true ALTER is limited) +- Materialized views have no row count until `ANALYZE` is run +- MV columns query must use `pg_attribute` (not `information_schema`) +- `REFRESH MATERIALIZED VIEW CONCURRENTLY` requires a unique index — don't + assume concurrency is always possible + +**Tests expected to go GREEN:** 41 cumulative + +--- + +### Sprint 7: Routines & Triggers + +**Implement:** + +- `get_routines` — query `pg_proc` + `pg_namespace` +- `get_routine_parameters` — query `pg_proc.proargnames` + `pg_proc.proargtypes` +- `get_routine_definition` — `pg_get_functiondef(oid)` +- `build_routine_call_sql` — generate `SELECT func(...)` or `CALL proc(...)` +- `routine_create_template` — generate `CREATE OR REPLACE FUNCTION/PROCEDURE` +- `get_routine_edit_script` — same as definition (PG functions are re-runnable) +- `drop_routine` — handle overloaded functions (need argument types in DROP) +- `get_triggers` — query `pg_trigger` + `information_schema.triggers` +- `get_trigger_definition` — `pg_get_triggerdef(oid)` +- `create_trigger` / `drop_trigger` / `update_trigger` — DDL execution + +**Gotchas — Routine management is complex in PG:** + +- Overloaded functions: same name, different argument types. `DROP FUNCTION` + requires the argument signature: `DROP FUNCTION add_numbers(integer, integer)` +- Functions vs procedures: different call syntax (`SELECT` vs `CALL`) +- IN/OUT/INOUT parameters: affect call SQL generation +- `SECURITY DEFINER` functions: execute with creator's privileges (security relevant) +- `SET` options on functions: must be preserved in edit scripts + +**Gotchas — Triggers:** + +- PG triggers can fire FOR EACH ROW or FOR EACH STATEMENT +- Trigger functions are separate objects (function must exist before trigger) +- `update_trigger` = DROP + CREATE (PG has no ALTER TRIGGER for body changes) + +**Tests expected to go GREEN:** 48 cumulative + +--- + +### Sprint 8: DDL, EXPLAIN, BLOB + +**Implement:** + +- `get_create_table_sql` — generate `CREATE TABLE` with all columns, PKs, constraints +- `get_add_column_sql` — `ALTER TABLE ADD COLUMN ...` +- `get_alter_column_sql` — `ALTER TABLE ALTER COLUMN ...` (rename, type, null, default) +- `get_create_index_sql` — `CREATE [UNIQUE] INDEX ...` +- `drop_index` — `DROP INDEX schema."index_name"` +- `get_create_foreign_key_sql` — `ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ...` +- `drop_foreign_key` — `ALTER TABLE DROP CONSTRAINT ...` +- `explain_query_plan` — `EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) ...` +- `save_blob_to_file` — query bytea column, write raw bytes to file path +- `fetch_blob_as_data_url` — query bytea column, return as `BLOB:size:mime:base64` +- `get_ai_schema_context` — return schema DDL as context for AI features + +**Gotchas — DDL:** + +- Schema-qualified identifiers everywhere: `"schema"."table"` +- SERIAL type: `get_create_table_sql` must use `SERIAL` not `INTEGER DEFAULT nextval` +- `ALTER COLUMN TYPE` may require `USING` clause for type casts + +**Gotchas — EXPLAIN:** + +- Parse JSON format explain output into `ExplainNode` tree +- `ANALYZE` actually executes the query — handle DML carefully +- `BUFFERS` option only available with `ANALYZE` +- Cost units are PG-specific (not milliseconds) + +**Gotchas — BLOB:** + +- PostgreSQL uses `bytea` (inline) or Large Objects (OID reference) +- Built-in driver uses `bytea` approach: `SELECT col FROM table WHERE pk = val` +- Return value must match the `BLOB:size:mime:base64` wire format exactly +- File write must handle binary data correctly (no UTF-8 assumptions) + +**Security — BLOB:** + +- `save_blob_to_file` writes to an arbitrary path. The plugin runs locally so + this is the same trust model as any desktop app file write. But validate the + path doesn't escape expected directories if possible. + +**Tests expected to go GREEN:** 53 cumulative + +--- + +### Sprint 9: Multi-Database & Polish + +**Verify:** + +- `get_databases` returns both `tabularis_test` and `tabularis_test_secondary` +- Queries with different `params.database` hit different pools +- Schema discovery on secondary database returns `secondary_schema` +- FK results include `ref_schema` for cross-schema references +- Pool cleanup on shutdown drains all per-database pools + +**Fix:** Any remaining edge cases or test failures from previous sprints. + +**Final verification run:** + +```bash +cargo test --features parity +# Result: 55/55 GREEN ✅ (or 70+/70+ if more tests were written) +``` + +**Tests expected to go GREEN:** 55/55 (ALL) + +--- + +## Security Audit Checklist (End of Phase 1) + +Before declaring Phase 1 complete, verify: + +- [ ] **No credential logging** — `password`, `ssh_password` never appear in stdout/stderr +- [ ] **Pool credentials in memory only** — not written to temp files, not in stack traces +- [ ] **SSL verification working** — `verify-ca` and `verify-full` modes actually validate certs +- [ ] **SQL injection in internal queries** — all metadata queries use parameterized bindings + (not string interpolation with user-provided table/column names) +- [ ] **File path validation** — `save_blob_to_file` validates path is writable +- [ ] **Startup script execution** — runs in a try/catch, error doesn't leak connection +- [ ] **Connection string parsing** — malformed URLs don't crash the plugin +- [ ] **Memory cleanup** — pools are properly drained on shutdown (no leaked connections) + +--- + +## Checkpoint: CP-3 (Mid-Phase Progress Check) + +**When:** Plugin is at approximately 25/55 tests GREEN (after Sprint 4). + +**Purpose:** Early signal to the core team that implementation is on track. + +**Communicate:** + +- Current test count (objective progress metric) +- Any blockers discovered (unexpected RPC limitations, type handling issues) +- Revised timeline estimate if needed +- Demo: connect to PG via plugin, run a SELECT, show results + +**This is NOT a release gate.** It's a progress sync to catch issues early. + +--- + +## Checkpoint: CP-4 (Phase 1 Complete — Beta Release Gate) + +**When:** 80/80 parity tests GREEN + baseline tests pass + manual smoke test complete. + +**This IS a release gate.** After CP-4: + +- The plugin can be published to the Tabularium registry as a **beta** +- Users can install it alongside the built-in driver and test +- Feedback collection begins (does it work with their specific PG setups?) + +**Verify at CP-4:** + +- [ ] 80/80 parity tests GREEN (byte-perfect dual-driver comparison) +- [ ] 72 baseline tests pass (builtin-only safety net) +- [ ] 26 golden snapshot tests pass (no drift) +- [ ] Manual smoke test: all 24 items pass +- [ ] `pnpm test` (frontend): no regressions +- [ ] Security audit checklist: all items verified +- [ ] Plugin binary builds on all 3 platforms (macOS, Linux, Windows) +- [ ] Plugin installs cleanly via Tabularis Settings > Plugins +- [ ] Built-in driver still works unchanged (no interference) + +**Communicate to team:** + +- Feature parity achieved and proven +- Ready for beta testing with real users +- Phase 2 (issue #16 improvements) can begin +- Collect feedback on performance, compatibility, edge cases + +--- + +## Repo Extraction — Timing and Open Question + +**Access to `TabularisDB/tabularis-postgresql-plugin` was granted during Phase 1 +development (2026-08-05).** Decision: stay in-tree through CP-4, then extract. + +**Why wait:** + +- Phase 1 is mid-TDD with a tight build → test → feedback loop within a single + CI run (`pg-integration.yml` builds the plugin and runs all 80 parity tests + against it in one job). Splitting into two repos now means cross-repo CI + (the host would need to clone/build the plugin repo as a dependency, or pull + release artifacts) — friction that actively hurts iteration speed while the + RPC surface and manifest are still shifting commit to commit. +- This matches the plan's original intent: build in-tree through Phase 1, + extract to a standalone repo at the CP-4 beta gate — consistent with how + every other Tabularis plugin (DuckDB, ClickHouse, DynamoDB, etc.) is + structured as an external repo. + +**Open question to resolve before/at CP-4 — where do the 80 parity tests live +post-extraction?** + +The parity tests currently live in `tabularis`'s own test suite +(`src-tauri/tests/postgres_integration/parity*.rs`). They import +`tabularis_lib` types directly (`DatabaseDriver`, `PostgresDriver`, +`ConnectionParams`, etc.) and spawn the plugin binary in-process via +`RpcDriver::new()`. Two options once the plugin moves to its own repo: + +1. **Keep parity tests in `tabularis`.** The host CI would need to build or + fetch the plugin binary from the new repo (e.g. checkout as a step, or + download a release artifact) before running the existing test suite + unchanged. Simpler on the plugin-repo side; adds a cross-repo dependency + to `tabularis`'s CI. +2. **Move parity tests to the plugin repo.** The plugin repo would need a + `tabularis_lib` dependency (path or published crate) to get `DatabaseDriver` + and the builtin `PostgresDriver` for comparison. Keeps the plugin + self-testing but couples it to the host's internal crate — `tabularis_lib` + isn't currently published or designed for external consumption. + +Revisit this when CP-4 is close — by then the RPC surface should be stable +enough that the decision doesn't need to be made twice. + +--- + +## Potential Gaps & Risks Specific to Phase 1 + +| Gap/Risk | Impact | Mitigation | +| -------- | ------ | ---------- | +| `tokio-postgres` type handling differs from `sqlx` | Extraction code must be rewritten, not just copied | Port logic, not code. Test each type individually. | +| Binary wire format vs text format | Built-in uses binary (sqlx default); plugin may start with text. Values might format differently (e.g., float precision). | Golden file tests will catch any formatting differences immediately. | +| Pool exhaustion under load | Plugin has one process for all connections. Deadpool defaults may be too conservative. | Configure max_size based on expected concurrent queries. Monitor in beta. | +| Plugin stderr noise | Accidental stdout writes corrupt JSON-RPC stream | Use `tracing` crate with stderr subscriber. Never use `println!`. Add a CI test that verifies no stdout writes outside JSON-RPC. | +| Cross-platform binary build | Plugin must compile for macOS (ARM+Intel), Linux, Windows | Set up cross-compilation in CI. Test on all platforms before CP-4. | + +--- + +## Definition of Done + +- [ ] 80/80 parity tests GREEN +- [ ] 72 baseline tests pass (builtin-only safety net) +- [ ] 26 golden snapshot tests pass +- [ ] Manual smoke test: 24/24 items pass +- [ ] Security audit checklist: complete +- [ ] Plugin builds on macOS, Linux, Windows +- [ ] Plugin installs and runs cleanly in Tabularis +- [ ] Built-in driver unaffected (both can coexist) +- [ ] CP-4 sync completed with core team +- [ ] Published to Tabularium registry as beta diff --git a/docs/planning/03-phase-2-issue-16.md b/docs/planning/03-phase-2-issue-16.md new file mode 100644 index 0000000..aa39d16 --- /dev/null +++ b/docs/planning/03-phase-2-issue-16.md @@ -0,0 +1,344 @@ +# Phase 2 — Issue #16 Improvements + +**Goal:** Add the PostgreSQL-specific features identified in issue #16 that go +beyond what the built-in driver supports. This is where the plugin exceeds the +built-in driver and becomes the definitively better PostgreSQL experience. + +**Prerequisite:** Phase 1 complete (55/55 parity tests GREEN, beta published). + +--- + +## Approach + +### TDD Continues + +Each new feature follows the same discipline: + +1. Write the test (RED) +2. Implement the feature (GREEN) +3. Verify no regressions (all previous tests still GREEN) + +### Plugin-Only Development + +Phase 2 features go into the plugin only — they do NOT exist in the built-in +driver. This is the first divergence point: the plugin becomes strictly superior. + +### UI Extensions + +Some features may need frontend UI. The plugin manifest supports `ui_extensions` +for injecting custom panels/tabs. However, for Phase 2, most features expose +through existing UI patterns (sidebar tree nodes, query results, context menus). + +### Check for Existing In-Flight Work + +**Before implementing any feature, check for open PRs that already address it.** +Duplicating community work wastes effort and creates merge conflicts. + +**Known in-flight PRs relevant to Phase 2 (as of this writing):** + +| PR | Feature | Author | Status | +| -- | ------- | ------ | ------ | +| [#427](https://github.com/TabularisDB/tabularis/pull/427) | HStore column editing | arturbent0 | Open | +| [#402](https://github.com/TabularisDB/tabularis/pull/402) | Multi-database connections | debba | Draft | +| [#222](https://github.com/TabularisDB/tabularis/pull/222) | Composite PK end-to-end | saurabh500 | Draft | + +**Process for each Phase 2 feature:** + +1. Search open PRs: `gh pr list --repo TabularisDB/tabularis --search ""` +2. If a PR exists and is active → coordinate with the author, don't duplicate +3. If a PR exists but is stale (>2 months inactive) → comment asking if still active; + if no response in 1 week, proceed with your own implementation +4. If no PR exists → proceed + +**For PR #427 (HStore) specifically:** This work already exists. When Phase 2 +reaches hstore support, either: + +- The PR has merged → we port its logic into the plugin (or the plugin + inherits it via the existing driver trait behavior) +- The PR hasn't merged → coordinate with `arturbent0` to align with the plugin + architecture (their work may target the built-in driver and need adaptation) + +--- + +## Features (Priority Order) + +### 2.1: Sequence Management + +**What:** List, inspect, create, alter, reset, and drop PostgreSQL sequences. + +**Why:** Sequences are fundamental to PG (every SERIAL/BIGSERIAL creates one). +Currently invisible in Tabularis — users must write raw SQL to manage them. + +**Implementation:** + +| Method | SQL | +| ------ | --- | +| List sequences | `SELECT * FROM pg_sequences WHERE schemaname = $1` | +| Get sequence details | `SELECT * FROM pg_sequences WHERE sequencename = $1` | +| Get current value | `SELECT currval('schema.seq')` or `last_value` from pg_sequences | +| Reset sequence | `ALTER SEQUENCE schema.seq RESTART WITH $1` | +| Set sequence value | `SELECT setval('schema.seq', $1)` | +| Create sequence | `CREATE SEQUENCE schema.seq [INCREMENT BY ...] [START WITH ...]` | +| Drop sequence | `DROP SEQUENCE schema.seq` | + +**Frontend integration:** Sequences appear in the sidebar under a "Sequences" +node (same level as Tables, Views, Routines). Double-click opens a detail panel. + +**Tests:** + +- `test_get_sequences` — lists all sequences in schema +- `test_get_sequence_details` — returns increment, min, max, start, current +- `test_reset_sequence` — verify value changes +- `test_create_and_drop_sequence` — lifecycle + +--- + +### 2.2: JSONB Inline Editing + +**What:** Edit JSONB column values with structured awareness — add/remove keys, +modify nested values, toggle between raw JSON text and structured editor. + +**Why:** Currently JSONB is edited as a raw text string. This is error-prone for +complex nested objects. A structured editor prevents syntax errors. + +**Implementation approach:** + +This is primarily a **frontend feature** (UI extension). The plugin's role is: + +1. Detect JSONB columns and flag them in `get_columns` response (already done — `data_type: "jsonb"`) +2. Validate JSON on `update_record` — return clear error if invalid JSON is submitted +3. Optionally: expose `jsonb_set`, `jsonb_insert`, `jsonb_delete_path` as helper operations + +**Plugin-side additions:** + +- New RPC method: `validate_jsonb(value)` → returns ok or parse error with position +- New RPC method: `jsonb_patch(params, table, pk, path, operation, value)` → applies a + targeted JSONB modification without overwriting the entire value + +**Frontend UI extension:** + +- JSON tree editor component (expand/collapse nodes, edit values inline) +- Add/remove key buttons +- Path breadcrumb showing current location in the JSON tree +- Raw mode toggle (switch between tree and text editor) + +**Tests:** + +- `test_insert_complex_jsonb` — nested objects, arrays, mixed types +- `test_update_jsonb_full_replace` — overwrite entire value +- `test_jsonb_patch_add_key` — add key to existing object +- `test_jsonb_patch_remove_key` — remove key from object +- `test_jsonb_patch_nested_update` — modify deeply nested value +- `test_invalid_jsonb_rejected` — malformed JSON returns clear error + +--- + +### 2.3: Extension-Aware Type System + +**What:** Detect installed PostgreSQL extensions and expose their types in the +type picker and column handling. + +**Extensions to support initially:** + +- **PostGIS** — geometry, geography, raster types +- **pgvector** — vector(N) type for embeddings +- **ltree** — label tree type +- **hstore** — key-value store (legacy, still common) — **see PR #427 (in-flight)** +- **citext** — case-insensitive text + +**Implementation:** + +```sql +-- Detect installed extensions +SELECT extname, extversion FROM pg_extension WHERE extname IN ( + 'postgis', 'vector', 'ltree', 'hstore', 'citext' +); +``` + +For each detected extension, add its types to the runtime type list. The plugin +can dynamically extend `data_types` after `initialize` by checking what's installed. + +**Gotcha:** The `data_types` in the manifest are static. Dynamic type discovery +requires either: + +- A new RPC method: `get_dynamic_data_types(params)` → returns additional types + based on what's installed +- Or: the plugin returns a comprehensive superset and the UI filters by what's + actually usable + +**Tests:** + +- `test_detect_postgis_extension` (requires PG with PostGIS — optional CI extension) +- `test_vector_type_handling` (requires pgvector) +- `test_ltree_insert_and_query` + +**Note:** These tests may need to be `#[ignore]` in CI unless extensions are +installed in the test container. Consider a separate "extended type" test profile. + +--- + +### 2.4: Partition Table Introspection + +**What:** Show partition hierarchy in the sidebar — parent table with child +partitions listed underneath. Show partition key and bounds. + +**Implementation:** + +```sql +-- Find partitioned tables +SELECT c.relname, pg_get_partkeydef(c.oid) as partition_key +FROM pg_class c +JOIN pg_namespace n ON c.relnamespace = n.oid +WHERE c.relkind = 'p' AND n.nspname = $1; + +-- Find partitions of a parent table +SELECT c.relname, pg_get_expr(c.relpartbound, c.oid) as partition_bound +FROM pg_inherits i +JOIN pg_class c ON i.inhrelid = c.oid +WHERE i.inhparent = (SELECT oid FROM pg_class WHERE relname = $1); +``` + +**Frontend integration:** + +- Partitioned tables show with a special icon in sidebar +- Expanding shows child partitions with their bounds +- Context menu: "Create Partition", "Detach Partition" + +**Tests:** + +- `test_get_partitioned_tables` — identifies partition parents +- `test_get_partitions` — lists children with bounds +- `test_partition_range_bounds` — range partition display +- `test_partition_list_bounds` — list partition display + +--- + +### 2.5: Row-Level Security Policies + +**What:** Display RLS policies on tables. Show which roles they apply to, the +USING and WITH CHECK expressions. + +**Implementation:** + +```sql +SELECT polname, polcmd, polroles, pg_get_expr(polqual, polrelid) as using_expr, + pg_get_expr(polwithcheck, polrelid) as check_expr +FROM pg_policy WHERE polrelid = $1::regclass; +``` + +**Frontend integration:** + +- In the table detail panel, show a "Security Policies" section +- Each policy shows: name, command (SELECT/INSERT/UPDATE/DELETE/ALL), roles, expressions + +**Tests:** + +- `test_get_policies` — lists policies on a table with RLS enabled +- `test_policy_per_command` — distinguishes SELECT vs UPDATE policies + +--- + +### 2.6: Publication/Subscription Visibility + +**What:** Show logical replication publications and subscriptions for monitoring. + +**Implementation:** + +```sql +-- Publications +SELECT pubname, puballtables, pubinsert, pubupdate, pubdelete +FROM pg_publication; + +-- Subscription status +SELECT subname, subenabled, subslotname, subpublications +FROM pg_subscription; +``` + +**Frontend:** New sidebar section "Replication" with Publications and Subscriptions. + +--- + +### 2.7: Advisory Lock Monitoring + +**What:** Show currently held advisory locks for debugging lock contention. + +```sql +SELECT locktype, objid, mode, granted, pid, + (SELECT usename FROM pg_stat_activity WHERE pid = l.pid) as held_by +FROM pg_locks l WHERE locktype = 'advisory'; +``` + +--- + +## Implementation Order + +Prioritized by user impact and implementation complexity: + +```text +Sprint 1: Sequence management (high demand, straightforward) +Sprint 2: JSONB inline editing (high demand, more complex — UI extension) +Sprint 3: Extension type system (high demand for PostGIS/pgvector users) +Sprint 4: Partition introspection (medium demand) +Sprint 5: RLS policies (medium demand, straightforward) +Sprint 6: Pub/Sub + Advisory locks (lower priority, quick wins) +``` + +--- + +## Checkpoint: CP-5 (Phase 2 Complete — Stable Release Gate) + +**When:** Core Phase 2 features complete (at minimum: sequences + JSONB + extensions). + +**This IS a major release gate.** The plugin now exceeds the built-in driver. + +**Verify:** + +- [ ] All Phase 1 parity tests still GREEN (no regressions) +- [ ] New Phase 2 features have dedicated tests (all GREEN) +- [ ] Sequence management works end-to-end +- [ ] JSONB editing works with nested objects +- [ ] At least one extension type (PostGIS or pgvector) is handled +- [ ] Plugin published as **stable** (not beta) to Tabularium registry + +**Communicate to team:** + +- Plugin is now the recommended PostgreSQL driver for power users +- Built-in driver still works but is feature-frozen +- Begin planning Phase 3 (deprecation decision) + +--- + +## Ship / Release Points + +| After | What to ship | Channel | +| ----- | ------------ | ------- | +| Sequences done | Plugin update (minor version bump) | Beta → early adopters | +| JSONB editing done | Plugin update | Beta | +| All Phase 2 core done | Plugin promoted to **stable** | Public registry | +| Extensions done | Plugin update | Stable | + +The plugin architecture enables shipping each feature independently without +waiting for a Tabularis core release. This is a key advantage. + +--- + +## Security Considerations + +| Feature | Security Concern | Mitigation | +| ------- | ---------------- | ---------- | +| Sequence reset | Could disrupt application logic (PK collisions) | Confirmation dialog before reset | +| JSONB patch | Could corrupt data if path is wrong | Validate path exists before patching; show preview | +| RLS policies | Exposing USING expressions might reveal security rules | Only show to connection owner / superuser | +| Advisory locks | Revealing lock holders exposes active sessions | Same visibility as `pg_stat_activity` (requires `pg_monitor` role) | + +--- + +## Definition of Done + +- [ ] Sequences: list, inspect, reset, create, drop — all tested +- [ ] JSONB: structured editing, validation, patch operations — all tested +- [ ] Extensions: at least PostGIS + pgvector types detected and handled +- [ ] Partitions: hierarchy displayed, bounds shown +- [ ] All Phase 1 tests still GREEN (no regressions) +- [ ] Plugin published as stable release +- [ ] CP-5 sync completed with core team diff --git a/docs/planning/04-phase-3-deprecate-builtin.md b/docs/planning/04-phase-3-deprecate-builtin.md new file mode 100644 index 0000000..bc2b745 --- /dev/null +++ b/docs/planning/04-phase-3-deprecate-builtin.md @@ -0,0 +1,140 @@ +# Phase 3 — Deprecate Built-in Driver (Deferred Decision) + +**Goal:** Remove the built-in PostgreSQL driver from Tabularis core, making the +plugin the sole PostgreSQL driver. This is a strategic decision that requires +full team consensus and community readiness. + +**Status:** Deferred until Phases 1 and 2 are complete and proven in production. + +--- + +## When to Revisit This Decision + +This phase should be discussed when ALL of the following are true: + +- [ ] Plugin has been in stable release for at least 2 months +- [ ] No critical bug reports from plugin users +- [ ] Plugin test suite has 100% parity with built-in (Phase 1 proven) +- [ ] Plugin exceeds built-in in features (Phase 2 shipped) +- [ ] Community feedback is positive (no "I want the old driver back" sentiment) +- [ ] Performance benchmarks show no meaningful regression +- [ ] All supported platforms (macOS, Linux, Windows) confirmed working + +--- + +## Decision Points + +The team must agree on: + +### 1. Plugin ID Strategy + +| Option | Effort | User Impact | +| ------ | ------ | ----------- | +| Rename plugin to `"postgres"` (remove guard) | Low — one code change | Zero — saved connections work unchanged | +| Keep `"postgres-plugin"`, add migration UI | Medium — migration dialog + saved connection rewrite | Low — one-time dialog on update | +| Keep both (built-in frozen, plugin recommended) | Zero | Confusing — two PG drivers visible | + +### 2. Bundling Strategy + +| Option | Pros | Cons | +| ------ | ---- | ---- | +| Bundle plugin in app distribution | No install step, guaranteed availability | Larger app binary | +| Auto-install from registry on first launch | Smaller app, always latest version | Requires internet, extra startup time | +| Manual install (user must add via Settings) | Simplest for us | Worst UX, many users won't discover it | + +### 3. Built-in Driver Removal Scope + +What gets removed from `src-tauri/`: + +- `drivers/postgres/` (2420+ lines — mod.rs, binding.rs, client.rs, explain.rs, helpers.rs, types.rs, extract/) +- PostgreSQL pool creation in `pool_manager.rs` +- `"postgres"` entry in `BUILTIN_DRIVER_IDS` +- PostgreSQL-specific code in `commands.rs` (SSH expansion for PG, postgres_dbname helper) + +What stays: + +- The `DatabaseDriver` trait (used by all drivers) +- RPC infrastructure (used by all plugins) +- Frontend driver capability handling (generic, works with any driver) + +### 4. Rollback Plan + +If something goes wrong after removing the built-in driver: + +- **Short-term:** Users can install the last Tabularis version with built-in PG +- **Medium-term:** We can re-add the built-in driver in a patch release (code is in git history) +- **Plugin-side:** If the plugin has a bug, push a new plugin version (no app update needed) + +--- + +## Implementation Steps (When Decided) + +1. Remove `"postgres"` from `BUILTIN_DRIVER_IDS` array +2. If renaming plugin: update `.tabularium` manifest `id` to `"postgres"` +3. Remove `src-tauri/src/drivers/postgres/` directory +4. Remove PG pool logic from `pool_manager.rs` +5. Remove `postgres_dbname()` helper from `commands.rs` +6. Add migration logic: on first launch after update, if no `"postgres"` driver is + registered, auto-install the plugin (or prompt user) +7. Update frontend: remove PG-specific fallback capabilities in `useDrivers.ts` +8. Update documentation: migration guide for users +9. Update CHANGELOG: announce the change prominently +10. Test: full regression suite against plugin-only configuration + +--- + +## Checkpoint: CP-6 + +**When:** Team decides to proceed with deprecation. + +**Stakeholders:** Full team consensus required — not a solo decision. + +**Criteria for proceeding:** + +- [ ] All items in "When to Revisit" section are satisfied +- [ ] Team has unanimously agreed on plugin ID strategy +- [ ] Team has agreed on bundling strategy +- [ ] Rollback plan is documented and tested +- [ ] Migration path verified with test accounts (saved connections survive) +- [ ] Community announcement drafted + +--- + +## Security Consideration + +Removing the built-in driver means all PostgreSQL connections flow through the +plugin process (a separate child process communicating via stdio). This changes +the security boundary: + +| Concern | Built-in | Plugin | +| ------- | -------- | ------ | +| Credential handling | In-process, same memory space | Sent via JSON over stdio (local pipes) | +| Connection lifetime | Managed by Tabularis process | Managed by plugin process (kill_on_drop) | +| Crash isolation | PG driver crash = Tabularis crash | PG driver crash = error message (Tabularis survives) | +| Code audit surface | Part of main codebase | Separate binary (must be audited separately) | + +The security posture is **slightly better** with the plugin (crash isolation) +but introduces a **new trust boundary** (the plugin binary must be verified +as legitimate at install time — already handled by registry SHA-256 verification). + +--- + +## Timeline Estimate + +This phase is purely a coordination and removal exercise. Technical effort is +minimal (< 1 week). The real timeline is governed by: + +- Community confidence building (2+ months of stable plugin usage) +- Team scheduling for the migration release +- Documentation and announcement preparation + +--- + +## Definition of Done + +- [ ] Built-in PG driver code removed from Tabularis core +- [ ] Plugin is the sole PG driver, working identically +- [ ] Existing saved connections work without user action (or clear migration dialog) +- [ ] No user-facing regressions reported within 2 weeks of release +- [ ] CHANGELOG + migration guide published +- [ ] CP-6 sync completed with full team diff --git a/docs/planning/README.md b/docs/planning/README.md new file mode 100644 index 0000000..6642a1e --- /dev/null +++ b/docs/planning/README.md @@ -0,0 +1,35 @@ +# PostgreSQL Plugin Migration — Phase Docs Index + +**Master Plan:** [postgres-plugin-migration.md](./postgres-plugin-migration.md) + +## Phase Documents + +| Phase | Document | Status | +| ----- | -------- | ------ | +| Prerequisites | [00-prerequisites.md](./00-prerequisites.md) | ✅ Complete (PR #576) | +| Phase 0 | [01-phase-0-baseline-tests.md](./01-phase-0-baseline-tests.md) | ✅ Complete | +| Phase 1 | [02-phase-1-plugin-build.md](./02-phase-1-plugin-build.md) | 🟡 In Progress | +| Phase 2 | [03-phase-2-issue-16.md](./03-phase-2-issue-16.md) | Planning | +| Phase 3 | [04-phase-3-deprecate-builtin.md](./04-phase-3-deprecate-builtin.md) | Planning | + +## Test Architecture + +| Layer | Count | Purpose | +| ----- | ----- | ------- | +| Parity tests | 80 | Byte-perfect comparison: plugin output == builtin output | +| Baseline tests | 72 | Safety net: builtin behavior hasn't regressed | +| Golden tests | 26 | Snapshot drift detection | + +See [02-phase-1-plugin-build.md](./02-phase-1-plugin-build.md) for why 80 +parity tests (not 102) is the correct number for the CP-4 gate. + +## Checkpoints & Release Gates + +| Checkpoint | When | Stakeholders | Ship? | +| ---------- | ---- | ------------ | ----- | +| CP-1 | After Prerequisites merged | Core team review | ✅ Done | +| CP-2 | After Phase 0 complete | Core team + QA | ✅ Done (@aesslinger proceeded) | +| CP-3 | Phase 1 metadata parity (13/80) | Core team sync | ✅ Done (@aesslinger proceeded) | +| CP-4 | Phase 1 at 80/80 parity tests green | Core team + QA | **Yes — beta release** | +| CP-5 | After Phase 2 features complete | Core team + community | **Yes — stable release** | +| CP-6 | Phase 3 decision | Full team consensus | Depends on decision | diff --git a/docs/planning/postgres-improvements.md b/docs/planning/postgres-improvements.md new file mode 100644 index 0000000..bda2dd6 --- /dev/null +++ b/docs/planning/postgres-improvements.md @@ -0,0 +1,1238 @@ +# PostgreSQL Driver Improvements — Feature Gap Audit & Implementation Plan + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [#15 — Schema handling fix (closed)](https://github.com/TabularisDB/tabularis/issues/15), +[PR #342 — Materialized views (merged)](https://github.com/TabularisDB/tabularis/pull/342), +[PR #402 — Multi-database connections (open, in progress)](https://github.com/TabularisDB/tabularis/pull/402) + +## Executive Summary + +A comprehensive audit of the PostgreSQL driver (`src-tauri/src/drivers/postgres/`) +reveals a **highly mature implementation** — the most complete of the three built-in +drivers. It implements 100% of the `DatabaseDriver` trait, supports 70+ data types +across 14 categories, and handles PostgreSQL-specific complexities (enum CASTs, +composite types, range/multi-range extraction, overloaded routine management, and +schema-qualified identifiers). + +However, several PostgreSQL capabilities that are standard in professional database +tools remain unimplemented. Issue 16 explicitly calls out **sequences**, **JSONB +editing**, and **schema handling** — the last of which is resolved. This document +identifies 3 active bugs, 6 feature gaps, and 5 polish items, organized into a +prioritized implementation plan. + +--- + +## Table of Contents + +1. [Dependency: PR 402 — Multi-Database Connections](#dependency-pr-402--multi-database-connections) +2. [Audit Methodology](#audit-methodology) +3. [Current State](#current-state) +4. [Findings: Active Bugs](#findings-active-bugs) +5. [Findings: Feature Gaps](#findings-feature-gaps) +6. [Findings: Polish & Enhancements](#findings-polish--enhancements) +7. [Findings: Out of Scope](#findings-out-of-scope) +8. [Feature Comparison Matrix](#feature-comparison-matrix) +9. [Implementation Plan](#implementation-plan) +10. [Testing Strategy](#testing-strategy) +11. [Open Questions](#open-questions) + +--- + +## Dependency: PR 402 — Multi-Database Connections + +[PR #402](https://github.com/TabularisDB/tabularis/pull/402) is an in-flight PR +by debba that adds multi-database browsing to PostgreSQL connections. It is the +foundational architecture change that all work in this plan should build on top of. + +### What PR 402 Delivers + +PR 402 allows a single PostgreSQL connection to browse multiple databases — each +with its own schemas — from the sidebar. Key architectural changes: + +- **Per-database connection pools** — Pool key becomes `driver:conn:{id}:{db}`, + with separate pools for each selected database +- **`database: Option` routing** — Every Tauri command now accepts an + optional `database` parameter; when set, the backend overrides `params.database` + to route to the correct pool +- **Editor tabs carry `database`** — Each tab stores its target database alongside + schema, so DML routes to the correct pool regardless of sidebar state +- **`buildTableRoutingParams` helper** — Frontend utility that builds the + `{ schema, database }` pair from a tab's context for any backend call +- **`isSchemaBasedMultiDb` helper** — Distinguishes hierarchical PG layout + (`database → schema → table`) from flat MySQL layout (`database → table`) +- **`SchemaData` nesting** — `databaseDataMap` entries now optionally contain + `schemas: string[]` and `schemaDataMap: Record` for + the hierarchical PG model + +### PR 402 Status + +| Aspect | State | +|--------|-------| +| Branch state | Open, has merge conflicts with `main` | +| Last activity | 2026-07-01 (14 commits, 60+ files changed) | +| Tests | 2730 frontend + 766 Rust passing at last push | +| Verification checklist | 4/69 items checked | +| Formal reviews | None submitted yet | + +### PR 402 Remaining Known Limitations + +These are gaps that PR 402 explicitly declares as out of scope. Some overlap +with our plan and some are purely routing issues that need follow-up work: + +#### Routing Gaps (Still Need Fixing After 402 Merges) + +| Gap | Description | Overlap with Our Plan | +|-----|-------------|----------------------| +| **Object-creation DDL not database-aware** | Create Table / View / Trigger / Index / FK from a nested schema node routes to the primary database, not the node's database | Affects our Enhancement 4 (schema/DB management) — any new DDL commands must be database-aware | +| **AI Query Generation not database-aware** | `AiQueryModal` schema context uses primary database only | Not in our scope | +| **Clipboard Import not database-aware** | Import creates table on primary database regardless of context | Not in our scope | +| **SQL autocomplete not database-aware** | `get_columns` for autocomplete runs against primary pool | Not in our scope but good to note | +| **No pool cap or idle eviction** | Each selected database keeps max 10 connections indefinitely | Operational concern for our work (large schemas with many DBs) | + +#### Features Explicitly Out of Scope in 402 + +PR 402's own checklist confirms these are NOT implemented and left for follow-up +(directly aligning with our plan): + +| Feature | Our Plan Item | +|---------|---------------| +| Sequences (first-class management) | **Gap 1** — our primary deliverable | +| Custom types / Enums / Domains | **Enhancement 3** | +| Extensions (PostGIS, hstore, …) | **Gap 5** | +| Check / Unique constraints (dedicated listing) | Not in our plan (low priority) | +| CREATE/DROP DATABASE, CREATE/DROP SCHEMA | **Enhancement 4** | +| TRUNCATE / RENAME table | Not in our plan | +| Query cancellation via `pg_cancel_backend` | Not in our plan | +| Materialized views | Already delivered in PR 342 | + +### Impact on Our Implementation + +#### What 402 Fixes That We Originally Identified + +**Bug 516 (Wrong Schema in DML)** — PR 402 directly addresses this class of bug. +The fix is that editor tabs now carry `database` alongside `schema`, and the +`buildTableRoutingParams` helper ensures DML operations route to the tab's stored +context rather than the sidebar's globally-selected schema. The specific commit +"fix: route results-grid operations to the tab's database pool" (86640146) fixed +the exact pattern: Ctrl+S commit was sending the schema name as a database name +on schema-based drivers, routing to the wrong pool. + +**Verdict:** Bug 516 should be **verified after PR 402 merges** rather than fixed +independently. If it persists, it would be a residual routing bug in 402's model +(unlikely given the thorough fix commits). + +#### What Our Plan Must Do Differently + +1. **All new Tauri commands must include `database: Option`** and apply + the standard routing pattern: + + ```rust + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } + ``` + +2. **All new frontend invocations must pass `database`** from the tab or sidebar + context using `buildTableRoutingParams` or equivalent. + +3. **New sidebar groups (Sequences, Extensions, Types) must propagate `database`** + down to their child items the same way `SidebarSchemaItem` propagates it to + tables, views, routines, and triggers. + +4. **Rebase on 402 before starting implementation** — our branch should be based + on the post-402 state of `main` to avoid conflict in `commands.rs` (182 + additions), `DatabaseContext.ts`, `DatabaseProvider.tsx`, and `Editor.tsx`. + +--- + +## Audit Methodology + +The audit compared the PostgreSQL driver against: + +- The full `DatabaseDriver` trait definition in `src-tauri/src/drivers/driver_trait.rs` +- The MySQL driver (`src-tauri/src/drivers/mysql/mod.rs`) for feature parity baseline +- PostgreSQL's own catalog (`pg_catalog`) and `information_schema` capabilities +- Open GitHub issues tagged with PostgreSQL-related keywords +- Professional database tool standards (pgAdmin, DBeaver, DataGrip) + +**Source files reviewed:** + +| File | Lines | Purpose | +|------|-------|---------| +| `src-tauri/src/drivers/postgres/mod.rs` | ~2500 | Main driver implementation | +| `src-tauri/src/drivers/postgres/binding.rs` | — | Value binding for parameterized queries | +| `src-tauri/src/drivers/postgres/client.rs` | — | Pool client wrappers | +| `src-tauri/src/drivers/postgres/explain.rs` | — | EXPLAIN plan parsing | +| `src-tauri/src/drivers/postgres/export.rs` | — | Streaming query export | +| `src-tauri/src/drivers/postgres/extract/` | 7 files | Value extraction (simple, array, composite, enum, range, multi_range, advanced) | +| `src-tauri/src/drivers/postgres/helpers.rs` | — | Identifier escaping, enum type handling | +| `src-tauri/src/drivers/postgres/routines.rs` | — | Stored routine SQL builders | +| `src-tauri/src/drivers/postgres/types.rs` | 838 | Data type catalog (70+ types) | +| `src-tauri/src/drivers/postgres/tests.rs` | — | Unit tests | +| `src-tauri/src/drivers/driver_trait.rs` | ~600 | Trait definition and capabilities | +| `src-tauri/src/commands.rs` | — | Tauri command layer | +| `src/types/plugins.ts` | — | Frontend capability types | +| `src/contexts/DatabaseContext.ts` | — | Schema data model | + +--- + +## Current State + +### What Works Correctly + +The PostgreSQL driver fully implements: + +| Category | Features | +|----------|----------| +| **Connection** | Pool-based (`deadpool-postgres`), SSL/TLS, SSH tunneling, connection string import, configurable search_path | +| **Schema Inspection** | Multi-schema browsing, tables, columns (with enum values, max length), FKs (with update/delete rules), indexes | +| **Views** | List, create (CREATE VIEW), alter (CREATE OR REPLACE VIEW), drop, column introspection | +| **Materialized Views** | List, columns, indexes, definition, refresh, read-only grid enforcement | +| **Routines** | List functions/procedures, parameters, full definition (`pg_get_functiondef`), call/create/edit/drop (overload-safe via identity arguments) | +| **Triggers** | List (with event aggregation), definition (`pg_get_triggerdef`), create, drop | +| **CRUD** | Type-aware insert/update/delete with enum CAST, JSON/JSONB, BLOB, DEFAULT VALUES, composite PK binding | +| **DDL** | CREATE TABLE, ADD COLUMN, ALTER COLUMN (with USING clause for incompatible casts), CREATE INDEX, CREATE FK, schema-qualified DROP | +| **Query Execution** | Paginated SELECT (LIMIT+1 pattern), batch on single client (session-safe), cancellation via CancellationToken | +| **EXPLAIN** | FORMAT JSON with ANALYZE and BUFFERS options, parsed plan tree | +| **BLOB** | Save BYTEA to file, preview as data URL | +| **Type Extraction** | Enums, JSON/JSONB, arrays (nested), composites, ranges, multi-ranges, HSTORE (read), network types, geometric types, FTS types, system types | +| **Compatibility** | PG 9.x/10 support (prokind fallback for pre-11 servers) | + +### Declared Capabilities + +```rust +DriverCapabilities { + schemas: true, // Multi-schema support + single_database: false, // Multiple databases + views: true, // Full view lifecycle + materialized_views: true, // PG-exclusive + routines: true, // Function/procedure listing + routine_management: true, // Full routine CRUD + file_based: false, // Network driver + folder_based: false, + connection_string: true, // postgres://user:pass@host:port/db + identifier_quote: "\"", // Double-quote identifiers + alter_primary_key: true, // ALTER TABLE PK modification + serial_type: "SERIAL", // Type-replacement auto-increment + auto_increment_keyword: "", // No keyword (uses SERIAL types) + inline_pk: false, + alter_column: true, // ALTER COLUMN support + create_foreign_keys: true, // FK creation + manage_tables: true, // Full table DDL + explain: true, // EXPLAIN plan visualization + readonly: false, + triggers: true, // Trigger management + supports_ssl: true, // SSL/TLS configuration + sql_dialect: Postgres, // PG-specific statement splitting +} +``` + +--- + +## Findings: Active Bugs + +### Bug 1: Wrong Schema Name in DML Submission + +**Issue:** [#516](https://github.com/TabularisDB/tabularis/issues/516) + +**Status:** ⚠️ **Likely resolved by PR 402** — verify after merge + +**The Problem:** + +When a user has tables with the same name in different schemas (e.g., +`schemaA.app_settings` and `schemaB.app_settings`), editing a row in one schema +may generate DML that targets the wrong schema. The UPDATE/DELETE statement +references `schemaB.app_settings` when the user was editing `schemaA.app_settings`. + +**Why PR 402 Likely Fixes This:** + +PR 402 introduces per-tab `database` + `schema` routing. The specific commit +(86640146) fixed a regression where `isMultiDatabaseCapable` now including Postgres +caused the flat-driver fallback to send the PostgreSQL schema name as the database +parameter — routing every update/insert/delete to a pool for a database literally +named after the schema. The fix gates this with `!isSchemaBasedConn` and routes +via `buildTableRoutingParams` which uses `activeTab.database` and `activeTab.schema`. + +**Action Required:** + +After PR 402 merges, reproduce the bug (same table name in two schemas, edit in +schema A, verify DML targets schema A). If it persists, the fix is to ensure the +editor tab captures its schema at open time and never resolves dynamically from +the sidebar. + +**Complexity:** None (verify only) or Low (residual fix if needed) + +--- + +### Bug 2: Visual Query Builder Fails on Reserved-Word Table Names + +**Issue:** [#335](https://github.com/TabularisDB/tabularis/issues/335) + +**The Problem:** + +When a table is named with a PostgreSQL reserved word (e.g., `user`, `order`, +`group`), the Visual Query Builder generates unquoted identifiers: + +```sql +-- Generated (broken): +SELECT user.name FROM user + +-- Correct: +SELECT "user"."name" FROM "user" +``` + +**Root Cause:** + +The Visual Query Builder's SQL generation does not apply identifier quoting. The +`identifier_quote` capability is declared (`"\""`), but the Visual Query Builder +bypasses it. + +**Impact:** Visual Query Builder is unusable for any table with a reserved-word name. + +**Severity:** MEDIUM + +**Fix Direction:** + +The Visual Query Builder's SQL generation must quote all identifiers using the +driver's `identifier_quote` value. This applies to table names, column names, +schema names, and aliases. The safest approach is to always quote — this is valid +SQL regardless of whether the name is reserved. + +**Complexity:** Low-Medium (Visual Query Builder SQL generation) + +--- + +### Bug 3: Foreign Keys Not Visible with Restricted Privileges + +**Issue:** [#96](https://github.com/TabularisDB/tabularis/issues/96) + +**The Problem:** + +Users with read-only grants (`GRANT SELECT ON ALL TABLES`) cannot see foreign +keys. The `get_foreign_keys` function queries `pg_constraint` which requires +additional privileges beyond SELECT on the user tables. + +**Root Cause:** + +The FK query uses: + +```sql +FROM pg_constraint con +JOIN pg_class cls ON cls.oid = con.conrelid +JOIN pg_namespace ns ON ns.oid = cls.relnamespace +... +WHERE con.contype = 'f' +``` + +Access to `pg_constraint` requires `USAGE` on the schema AND visibility into +the constraint's owning table in `pg_class`. A strictly read-only user with only +`SELECT` grants may not have the necessary catalog visibility. + +**Impact:** FKs appear to not exist for users with limited permissions. + +**Severity:** LOW-MEDIUM + +**Fix Direction:** + +Provide a fallback query using `information_schema.referential_constraints` + +`information_schema.key_column_usage`, which respects standard SQL privilege +rules. Try the `pg_constraint` query first (it returns richer data including +update/delete rules), and fall back to the information_schema approach if the +primary query returns zero results or errors. + +```sql +-- Fallback query: +SELECT + tc.constraint_name, + kcu.column_name, + ccu.table_schema AS referenced_schema, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column, + rc.update_rule, + rc.delete_rule +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema +JOIN information_schema.referential_constraints rc + ON rc.constraint_name = tc.constraint_name + AND rc.constraint_schema = tc.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 +``` + +**Complexity:** Medium (fallback logic + testing with restricted users) + +--- + +## Findings: Feature Gaps + +### Gap 1: Sequences — Browse, Inspect, Manage + +**Priority:** HIGH — Explicitly named in issue 16 + +**What PostgreSQL Provides:** + +Sequences are first-class objects in PostgreSQL. They power `SERIAL`/`BIGSERIAL` +columns, `GENERATED AS IDENTITY`, and can be used standalone for custom ID +generation across tables. + +**Catalog Sources:** + +- `pg_sequences` view (PG 10+): name, schema, data_type, start, min, max, + increment, cycle, cache_size, last_value +- `information_schema.sequences` (less detailed) +- `pg_class WHERE relkind = 'S'` (pre-PG10 fallback) + +**Proposed Feature Set:** + +| Operation | SQL | UI Location | +|-----------|-----|-------------| +| List sequences | `SELECT * FROM pg_sequences WHERE schemaname = $1` | Sidebar → "Sequences" group per schema | +| View properties | `SELECT * FROM pg_sequences WHERE schemaname = $1 AND sequencename = $2` | Properties panel or context menu → "Show Details" | +| View current value | `SELECT last_value FROM schema.sequence_name` | Shown in properties | +| Alter (restart) | `ALTER SEQUENCE schema.seq RESTART WITH n` | Context menu → "Restart…" with value input | +| Alter (properties) | `ALTER SEQUENCE schema.seq INCREMENT BY n MINVALUE m MAXVALUE M CACHE c [NO] CYCLE` | Edit dialog | +| Create | `CREATE SEQUENCE schema.name [AS type] [START WITH n] [INCREMENT BY n] ...` | Context menu on "Sequences" group → "New Sequence" | +| Drop | `DROP SEQUENCE IF EXISTS schema.name [CASCADE]` | Context menu → "Drop Sequence" with confirmation | +| Show DDL | Reconstruct `CREATE SEQUENCE` from metadata | Context menu → "Show Definition" | + +**Implementation Requirements:** + +1. **Backend (Rust):** + - New model: `SequenceInfo { name, schema, data_type, start_value, min_value, max_value, increment_by, cycle, cache_size, last_value, owner_table, owner_column }` + - New functions in `postgres/mod.rs`: `get_sequences`, `get_sequence_details`, `create_sequence`, `alter_sequence`, `drop_sequence`, `restart_sequence`, `get_sequence_ddl` + - New trait methods with default impls (empty/error) to avoid breaking other drivers + - New capability flag: `sequences: bool` in `DriverCapabilities` + - New Tauri commands: `get_sequences`, `get_sequence_details`, `create_sequence`, `alter_sequence`, `drop_sequence`, `restart_sequence` + +2. **Frontend (TypeScript/React):** + - New type: `SequenceInfo` in `src/types/schema.ts` + - Extend `SchemaData` interface to include `sequences?: SequenceInfo[]` + - New sidebar group in `SidebarSchemaItem` (between "Tables" and "Views") + - New component: `SidebarSequenceItem` + - Context menu actions: Show Details, Restart, Drop + - Sequence creation dialog + - Gate on `capabilities.sequences` + +3. **Localization:** + - Add keys to all 8 locale files (en, de, es, fr, it, ja, ru, zh) + +**Owner relationship:** Also display which table/column owns a sequence (via +`pg_depend` joining `pg_class` to `pg_attrdef`). This helps users understand +the link between `users.id SERIAL` and `users_id_seq`. + +**Complexity:** High (new schema object type end-to-end) + +--- + +### Gap 2: HSTORE Write Support + +**Priority:** HIGH — Issue [#395](https://github.com/TabularisDB/tabularis/issues/395) is open + +**Current State:** + +- **Read:** Works correctly. `extract/simple.rs` line 71 deserializes HSTORE + to `HashMap>` → JSON object via serde. +- **Write:** Not implemented. `binding.rs` has no HSTORE path. Users cannot + insert or update HSTORE columns through the data grid. + +**What PostgreSQL Expects:** + +HSTORE values are written as text literals: `'"key1"=>"value1", "key2"=>"value2"'` + +Or via the `hstore()` function: `hstore(ARRAY['key1','key2'], ARRAY['val1','val2'])` + +**Implementation:** + +In `binding.rs`, add a match arm for HSTORE columns: + +```rust +// When the incoming value is a JSON object and the column type is hstore: +serde_json::Value::Object(map) => { + if is_hstore_column { + // Serialize to PostgreSQL hstore literal format + let hstore_literal = map.iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::Null => "NULL".to_string(), + serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), + other => format!("\"{}\"", other), + }; + format!("\"{}\"=>{}", k.replace('\\', "\\\\").replace('"', "\\\""), val) + }) + .collect::>() + .join(", "); + // Bind as TEXT, let PostgreSQL cast to hstore + separated.push_bind(hstore_literal); + } +} +``` + +**Alternatively:** Use a simple TEXT bind with explicit CAST: + +```sql +UPDATE table SET hstore_col = $1::hstore WHERE pk = $2 +``` + +This requires knowing the column is HSTORE at bind time — similar to the existing +enum CAST logic in `get_enum_column_types`. + +**Frontend Enhancement (optional but valuable):** + +A key/value editor UI for HSTORE cells (similar to the JSON tree editor but +simpler — flat key→value pairs only, both strings). Since HSTORE values are +already extracted as JSON objects, the existing `json-edit-react` component could +be reused with constraints (no nesting, values are always strings or null). + +**Complexity:** Medium (binding logic + type detection; optional UI enhancement) + +--- + +### Gap 3: Structured JSONB Editing + +**Priority:** MEDIUM — Explicitly named in issue 16 + +**Current State:** + +- **Read:** Perfect. JSON/JSONB values are extracted as structured `serde_json::Value` + and displayed in a tree editor (`json-edit-react` component in `JsonTreeView.tsx`). +- **Write (full replace):** Works. Users can edit the full JSON text and submit. +- **Write (path-based):** Not supported. Users cannot update a single key within + a JSONB document without replacing the entire value. + +**What PostgreSQL Provides:** + +```sql +-- Path-based update (PG 14+): +UPDATE t SET data = jsonb_set(data, '{address,city}', '"Berlin"') WHERE id = 1; + +-- Remove a key: +UPDATE t SET data = data - 'deprecated_key' WHERE id = 1; + +-- Deep merge (PG 16+): +UPDATE t SET data = data || '{"new_key": "value"}' WHERE id = 1; +``` + +**Proposed Enhancement:** + +This is primarily a **frontend** improvement. When a user edits a single key in +the JSON tree editor: + +1. Detect which path was modified (the tree editor already tracks this) +2. Instead of sending the entire document as a replacement, send a + path-based update command +3. Backend generates `jsonb_set()` for the specific path + +**Benefits:** + +- Avoids overwriting concurrent changes to other keys in the same document +- More efficient for large JSONB documents +- Matches what users expect from a professional database tool + +**Implementation:** + +1. **Frontend:** Extend the JSON tree editor to emit path-based change events + (e.g., `{ path: ["address", "city"], value: "Berlin", operation: "set" }`) +2. **Backend:** New helper that generates `jsonb_set` / `jsonb_delete_path` SQL + based on the operation type +3. **Fallback:** If the server is < PG 14 or the change is complex (multiple + paths, restructuring), fall back to full-document replacement + +**Complexity:** High (frontend tree editor changes + backend SQL generation + version detection) + +--- + +### Gap 4: Table/Database Size Information + +**Priority:** MEDIUM — Universally expected in database tools + +**What PostgreSQL Provides:** + +```sql +-- Database size: +SELECT pg_size_pretty(pg_database_size(current_database())); + +-- Table size (data only): +SELECT pg_size_pretty(pg_table_size('schema.table')); + +-- Table size (with indexes): +SELECT pg_size_pretty(pg_total_relation_size('schema.table')); + +-- Index size: +SELECT pg_size_pretty(pg_indexes_size('schema.table')); + +-- All tables in a schema with sizes: +SELECT + schemaname, + relname AS table_name, + pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size, + pg_size_pretty(pg_table_size(schemaname || '.' || relname)) AS data_size, + pg_size_pretty(pg_indexes_size(schemaname || '.' || relname)) AS index_size, + n_live_tup AS estimated_rows +FROM pg_stat_user_tables +WHERE schemaname = $1 +ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC; +``` + +**Proposed Feature Set:** + +| Location | Information Shown | +|----------|-------------------| +| Sidebar table item (tooltip or badge) | Total relation size (compact, e.g., "12 MB") | +| Table properties panel | Data size, index size, total size, estimated rows, toast size | +| Database item (tooltip) | Database total size | +| Status bar (when table is open) | Table size + row estimate | + +**Implementation:** + +1. **Backend:** New function `get_table_sizes(params, schema) -> Vec` + that batch-fetches sizes for all tables in a schema (single query). + Model: `TableSizeInfo { name, data_size, index_size, total_size, toast_size, estimated_rows }` +2. **Backend:** New function `get_database_size(params) -> DatabaseSizeInfo` +3. **Tauri commands:** `get_table_sizes`, `get_database_size` +4. **Frontend:** Display size info in sidebar tooltips and a new properties section +5. **Caching:** Size data should be fetched lazily and cached (refreshed on demand), + not on every schema load — `pg_total_relation_size` can be slow on schemas + with thousands of tables. + +**Complexity:** Medium (new queries + UI display, but no new object type management) + +--- + +### Gap 5: Extensions List + +**Priority:** MEDIUM — Helps users understand available types and features + +**What PostgreSQL Provides:** + +```sql +-- Installed extensions: +SELECT + e.extname AS name, + e.extversion AS version, + n.nspname AS schema, + c.description +FROM pg_extension e +JOIN pg_namespace n ON n.oid = e.extnamespace +LEFT JOIN pg_description c ON c.objoid = e.oid AND c.classoid = 'pg_extension'::regclass +ORDER BY e.extname; + +-- Available (not yet installed): +SELECT name, default_version, comment +FROM pg_available_extensions +WHERE installed_version IS NULL +ORDER BY name; +``` + +**Proposed Feature Set:** + +| Operation | SQL | UI Location | +|-----------|-----|-------------| +| List installed | `SELECT FROM pg_extension ...` | Sidebar → "Extensions" group (or separate section) | +| View details | Extension name, version, schema, description | Tooltip or details panel | +| Create (install) | `CREATE EXTENSION name [SCHEMA schema] [VERSION version]` | Context menu → "Install Extension" with picker | +| Drop | `DROP EXTENSION name [CASCADE]` | Context menu → "Drop Extension" with cascade warning | + +**Implementation:** + +1. **Backend:** `get_extensions(params) -> Vec`, + `create_extension(params, name, schema, version)`, + `drop_extension(params, name, cascade)` +2. **Model:** `ExtensionInfo { name, version, schema, description, is_relocatable }` +3. **New capability flag:** `extensions: bool` (only PG sets this to true) +4. **Frontend:** New sidebar section or group within the schema tree +5. **Localization:** Keys for all 8 locales + +**Note:** Extension management requires superuser privileges in most configurations. +The UI should gracefully handle permission errors and still allow listing +(which requires less privilege). + +**Complexity:** Medium (simpler than sequences — fewer operations, no complex state) + +--- + +### Gap 6: Table Partition Awareness + +**Priority:** MEDIUM — Issue [#338](https://github.com/TabularisDB/tabularis/issues/338) + +**Current State:** + +The `get_tables` query fetches from `information_schema.tables WHERE table_type = 'BASE TABLE'`. +This returns **all** tables including partitions, with no distinction between: + +- Regular tables (`relkind = 'r'`) +- Partitioned parent tables (`relkind = 'p'`) +- Child partition tables (`relispartition = true`) + +Large production schemas with many partitions show a flat list of hundreds of +tables, making navigation difficult. + +**What PostgreSQL Provides:** + +```sql +-- Identify partitioned tables and their children: +SELECT + c.relname AS table_name, + c.relkind, -- 'p' = partitioned, 'r' = regular + c.relispartition, -- true = is a child partition + pg_get_expr(c.relpartbound, c.oid) AS partition_bound, -- e.g., "FOR VALUES FROM (1) TO (100)" + parent.relname AS parent_table +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_inherits i ON i.inhrelid = c.oid +LEFT JOIN pg_class parent ON parent.oid = i.inhparent +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p') + AND c.relpersistence != 't' -- exclude temp tables +ORDER BY c.relname; +``` + +**Proposed UX:** + +```text +📁 Tables +├── 📋 users (regular table) +├── 📋 orders (regular table) +├── 📋 events [Partitioned] (relkind = 'p') +│ ├── 📋 events_2024_q1 (partition, collapsed by default) +│ ├── 📋 events_2024_q2 +│ ├── 📋 events_2024_q3 +│ └── 📋 events_2024_q4 +└── 📋 logs [Partitioned] + ├── 📋 logs_archive + └── 📋 logs_current +``` + +**Implementation:** + +1. **Backend:** Extend `get_tables` to return partition metadata: + - New fields on `TableInfo`: `is_partitioned: bool`, `is_partition: bool`, + `parent_table: Option`, `partition_bound: Option` + - Query `pg_class` directly instead of `information_schema.tables` (needed + for `relkind`, `relispartition`) +2. **Frontend:** `SidebarTableItem` nests partition children under their parent + when `is_partitioned: true`. Partitions are collapsed by default. +3. **Context menu on parent:** "Show Partition Info" → displays partition strategy + (RANGE, LIST, HASH) and all partition bounds. +4. **Optional:** Filter to hide partitions from the flat list entirely (user preference). + +**Compatibility:** The `relispartition` column exists from PG 10+. For PG 9.x (which +supports only inheritance-based partitioning), fall back to showing all tables flat. + +**Complexity:** High (modifies the core table-listing model, frontend tree restructuring) + +--- + +## Findings: Polish & Enhancements + +These are lower-priority improvements that enhance the professional feel of the +PostgreSQL driver without addressing critical functional gaps. + +### Enhancement 1: Column & Table Comments + +**What PostgreSQL Provides:** + +```sql +-- Table comment: +SELECT obj_description('"schema"."table"'::regclass, 'pg_class'); + +-- Column comments (batch): +SELECT + a.attname AS column_name, + col_description(c.oid, a.attnum) AS comment +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +WHERE n.nspname = $1 AND c.relname = $2 + AND a.attnum > 0 AND NOT a.attisdropped; +``` + +**Proposed:** Add `comment` field to `TableColumn` model. Display as tooltip in +the sidebar column list and in the column header of the data grid. + +**Complexity:** Low (add a field + join in existing get_columns query) + +--- + +### Enhancement 2: Table Statistics (pg_stat_user_tables) + +**What PostgreSQL Provides:** + +```sql +SELECT + n_live_tup AS estimated_rows, + n_dead_tup AS dead_rows, + last_vacuum, + last_autovacuum, + last_analyze, + last_autoanalyze, + seq_scan, + idx_scan +FROM pg_stat_user_tables +WHERE schemaname = $1 AND relname = $2; +``` + +**Proposed:** Display in a table properties panel (accessible via context menu). +Useful for identifying tables needing VACUUM or ANALYZE. + +**Complexity:** Low (read-only query + new UI panel) + +--- + +### Enhancement 3: Custom Type Browser (Enums, Domains, Composites) + +**What PostgreSQL Provides:** + +```sql +-- All user-defined types: +SELECT + t.typname AS name, + n.nspname AS schema, + CASE t.typtype + WHEN 'e' THEN 'enum' + WHEN 'd' THEN 'domain' + WHEN 'c' THEN 'composite' + WHEN 'r' THEN 'range' + END AS kind, + -- For enums: list values + -- For domains: base type + constraints + -- For composites: column definitions +FROM pg_type t +JOIN pg_namespace n ON n.oid = t.typnamespace +WHERE t.typtype IN ('e', 'd', 'c', 'r') + AND n.nspname = $1 +ORDER BY t.typname; +``` + +**Proposed:** A "Types" group in the sidebar (gated on a new `custom_types: bool` +capability). Allows browsing enum values, domain base types and constraints, +composite field definitions. + +**Future:** CREATE TYPE / ALTER TYPE / DROP TYPE operations. + +**Complexity:** Medium (new object type, multiple sub-kinds with different display needs) + +--- + +### Enhancement 4: Schema & Database Management + +**Current State:** Users can browse schemas and databases, but cannot create, rename, +or drop them from the UI. + +**Proposed Operations:** + +| Operation | SQL | +|-----------|-----| +| Create schema | `CREATE SCHEMA name [AUTHORIZATION role]` | +| Drop schema | `DROP SCHEMA name [CASCADE\|RESTRICT]` | +| Create database | `CREATE DATABASE name [OWNER role] [TEMPLATE tmpl] [ENCODING enc]` | +| Drop database | `DROP DATABASE name` (must not be connected to it) | + +**Note:** `CREATE DATABASE` cannot run inside a transaction and requires the +connection to target a different database (typically `postgres`). This is a +UX challenge — the user would need to be connected to `postgres` or another +database to create a new one. + +**Complexity:** Medium (backend is simple; UX for cross-database operations is tricky) + +--- + +### Enhancement 5: Row-Level Security (RLS) Policies + +**What PostgreSQL Provides:** + +```sql +SELECT + pol.polname AS policy_name, + CASE pol.polcmd + WHEN 'r' THEN 'SELECT' + WHEN 'a' THEN 'INSERT' + WHEN 'w' THEN 'UPDATE' + WHEN 'd' THEN 'DELETE' + WHEN '*' THEN 'ALL' + END AS command, + pg_get_expr(pol.polqual, pol.polrelid) AS using_expression, + pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check_expression, + ARRAY(SELECT rolname FROM pg_roles WHERE oid = ANY(pol.polroles)) AS roles +FROM pg_policy pol +JOIN pg_class cls ON cls.oid = pol.polrelid +JOIN pg_namespace ns ON ns.oid = cls.relnamespace +WHERE ns.nspname = $1 AND cls.relname = $2; +``` + +**Proposed:** Read-only display of RLS policies in table properties panel. +Increasingly important for modern architectures (Supabase, multi-tenant). + +**Complexity:** Low (read-only introspection, new panel section) + +--- + +## Findings: Out of Scope + +These PostgreSQL features are deliberately excluded from this plan as they serve +specialized admin/DBA workflows beyond what a database browser/editor targets: + +| Feature | Reason | +|---------|--------| +| **Active connections / `pg_stat_activity`** | Admin monitoring tool territory (pgAdmin, pg_top) | +| **VACUUM / ANALYZE / REINDEX** | Maintenance operations; could be added as simple actions later | +| **Publications / Subscriptions** | Logical replication admin — very specialized | +| **Foreign Data Wrappers** | Specialized federated query setup | +| **Event triggers** | Rare; standard triggers cover 99% of use cases | +| **Tablespaces** | Physical storage admin | +| **Roles / Grants management** | Full role admin is complex; read-only role display possible later | +| **pg_hba.conf / Server config** | Server-side config, not accessible via SQL connection | +| **Inheritance (non-partition)** | Legacy feature, rarely used in modern PG | + +--- + +## Feature Comparison Matrix + +| Feature | PostgreSQL (current) | PostgreSQL (proposed) | MySQL | Notes | +|---------|---------------------|----------------------|-------|-------| +| **Schema Objects** | | | | | +| Tables | ✅ | ✅ | ✅ | Parity | +| Views | ✅ | ✅ | ✅ | Parity | +| Materialized Views | ✅ | ✅ | N/A | PG-exclusive | +| Sequences | ❌ | ✅ | N/A | **Gap 1** | +| Routines | ✅ | ✅ | ✅ | Parity | +| Triggers | ✅ | ✅ | ✅ | Parity | +| Extensions | ❌ | ✅ | N/A | **Gap 5** | +| Custom Types | ❌ | ✅ | N/A | **Enhancement 3** | +| Partitions (nested display) | ❌ | ✅ | N/A | **Gap 6** | +| **Data Operations** | | | | | +| CRUD (basic types) | ✅ | ✅ | ✅ | Parity | +| HSTORE write | ❌ | ✅ | N/A | **Gap 2** | +| JSONB path-based edit | ❌ | ✅ | N/A | **Gap 3** | +| BLOB read/write | ✅ | ✅ | ✅ | Parity | +| **Metadata** | | | | | +| Table/DB sizes | ❌ | ✅ | ❌ | **Gap 4** | +| Column comments | ❌ | ✅ | ❌ | **Enhancement 1** | +| Table statistics | ❌ | ✅ | ❌ | **Enhancement 2** | +| RLS Policies | ❌ | ✅ | N/A | **Enhancement 5** | +| **Bug Fixes** | | | | | +| Schema in DML | ⚠️ Bug 516 | ✅ | N/A | **Bug 1** | +| Reserved-word quoting | ⚠️ Bug 335 | ✅ | N/A | **Bug 2** | +| FK with restricted user | ⚠️ Bug 96 | ✅ | N/A | **Bug 3** | + +--- + +## Implementation Plan + +### Tier 1: Bug Fixes (Critical Path) + +These should be addressed first as they affect basic usability. + +| Item | Severity | Complexity | Dependencies | +|------|----------|------------|--------------| +| Bug 516 — Schema context in DML | HIGH | Verify only | PR 402 must merge first | +| Bug 335 — Identifier quoting in VQB | MEDIUM | Low-Medium | None | +| Bug 96 — FK fallback for restricted users | LOW-MEDIUM | Medium | None | + +**Estimated effort:** 1-2 days (Bug 516 is verification; 335 and 96 are the real work) + +--- + +### Tier 2: Core Feature Gaps (Issue 16 Deliverables) + +These directly address the items called out in the issue. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Gap 1 — Sequences | HIGH | High | PR 402 merged (database routing pattern) | +| Gap 2 — HSTORE write | HIGH | Medium | Column type detection in binding | +| Gap 3 — JSONB path editing | MEDIUM | High | Frontend tree editor changes | +| Gap 4 — Table/DB sizes | MEDIUM | Medium | PR 402 merged (database routing pattern) | + +**Estimated effort:** 1-2 weeks + +**Implementation order:** HSTORE write (smaller, unblocks issue 395, no 402 +dependency) → Sequences (largest new feature, requires 402 routing pattern) → +Table sizes (independent) → JSONB path editing (highest complexity, can follow +later) + +**Critical constraint:** All new Tauri commands for Gaps 1 and 4 must follow the +`database: Option` routing pattern established by PR 402. See the +[Dependency section](#dependency-pr-402--multi-database-connections) for the +exact code pattern. + +--- + +### Tier 3: Schema Object Discovery + +New browsable object types in the sidebar. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Gap 5 — Extensions | MEDIUM | Medium | PR 402 merged (sidebar propagation) | +| Gap 6 — Partition awareness | MEDIUM | High | PR 402 merged (modifies core TableInfo) | +| Enhancement 3 — Custom Types | LOW-MEDIUM | Medium | PR 402 merged (sidebar propagation) | + +**Estimated effort:** 1-2 weeks + +**Implementation order:** Extensions (simpler, high visibility) → Partitions +(high value for production users but complex) → Custom Types + +**Critical constraint:** New sidebar groups must propagate `database` to their +child items following the same pattern as `SidebarSchemaItem` → `SidebarTableItem`. +PR 402 established this propagation chain; our new groups (Sequences, Extensions, +Types) must participate in it. + +--- + +### Tier 4: Metadata & Polish + +Read-only informational additions that enhance the professional feel. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Enhancement 1 — Column/table comments | LOW-MEDIUM | Low | None | +| Enhancement 2 — Table statistics | LOW | Low | None | +| Enhancement 4 — Schema/DB management | LOW-MEDIUM | Medium | Cross-DB UX design | +| Enhancement 5 — RLS Policies | LOW | Low | None | + +**Estimated effort:** 3-5 days + +--- + +## Testing Strategy + +### Unit Tests (Rust) + +```text +tests/drivers/postgres/ +├── sequences.test.rs +│ ├── get_sequences returns all sequences in schema +│ ├── get_sequence_details returns full metadata +│ ├── create_sequence generates valid DDL +│ ├── alter_sequence modifies properties correctly +│ ├── drop_sequence removes without error +│ ├── restart_sequence resets last_value +│ ├── owned-by relationship resolved correctly +│ └── schema-qualified names handled (non-public schema) +├── hstore_binding.test.rs +│ ├── insert_record with HSTORE JSON object succeeds +│ ├── update_record with HSTORE JSON object succeeds +│ ├── empty HSTORE (empty object) handled +│ ├── HSTORE with NULL values preserved +│ ├── HSTORE with special characters in keys/values +│ ├── HSTORE with unicode content +│ └── round-trip: insert HSTORE → select → compare +├── foreign_key_fallback.test.rs +│ ├── FK query succeeds for superuser (primary path) +│ ├── FK query fallback fires for restricted user +│ ├── Fallback returns same structure as primary +│ ├── FKs across schemas resolved correctly +│ └── Self-referencing FKs handled in both paths +├── extensions.test.rs +│ ├── get_extensions lists installed extensions +│ ├── Extension details include schema and version +│ └── Extensions from non-default schemas included +├── partitions.test.rs +│ ├── Partitioned table identified (relkind = 'p') +│ ├── Child partitions linked to parent +│ ├── Partition bound expression included +│ ├── Regular tables unaffected +│ └── Mixed schemas handled correctly +└── sizes.test.rs + ├── get_table_sizes returns data for all tables + ├── Sizes are human-readable + ├── Empty tables report 0 or minimal size + └── Schema-qualified tables handled +``` + +### Frontend Tests + +```text +tests/components/layout/sidebar/ +├── SidebarSequenceItem.test.tsx +│ ├── Renders sequence with correct icon +│ ├── Context menu shows Restart / Drop options +│ ├── Sequence group hidden when capability is false +│ └── Sequence group shows count badge +├── SidebarSchemaItem.test.tsx (extend) +│ ├── Partitioned tables render with nested partitions +│ ├── Partitions collapsed by default +│ ├── Extensions group rendered when capability is true +│ └── Sequences group rendered when capability is true +└── Editor.test.tsx (extend) + ├── Schema context retained per tab (not global) + └── DML uses tab's original schema, not sidebar selection +``` + +### Integration Tests + +- Connect with restricted-privilege user → verify FKs visible via fallback +- Create sequence → restart → verify new value → drop +- Insert HSTORE via grid → SELECT → verify round-trip +- Open table in schemaA → switch sidebar to schemaB → submit edit → verify targets schemaA +- Visual Query Builder with reserved-word table → verify quoted SQL generated +- Schema with 50+ partition tables → verify parent/child nesting in sidebar +- Install extension → verify it appears in list → drop + +--- + +## Open Questions + +1. **Sequence sidebar placement** — Should sequences be a peer group to "Tables" + and "Views" within each schema? Or a separate top-level section? Peer group + (inside the schema accordion) seems consistent with how other drivers organize + schema objects. + +2. **Partition nesting default** — Should partitions be hidden by default (with a + toggle to show), or shown nested under their parent (collapsed)? The latter + matches DBeaver's behavior and is less surprising. + +3. **JSONB path editing scope** — Should path-based editing be limited to + `jsonb_set` on leaf values, or also support structural operations (add key, + remove key, move key)? The tree editor already supports these operations + visually — the question is whether to wire them to path-based SQL or always + fall back to full-document replacement for structural changes. + +4. **Table sizes: eager vs. lazy** — Should table sizes be fetched alongside + `get_tables` (adds latency to schema load) or on-demand (e.g., when user + hovers or expands a table)? For schemas with thousands of tables, + `pg_total_relation_size` across all tables can be slow. Recommended: lazy + fetch with caching. + +5. **Extension management privileges** — `CREATE EXTENSION` typically requires + superuser. Should the UI hide the "Install Extension" action entirely for + non-superusers, or show it and let the error propagate? Recommended: always + show; handle the permission error with a clear message ("requires superuser + privileges"). + +6. **HSTORE column detection** — Should we detect HSTORE columns proactively + (like we do for enums in `get_enum_column_types`) to apply proper binding, + or use a reactive approach (detect from the error when a plain TEXT bind fails)? + Recommended: proactive detection via `pg_type` — consistent with the enum pattern. + +7. **Backward compatibility for `TableInfo`** — Adding `is_partitioned`, + `is_partition`, `parent_table` fields to `TableInfo` affects all drivers. + Should these be `Option` fields with `serde(default)` to avoid breaking + the MySQL/SQLite drivers, or should each driver explicitly return + `false`/`None`? + +8. **PR 402 merge timing** — Our Tier 2 and 3 work depends on PR 402's routing + pattern being in `main`. If 402 stalls (it has merge conflicts and no formal + reviews yet), should we proceed with HSTORE write support (which has no 402 + dependency) and defer sequence/extensions work? Or should we resolve 402's + conflicts and help get it merged first? + +9. **PR 402's DDL routing gap** — PR 402 explicitly leaves object-creation DDL + (Create Table/View/Trigger/Index/FK) as not database-aware on nested schema + nodes. Should we fix this as part of our Enhancement 4 (Schema/DB management) + work, or is it a separate follow-up PR that should go in between 402 and our + work? + +--- + +## Appendix: SQL Reference for Implementation + +### Sequence Introspection (PG 10+) + +```sql +SELECT + s.sequencename AS name, + s.schemaname AS schema, + s.data_type, + s.start_value, + s.min_value, + s.max_value, + s.increment_by, + s.cycle, + s.cache_size, + s.last_value, + -- Owner info (which table.column owns this sequence): + d.refobjid::regclass AS owner_table, + a.attname AS owner_column +FROM pg_sequences s +LEFT JOIN pg_depend d + ON d.objid = (s.schemaname || '.' || s.sequencename)::regclass + AND d.deptype = 'a' + AND d.classid = 'pg_class'::regclass +LEFT JOIN pg_attribute a + ON a.attrelid = d.refobjid + AND a.attnum = d.refobjsubid +WHERE s.schemaname = $1 +ORDER BY s.sequencename; +``` + +### Partition Hierarchy + +```sql +SELECT + c.relname AS table_name, + c.relkind, + c.relispartition, + CASE + WHEN pt.partstrat = 'r' THEN 'RANGE' + WHEN pt.partstrat = 'l' THEN 'LIST' + WHEN pt.partstrat = 'h' THEN 'HASH' + END AS partition_strategy, + pg_get_expr(c.relpartbound, c.oid) AS partition_bound, + parent.relname AS parent_table +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_inherits i ON i.inhrelid = c.oid +LEFT JOIN pg_class parent ON parent.oid = i.inhparent +LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p') + AND c.relpersistence != 't' +ORDER BY + COALESCE(parent.relname, c.relname), -- Group children with parent + c.relispartition, -- Parent first + c.relname; +``` + +### Extension Details + +```sql +SELECT + e.extname AS name, + e.extversion AS version, + n.nspname AS schema, + e.extrelocatable AS is_relocatable, + c.description +FROM pg_extension e +JOIN pg_namespace n ON n.oid = e.extnamespace +LEFT JOIN pg_description c + ON c.objoid = e.oid + AND c.classoid = 'pg_extension'::regclass +ORDER BY e.extname; +``` + +### HSTORE Binding Format + +```text +-- PostgreSQL HSTORE text representation: +'"key1"=>"value1", "key2"=>"value2", "null_key"=>NULL' + +-- Escaping rules: +-- - Keys and values are double-quoted +-- - Backslash and double-quote within values are backslash-escaped +-- - NULL (unquoted) represents a null value +-- - Empty HSTORE is an empty string: '' +``` + +### Column Comments (batch) + +```sql +SELECT + a.attname AS column_name, + col_description(c.oid, a.attnum) AS comment +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +WHERE n.nspname = $1 + AND c.relname = $2 + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY a.attnum; +``` diff --git a/docs/planning/postgres-plugin-migration-original.md b/docs/planning/postgres-plugin-migration-original.md new file mode 100644 index 0000000..76fb7ed --- /dev/null +++ b/docs/planning/postgres-plugin-migration-original.md @@ -0,0 +1,925 @@ +# PostgreSQL Plugin Migration — Phased Implementation Plan + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [PR #402 — Multi-database connections](https://github.com/TabularisDB/tabularis/pull/402) +**Direction:** Per debba — all drivers should eventually become plugins; built-in +drivers will be removed over time. + +## Executive Summary + +This plan migrates the built-in PostgreSQL driver to a standalone plugin driver, +achieving full feature parity before adding the multi-database capabilities from +PR #402 and the improvements from issue #16. The approach is incremental — each +phase delivers working software that can be tested and shipped independently. + +--- + +## Table of Contents + +1. [Architecture Context](#architecture-context) +2. [Critical Constraint: The BUILTIN_DRIVER_IDS Guard](#critical-constraint) +3. [Migration Strategy](#migration-strategy) +4. [RPC Adapter Blockers and Gotchas](#rpc-adapter-blockers-and-gotchas) +5. [Phase 0: Baseline Test Suite](#phase-0-baseline-test-suite-before-any-migration) +6. [Phase 1: Plugin Scaffold with Feature Parity](#phase-1-plugin-scaffold-with-feature-parity) +7. [Phase 2: Multi-Database Support (PR 402)](#phase-2-multi-database-support-pr-402) +8. [Phase 3: Issue 16 Improvements](#phase-3-issue-16-improvements) +9. [Phase 4: Deprecate Built-in Driver](#phase-4-deprecate-built-in-driver-deferred-decision) +10. [Plugin Architecture Reference](#plugin-architecture-reference) +11. [PR 402 Architecture Summary](#pr-402-architecture-summary) +12. [Dependency Sequencing](#dependency-sequencing) +13. [Developer Workflow](#developer-workflow) +14. [Risk Assessment](#risk-assessment) +15. [Open Questions](#open-questions) + +--- + +## Architecture Context + +### How Plugin Drivers Work + +Tabularis plugin drivers are **standalone executables** that communicate with the +host via **JSON-RPC 2.0 over stdin/stdout**. Each plugin: + +- Declares capabilities in a `.tabularium` manifest file +- Is spawned as a child process at startup (or on enable) +- Receives method calls as JSON-RPC requests on stdin +- Returns results as JSON-RPC responses on stdout +- Manages its own connection pooling internally +- Is killed on disable/uninstall (`kill_on_drop: true`) + +### Current Built-in PostgreSQL Driver + +- Location: `src-tauri/src/drivers/postgres/mod.rs` (2420 lines) +- Uses `sqlx` with `deadpool-postgres` for connection pooling +- 6 extraction submodules (simple, array, range, multi_range, composite, enum, advanced) +- Full typed binding system (473 lines in `binding.rs`) +- Routine management (overloaded function resolution) +- Schema-qualified identifier handling throughout +- 97+ declared data types across 14 categories + +--- + +## Critical Constraint + +### The `BUILTIN_DRIVER_IDS` Guard + +In `src-tauri/src/plugins/manager.rs` lines 164-169: + +```rust +const BUILTIN_DRIVER_IDS: [&str; 3] = ["mysql", "postgres", "sqlite"]; +if BUILTIN_DRIVER_IDS.contains(&&plugin_id.as_str()) { + return Err(format!( + "Plugin id '{}' collides with a built-in driver and was refused", + plugin_id + )); +} +``` + +**A plugin cannot use the id `"postgres"`.** This means: + +| Option | Approach | Impact | +| ------ | -------- | ------ | +| A | Use a different id (e.g., `"postgres-plugin"`) | Existing connections won't auto-migrate; users must reconnect or we need a migration script | +| B | Remove the guard before installing the plugin | Requires a Tabularis core change; allows seamless `driver: "postgres"` swap | +| C | Remove the built-in driver AND the guard simultaneously | Clean swap — plugin takes over the `"postgres"` id slot | + +**Recommended: Option C eventually, but deferred.** During development, the plugin +uses the id `"postgres-plugin"`. The question of whether/how to remove the guard +and take over the `"postgres"` id is a decision for later — once feature parity is +proven and the team agrees on a migration path for existing connections. + +--- + +## Migration Strategy + +```text +Phase 0: Build baseline test suite + CI infrastructure (PREREQUISITE) + ↓ +Phase 1: Build plugin "postgres-plugin" with full feature parity + ↓ +Phase 2: Integrate PR 402 multi-database support into plugin + ↓ +Phase 3: Add issue #16 improvements (sequences, JSONB editing, etc.) + ↓ +Phase 4: Deprecate built-in driver (decision deferred) +``` + +Each phase is independently shippable: + +- After Phase 0: Confidence in the built-in driver's behavior (test baseline) +- After Phase 1: Users can test the plugin alongside the built-in driver +- After Phase 2: Plugin surpasses built-in in functionality +- After Phase 3: Plugin is the definitive PostgreSQL experience +- After Phase 4: Clean architecture — one plugin, no built-in + +--- + +## RPC Adapter Blockers and Gotchas + +Before building the plugin, these limitations in the host's `RpcDriver` adapter +(`src-tauri/src/plugins/driver.rs`) must be understood and addressed. Some require +changes to the Tabularis core; others must be handled plugin-side. + +### P0 — Must Fix Before Feature Parity Is Possible + +| Issue | Detail | Resolution | +| ----- | ------ | ---------- | +| **BLOB methods not forwarded** | `save_blob_to_file` and `fetch_blob_as_data_url` inherit trait defaults that return "not supported". Built-in PG driver reads bytea data and exports to file or base64 wire format. | Extend the RpcDriver to forward these calls. Plugin returns base64 data over JSON; host writes to file. Requires Tabularis core PR. | +| **Materialized views not forwarded** | `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, `refresh_materialized_view` all inherit empty defaults. | Extend the RpcDriver to forward these 4 methods. Straightforward — same pattern as triggers. Requires Tabularis core PR. | +| **`map_inferred_type` not forwarded** | Synchronous method — cannot issue RPC call. Built-in PG maps `DATETIME`→`TIMESTAMP`, `JSON`→`JSONB`. | Plugin declares mappings in manifest/settings at `initialize` time. Host stores them and applies locally. Requires core change to `RpcDriver`. | + +### P1 — Must Handle in Plugin Implementation + +| Issue | Detail | Resolution | +| ----- | ------ | ---------- | +| **Query cancellation** | Host aborts the Tokio task but plugin keeps executing. No signal reaches the DB server. | Plugin implements an internal `cancel_query` mechanism using `pg_cancel_backend()` or connection drop. Discuss with team whether a `cancel` RPC method should be added to the protocol. | +| **`execute_query_batch` session state** | If plugin doesn't implement this, fallback uses separate RPC calls (separate connections). Breaks `BEGIN`/`COMMIT`, temp tables, `SET` commands. | Plugin MUST implement `execute_query_batch` using a single connection for the entire batch. Non-negotiable for PG. | +| **Startup script execution** | Host passes `startup_script` in `ConnectionParams` but does NOT execute it. Plugin must detect and run it on every new pooled connection. | Plugin implements `after_connect` hook in its internal pool that executes `params.startup_script`. | +| **120-second hard timeout** | Long queries (VACUUM, migrations, large aggregations) will timeout. | For now: document the limitation. Later: propose configurable timeout per plugin setting. | + +### P2 — Acceptable for Initial Release, Fix Later + +| Issue | Detail | +| ----- | ------ | +| **No streaming for large results** | Full JSON response in one line. Memory spike for 10K+ row results. Acceptable with pagination (host passes `limit`/`page`). | +| **Batch progress fires post-completion** | UI doesn't show per-statement progress during native batch. Acceptable — same behavior as some existing drivers. | +| **Plaintext password over stdio** | Local pipes only, same user. Acceptable security posture for desktop app. | +| **SSH params still in serialized ConnParams** | Plugin should ignore them (host already tunneled). Document in plugin guide. | +| **Static data_types** | Extension types (PostGIS, pgvector) won't appear in picker. Solve later with dynamic type discovery. | +| **Plugin crash = 120s hang for in-flight calls** | Acceptable for now. Later: fast-fail detection + auto-restart. | + +--- + +## Phase 0: Baseline Test Suite (Before Any Migration) + +### Why Phase 0 Exists + +The current PostgreSQL driver test coverage has critical gaps: + +| Category | Status | +| -------- | ------ | +| Value extraction (wire format parsing) | ✅ 162 unit tests — excellent | +| Parameter binding (type coercion) | ✅ 96 unit tests — excellent | +| Public API functions (36 methods) | ❌ Zero dedicated tests | +| Trait-level interface tests | ❌ Zero tests | +| Integration tests | ⚠️ 4 tests, all `#[ignore]`, never run in CI | +| Cross-driver parity tests | ❌ None | +| EXPLAIN parsing | ❌ Zero tests | +| BLOB handling | ❌ Zero tests | +| DDL generation | ❌ Zero tests | + +**We cannot prove feature parity without a baseline.** Phase 0 creates the test +infrastructure that will be used to verify both the built-in driver AND the plugin +produce identical results. + +### Phase 0 Deliverables + +#### 0.1: CI PostgreSQL Service + +Add a PostgreSQL service container to the CI workflow so integration tests run +automatically on every PR: + +```yaml +# .github/workflows/ci.yml addition +services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: tabularis_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +Remove `#[ignore]` from integration tests and gate on `services.postgres`. + +#### 0.2: Parity Test Harness + +A test framework that runs the same assertions against both the built-in driver +and (later) the plugin, ensuring identical behavior: + +```rust +// tests/parity/harness.rs +pub struct ParityTestHarness { + builtin: Box, + plugin: Option>, // Added in Phase 1 +} + +impl ParityTestHarness { + pub async fn assert_same( + &self, + method: &str, + builtin_result: Result, + plugin_result: Result, + ) { + assert_eq!(builtin_result, plugin_result, + "Parity failure in {}: built-in and plugin returned different results", method); + } +} +``` + +#### 0.3: Golden File Tests for API Surface + +Capture the exact output of every public method against a known test database +as golden/snapshot files: + +```text +tests/parity/golden/ +├── get_tables.json # Expected table list +├── get_columns_users.json # Expected columns for test table +├── get_indexes_users.json # Expected indexes +├── get_foreign_keys_orders.json # Expected FKs +├── get_views.json # Expected views +├── get_routines.json # Expected functions +├── get_triggers.json # Expected triggers +├── execute_query_types.json # Result of SELECT with every PG type +├── explain_simple.json # EXPLAIN output for simple query +├── explain_analyze.json # EXPLAIN ANALYZE output +├── get_materialized_views.json # MV listing +└── ddl/ + ├── create_table.sql # Generated CREATE TABLE + ├── add_column.sql # Generated ALTER TABLE ADD COLUMN + └── create_index.sql # Generated CREATE INDEX +``` + +These golden files become the parity contract. The plugin must produce output +that matches these files exactly (or with documented acceptable differences). + +#### 0.4: Integration Test Expansion + +Add dedicated integration tests for every public method that currently has zero +test coverage: + +```text +tests/integration/postgres/ +├── schema_discovery.rs +│ ├── test_get_schemas +│ ├── test_get_databases +│ ├── test_get_tables (with and without schema filter) +│ └── test_get_tables_system_tables_excluded +├── column_metadata.rs +│ ├── test_get_columns_all_types +│ ├── test_get_columns_nullable_detection +│ ├── test_get_columns_pk_detection +│ ├── test_get_columns_auto_increment_serial +│ ├── test_get_columns_default_values +│ └── test_get_columns_character_max_length +├── foreign_keys.rs +│ ├── test_get_foreign_keys_basic +│ ├── test_get_foreign_keys_composite +│ ├── test_get_foreign_keys_cross_schema +│ └── test_get_foreign_keys_on_delete_cascade +├── indexes.rs +│ ├── test_get_indexes_btree +│ ├── test_get_indexes_unique +│ ├── test_get_indexes_composite +│ └── test_get_indexes_partial +├── views.rs +│ ├── test_get_views +│ ├── test_get_view_definition +│ ├── test_get_view_columns +│ ├── test_create_view +│ ├── test_alter_view +│ └── test_drop_view +├── materialized_views.rs +│ ├── test_get_materialized_views +│ ├── test_get_mv_definition +│ ├── test_get_mv_columns +│ └── test_refresh_mv +├── routines.rs +│ ├── test_get_routines_functions +│ ├── test_get_routines_procedures +│ ├── test_get_routine_parameters +│ ├── test_get_routine_definition +│ ├── test_routine_create_template +│ └── test_drop_routine_overloaded +├── triggers.rs +│ ├── test_get_triggers +│ ├── test_get_trigger_definition +│ ├── test_create_trigger +│ └── test_drop_trigger +├── crud.rs +│ ├── test_insert_all_types +│ ├── test_insert_with_enum_cast +│ ├── test_insert_json_object +│ ├── test_insert_array_value +│ ├── test_update_with_pk +│ ├── test_update_composite_pk +│ ├── test_update_uuid_pk +│ ├── test_delete_single_pk +│ └── test_delete_composite_pk +├── ddl_generation.rs +│ ├── test_create_table_sql +│ ├── test_add_column_sql +│ ├── test_alter_column_rename +│ ├── test_alter_column_type +│ ├── test_create_index_sql +│ ├── test_create_foreign_key_sql +│ └── test_drop_index_sql +├── explain.rs +│ ├── test_explain_simple_select +│ ├── test_explain_analyze +│ └── test_explain_with_buffers +├── blob.rs +│ ├── test_save_blob_to_file +│ ├── test_fetch_blob_as_data_url +│ └── test_blob_round_trip +└── query_execution.rs + ├── test_execute_query_basic + ├── test_execute_query_with_pagination + ├── test_execute_query_all_types_roundtrip + ├── test_execute_batch_transaction + ├── test_execute_batch_temp_tables + └── test_execute_batch_set_commands +``` + +#### 0.5: Test Database Seed Script + +A repeatable seed script that creates the test schema used by all tests: + +```sql +-- tests/fixtures/postgres_seed.sql +CREATE SCHEMA IF NOT EXISTS test_schema; + +CREATE TABLE test_schema.all_types ( + id SERIAL PRIMARY KEY, + col_text TEXT, + col_varchar VARCHAR(255), + col_int INTEGER, + col_bigint BIGINT, + col_float REAL, + col_double DOUBLE PRECISION, + col_numeric NUMERIC(10,2), + col_bool BOOLEAN, + col_date DATE, + col_time TIME, + col_timestamp TIMESTAMP, + col_timestamptz TIMESTAMPTZ, + col_uuid UUID, + col_json JSON, + col_jsonb JSONB, + col_bytea BYTEA, + col_inet INET, + col_cidr CIDR, + col_macaddr MACADDR, + col_int_array INTEGER[], + col_text_array TEXT[], + col_int4range INT4RANGE, + col_tsrange TSRANGE +); + +CREATE TYPE test_schema.mood AS ENUM ('happy', 'sad', 'neutral'); +CREATE TABLE test_schema.with_enum ( + id SERIAL PRIMARY KEY, + current_mood test_schema.mood +); + +-- ... (tables with FKs, indexes, triggers, routines, views, MVs) +``` + +### Phase 0 Success Criteria + +- [ ] All 4 existing integration tests pass in CI (un-ignored, PG service running) +- [ ] 50+ new integration tests covering the full API surface +- [ ] Golden files captured for every public method +- [ ] Parity harness infrastructure ready (built-in driver fills it today) +- [ ] Seed script creates a comprehensive test schema +- [ ] CI runs in < 5 minutes with PG service + +--- + +## Phase 1: Plugin Scaffold with Feature Parity + +### Goal + +A standalone Rust plugin that implements every method the built-in PostgreSQL +driver currently supports, passing the same test suite. + +### Scaffold Structure + +```text +plugins/postgres-plugin/ +├── .tabularium # Plugin manifest +├── Cargo.toml # Rust project +├── src/ +│ ├── main.rs # Stdin/stdout JSON-RPC loop +│ ├── rpc.rs # Method dispatch router +│ ├── models.rs # ConnectionParams, shared types +│ ├── pool.rs # Connection pool management (tokio-postgres) +│ ├── handlers/ +│ │ ├── metadata.rs # get_tables, get_columns, get_views, etc. +│ │ ├── query.rs # execute_query, execute_query_batch +│ │ ├── crud.rs # insert_record, update_record, delete_record +│ │ ├── ddl.rs # get_create_table_sql, get_add_column_sql, etc. +│ │ ├── routines.rs # get_routines, build_routine_call_sql, etc. +│ │ ├── explain.rs # explain_query_plan +│ │ └── blob.rs # save_blob_to_file, fetch_blob_as_data_url +│ ├── binding.rs # Typed parameter binding (enum CAST, etc.) +│ ├── extract/ # Value extraction from PG rows +│ │ ├── mod.rs +│ │ ├── simple.rs # Basic types +│ │ ├── array.rs # PG arrays +│ │ ├── range.rs # Range types +│ │ ├── multi_range.rs # Multi-range types +│ │ ├── composite.rs # Composite/record types +│ │ ├── enum_type.rs # Enum extraction +│ │ └── advanced.rs # UUID, JSONB, geometric, etc. +│ └── types.rs # Data type declarations (97+ types) +└── tests/ + ├── metadata_test.rs + ├── query_test.rs + ├── crud_test.rs + └── ddl_test.rs +``` + +### Manifest (`.tabularium`) + +```json +{ + "id": "postgres-plugin", + "name": "PostgreSQL (Next)", + "version": "0.1.0", + "description": "Next-generation PostgreSQL driver plugin", + "executable": "postgres-plugin", + "default_port": 5432, + "default_username": "postgres", + "color": "#336791", + "icon": "postgres", + "engine": "PostgreSQL", + "paradigms": ["relational"], + "capabilities": { + "schemas": true, + "views": true, + "materialized_views": true, + "routines": true, + "routine_management": true, + "triggers": true, + "file_based": false, + "connection_string": true, + "connection_string_example": "postgresql://user:pass@host:5432/dbname", + "alter_primary_key": true, + "alter_column": true, + "create_foreign_keys": true, + "explain": true, + "supports_ssl": true, + "sql_dialect": "Postgres", + "identifier_quote": "\"", + "manage_tables": true, + "serial_type": "SERIAL", + "auto_increment_keyword": "" + }, + "settings": [ + { + "key": "sslMode", + "label": "SSL Mode", + "setting_type": "select", + "default": "prefer", + "options": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] + }, + { + "key": "statementTimeout", + "label": "Statement Timeout (ms)", + "setting_type": "number", + "default": 0, + "description": "0 = no timeout" + } + ], + "data_types": [] +} +``` + +### RPC Methods to Implement (Full List) + +| Category | Methods | +| -------- | ------- | +| Connection | `initialize`, `ping`, `test_connection`, `shutdown` | +| Databases | `get_databases`, `get_schemas` | +| Metadata | `get_tables`, `get_columns`, `get_views`, `get_view_definition`, `get_view_columns`, `get_indexes`, `get_foreign_keys`, `get_triggers`, `get_trigger_definition`, `get_routines`, `get_routine_parameters`, `get_routine_definition` | +| Query | `execute_query`, `execute_query_batch`, `count_query` | +| CRUD | `insert_record`, `update_record`, `delete_record` | +| BLOB | `save_blob_to_file`, `fetch_blob_as_data_url` | +| DDL | `get_create_table_sql`, `get_add_column_sql`, `get_alter_column_sql`, `get_create_index_sql`, `drop_index`, `get_create_foreign_key_sql`, `drop_foreign_key` | +| Views | `create_view`, `alter_view`, `drop_view` | +| Triggers | `create_trigger`, `drop_trigger`, `update_trigger` | +| Routines | `build_routine_call_sql`, `routine_create_template`, `get_routine_edit_script`, `drop_routine` | +| Explain | `explain_query_plan` | +| AI | `get_ai_schema_context` | + +### Key Technical Decisions + +| Decision | Choice | Rationale | +| -------- | ------ | --------- | +| PostgreSQL client library | `tokio-postgres` | Direct async access, full type system control. Matches what sqlx uses internally. | +| Connection pooling | `deadpool-postgres` | Production-grade pool with configurable size, timeouts, and recycling. Key pools by `host:port:database:user`. | +| Typed binding | Port existing `binding.rs` logic | Critical for enum CASTs, UUID handling, array bindings | +| Value extraction | Port existing `extract/` submodules | Needed for proper array, range, composite, enum rendering | +| SSL support | `tokio-postgres-rustls` | Matches existing SSL capability. `rustls` avoids OpenSSL dependency. | +| Binary format | Text protocol initially, binary later | Text is simpler to port; binary can be optimized later | + +### Phase 1 Success Criteria — Zero Wiggle Room + +Phase 1 is **not done** until: + +1. **All Phase 0 golden file tests pass with the plugin driver** — The parity + harness runs every test against both the built-in driver and the plugin, + asserting identical results. Zero tolerance for differences. + +2. **The full integration test suite passes with the plugin** — Same 50+ tests + that validate the built-in driver must pass when pointed at the plugin. + +3. **Manual smoke test checklist** (all pass): + - [ ] Connect to PG via host/port + - [ ] Connect via connection string + - [ ] Connect via SSL (all modes) + - [ ] Browse schemas in sidebar + - [ ] Browse tables, views, materialized views, routines, triggers + - [ ] Execute SELECT with all PG types (see seed table) + - [ ] Inline edit: update text, number, boolean, date, enum, json, array + - [ ] Insert new row with auto-generated serial PK + - [ ] Delete row by single PK and composite PK + - [ ] BLOB: save bytea column to file, preview as data URL + - [ ] EXPLAIN: view query plan, view ANALYZE output + - [ ] Batch: run multi-statement script with BEGIN/COMMIT + - [ ] Batch: temp table persists across statements + - [ ] Batch: SET command persists across statements + - [ ] Startup script: SET search_path executes on connect + - [ ] DDL: create table, add column, alter column, create index, create FK + - [ ] Views: create, alter, drop + - [ ] Materialized views: list, inspect, refresh + - [ ] Routines: list, inspect, call function, call procedure + - [ ] Triggers: list, inspect, create, drop + +4. **No regressions in existing frontend tests** — `pnpm test` passes unchanged. + +--- + +## Phase 2: Multi-Database Support (PR 402) + +### Phase 2 Goal + +Incorporate the multi-database browsing architecture from PR #402 into the plugin. + +### What PR 402 Requires from the Driver + +1. **Handle `database` parameter on every command** — The host sends `params.database` + set to the target database. The plugin must route to the correct pool. + +2. **Per-database connection pools** — When `params.database` changes between calls, + the plugin creates/reuses a pool for that specific database. + +3. **`get_schemas` per database** — Schema discovery is called separately for each + database the user expands in the sidebar. + +4. **`get_databases` returns all databases** — Used to populate the sidebar tree. + +5. **Fall back to `"postgres"` database** — When connecting without an explicit + database selection, use the maintenance database. + +6. **`ref_schema` in ForeignKey results** — Return the schema of the referenced + table for cross-schema FK navigation. + +### Implementation in the Plugin + +```rust +// In pool.rs — pool keyed by database +fn pool_key(params: &ConnectionParams) -> String { + format!("{}:{}:{}:{}", params.host, params.port, params.database, params.user) +} + +// In each handler — use params.database to select pool +async fn get_tables(params: &ConnectionParams, schema: Option<&str>) -> Result<...> { + let pool = get_or_create_pool(params).await?; + // Query using pool for params.database +} +``` + +The plugin naturally handles this because every RPC call receives the full +`ConnectionParams` with the correct `database` field already set by the host. + +--- + +## Phase 3: Issue 16 Improvements + +### Phase 3 Goal + +Add the feature gaps and bug fixes identified in the PostgreSQL audit (issue #16). + +### Items (from the audit) + +| Priority | Item | +| -------- | ---- | +| High | Sequence management (list, inspect, alter, reset) | +| High | JSONB inline editing (object/array manipulation) | +| High | Extension-aware type system (PostGIS, pgvector, ltree) | +| Medium | Partition table introspection | +| Medium | Row-level security policy display | +| Medium | Publication/subscription visibility | +| Medium | Advisory lock monitoring | +| Low | Query plan cost visualization improvements | +| Low | Table statistics (pg_stat_user_tables) display | + +### Advantage of Plugin Architecture + +These improvements are easier to ship as a plugin because: + +- No Tabularis core release needed — just update the plugin binary +- Can iterate faster (plugin version != app version) +- Users can opt-in to beta plugin versions +- Plugin-specific UI extensions can be bundled (`ui_extensions` in manifest) + +--- + +## Phase 4: Deprecate Built-in Driver (Deferred Decision) + +### Phase 4 Goal + +Remove the built-in PostgreSQL driver from the Tabularis core and let the plugin +become the sole PostgreSQL driver. **The specifics of this phase are deferred** +until Phases 1-3 are complete and the team can evaluate: + +- Whether the plugin id should become `"postgres"` (seamless migration) or remain + `"postgres-plugin"` (requires connection migration tooling) +- Whether to remove the `BUILTIN_DRIVER_IDS` guard entirely or modify it +- Whether to bundle the plugin with the app distribution or keep it installable + +### Possible Steps (to be finalized later) + +1. Remove `BUILTIN_DRIVER_IDS` guard (or remove `"postgres"` from the array) +2. Remove `src-tauri/src/drivers/postgres/` directory +3. Remove PostgreSQL pool logic from `pool_manager.rs` +4. Decide on plugin id (`"postgres"` vs keeping `"postgres-plugin"`) +5. If renaming to `"postgres"`: auto-migration for saved connections +6. If keeping `"postgres-plugin"`: connection migration UI or script +7. Update frontend: remove hardcoded PostgreSQL references in `useDrivers.ts` + +### Connection Migration (if plugin takes over `"postgres"` id) + +Existing saved connections use `driver: "postgres"`. If the plugin takes over +that exact id, connections work without modification: + +```text +Before: driver: "postgres" → built-in code path +After: driver: "postgres" → plugin registered with id "postgres" → same behavior +``` + +**No user action required** if the plugin uses the same id. + +--- + +## Plugin Architecture Reference + +### Communication Protocol + +```text +Host (Tauri) Plugin (standalone process) + | | + |-- JSON-RPC Request (stdin) ------->| + | {"jsonrpc":"2.0", | + | "method":"execute_query", | + | "params":{ | + | "params":{...ConnParams...}, | + | "query":"SELECT...", | + | "limit":500, | + | "page":1, | + | "schema":"public" | + | }, | + | "id":42} | + | | + |<-- JSON-RPC Response (stdout) -----| + | {"jsonrpc":"2.0", | + | "result":{ | + | "columns":["id","name"], | + | "rows":[[1,"Alice"],...], | + | "affected_rows":0, | + | "pagination":{...} | + | }, | + | "id":42} | +``` + +### Key Constraints + +| Constraint | Detail | +| ---------- | ------ | +| One process per plugin | All connections for that driver type go through one process | +| 120s call timeout | `PLUGIN_CALL_TIMEOUT` — if exceeded, returns error | +| No streaming | Full result returned in one JSON response | +| Newline-delimited | Each request/response is a single line of JSON | +| `ConnectionParams` on every call | Plugin must parse and route internally | +| `-32601` for unimplemented methods | Host falls back to defaults for optional methods | +| Plugin manages its own pools | Host does not pool connections for plugins | + +### ConnectionParams Structure (what the plugin receives) + +```json +{ + "host": "localhost", + "port": 5432, + "user": "postgres", + "password": "secret", + "database": "mydb", + "ssl": true, + "ssl_mode": "require", + "connection_string": null, + "startup_script": "SET search_path TO myschema", + "connection_id": "abc-123", + "driver": "postgres-plugin", + "settings": { + "sslMode": "prefer", + "statementTimeout": 30000 + } +} +``` + +--- + +## PR 402 Architecture Summary + +### Core Changes + +PR #402 adds per-database connection routing for PostgreSQL: + +- **Pool key includes database**: `postgres:conn:{id}:{host}:{port}:{dbname}` +- **Every command gains `database: Option`** parameter +- **Frontend tabs carry `tab.database`** alongside `tab.schema` +- **`buildTableRoutingParams()`** utility builds `{ schema, database }` for backend calls +- **`isSchemaBasedMultiDb()`** distinguishes PG hierarchy from MySQL flat layout +- **Lazy schema loading**: Sidebar loads schemas per-database on expand, not all at once +- **`ForeignKey.ref_schema`**: New field for cross-schema FK references + +### What This Means for the Plugin + +The plugin doesn't need to know about PR 402's frontend changes — the host handles +routing. The plugin just needs to: + +1. Use `params.database` to connect to the correct database +2. Pool connections per-database internally +3. Return schema-qualified FK references (`ref_schema`) +4. Support `get_schemas` called per-database + +--- + +## Dependency Sequencing + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ PREREQUISITE: 3 Tabularis Core PRs (can be one combined PR) │ +│ • RpcDriver: forward BLOB methods (base64 over JSON) │ +│ • RpcDriver: forward materialized view methods │ +│ • RpcDriver: resolve map_inferred_type from manifest/settings │ +└──────────────────────────────────┬──────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 0: Baseline Test Suite │ +│ • CI PG service + seed script │ +│ • 50+ integration tests against built-in driver │ +│ • Golden file captures │ +│ • Parity harness infrastructure │ +│ can overlap │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 1: Plugin with Feature Parity │ +│ • Build postgres-plugin (scaffold + all 30+ RPC methods) │ +│ • Run Phase 0 tests against plugin — must all pass │ +│ • Golden file comparison — must match built-in output │ +│ • Manual smoke test checklist — all items pass │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks (+ PR 402 merges) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 2: Multi-Database (PR 402) │ +│ • Per-database pool routing in plugin │ +│ • get_schemas per database │ +│ • ref_schema in FK results │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: Issue #16 Improvements │ +│ • Sequences, JSONB editing, extensions, partitions, etc. │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ team decision + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 4: Deprecate Built-in (deferred) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**Parallelization opportunity:** Phase 0 test writing and the Core PRs can happen +simultaneously. Phase 1 plugin scaffold can begin once the Core PRs are merged +(the plugin needs BLOB/MV forwarding to pass parity tests). + +--- + +## Developer Workflow + +### Local Development Setup + +```bash +# 1. Clone and build the plugin +cd plugins/postgres-plugin +cargo build --release + +# 2. Install locally (symlink or copy to plugin directory) +# macOS: +cp target/release/postgres-plugin \ + ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ +cp .tabularium \ + ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ + +# 3. Restart Tabularis — plugin auto-loads on startup +# Or use Settings > Plugins > Enable to hot-reload +``` + +### Testing Locally + +```bash +# Run plugin unit tests +cargo test + +# Run integration tests against local PG (requires Docker) +docker run -d --name pg-test -p 54320:5432 \ + -e POSTGRES_PASSWORD=test -e POSTGRES_DB=tabularis_test postgres:16 +cargo test --features integration + +# Run parity tests (compares plugin output vs golden files) +cargo test --features parity + +# Interactive REPL for debugging RPC calls +cargo run --bin test_plugin +> {"jsonrpc":"2.0","method":"get_tables","params":{"params":{...},"schema":"public"},"id":1} +``` + +### Testing in Tabularis + +1. Build the plugin binary +2. Install to the plugins directory +3. Launch Tabularis +4. Create a new connection with driver "PostgreSQL (Next)" +5. Run the manual smoke test checklist (see Phase 1 Success Criteria) +6. Compare behavior with a parallel "PostgreSQL" (built-in) connection to the same database + +--- + +## Risk Assessment + +| Risk | Likelihood | Mitigation | +| ---- | ---------- | ---------- | +| **Performance regression** (JSON-RPC overhead) | Medium | Benchmark with large result sets. JSON serialization is fast for tabular data. Network latency to PG server dominates. Defer optimization unless measurably slow. | +| **Feature parity gaps missed** | Low (with Phase 0) | Phase 0's golden files and 50+ tests create a comprehensive contract. Gaps caught immediately via automated parity comparison. | +| **Plugin crash isolation** | Low | Plugin crash doesn't crash Tabularis. Host returns error. Can offer "Restart plugin" in UI. | +| **Typed binding fidelity** | High | Existing binding system handles 20+ PG types with CASTs. Port with per-type tests. This is the highest-risk area — must be methodical. | +| **PR 402 integration conflicts** | Medium | Build plugin against `main`. When 402 merges, adapt plugin (isolated codebase — no merge conflicts with Tabularis core). | +| **Core PR rejection** | Low | The 3 required RpcDriver changes are small, non-breaking additions. Same pattern as existing forwarded methods. | +| **Phase 0 scope creep** | Medium | Timebox Phase 0. The 50+ tests are the minimum viable baseline. Don't gold-plate — capture what's needed for parity proof, nothing more. | + +--- + +## Open Questions + +1. **Plugin id during development** — Use `"postgres-plugin"` during Phases 1-3. + Decision on whether to rename to `"postgres"` deferred to Phase 4. + +2. **Bundling strategy** — Should the PostgreSQL plugin be bundled with Tabularis + app distribution (always available) or installed on-demand from the registry? + Bundling ensures no regression for existing users on Phase 4 cutover. + +3. **RPC adapter core PRs** — Phase 1 requires 3 Tabularis core changes to the + `RpcDriver` (BLOB forwarding, materialized view forwarding, `map_inferred_type` + resolution). Should these be submitted as a prerequisite PR before plugin + development, or developed in parallel? + +4. **BLOB protocol extension** — The RPC protocol has no binary data support. + Proposed: base64-encode blob data in JSON responses. Is the size overhead + (33% increase) acceptable? Alternative: shared temp file path exchange. + +5. **Query cancellation protocol** — Should we propose a `cancel_query` RPC method + to the plugin protocol? Without it, long queries are unkillable from the user's + perspective (the task is aborted but the server query continues). + +6. **PR 402 timing** — Should we wait for PR 402 to merge into main before + starting Phase 2, or port its changes directly into the plugin from the PR + branch? The latter avoids waiting but means maintaining a fork of 402's logic. + +7. **Existing plugin ecosystem** — Are there any community PostgreSQL plugins + already? Could we conflict with or build on existing work? + +8. **Plugin versioning** — When the plugin ships updates independently of Tabularis, + how do we ensure compatibility? Should the manifest declare a minimum Tabularis + version? + +9. **Phase 0 scope negotiation** — The 50+ integration tests in Phase 0 represent + significant work. Can we parallelize Phase 0 and Phase 1 (build plugin scaffold + while writing tests), or must Phase 0 fully complete first? + +10. **Timeout configurability** — The 120s hard timeout will break long-running + queries. Should we propose a manifest field (`call_timeout_seconds`) or a + per-call timeout negotiation? diff --git a/docs/planning/postgres-plugin-migration.md b/docs/planning/postgres-plugin-migration.md new file mode 100644 index 0000000..4841f1c --- /dev/null +++ b/docs/planning/postgres-plugin-migration.md @@ -0,0 +1,533 @@ +# PostgreSQL Plugin Migration — Alternative: Multi-Database From Day One + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [PR #402 — Multi-database connections](https://github.com/TabularisDB/tabularis/pull/402) +**Context:** Feedback suggesting multi-database support should be built in from the +start rather than added as a later phase. + +## Executive Summary + +This document explores the alternative approach of building the PostgreSQL plugin +with multi-database support from day one. After analysis, the conclusion is that +**the two approaches are architecturally equivalent** — a correctly-built plugin +inherently supports multi-database because the RPC protocol routes `params.database` +on every call. The plugin cannot function without reading this field. + +However, the feedback raises a valid point about **test coverage and verification +confidence**. This alternative plan consolidates Phases 1 and 2 into a single +phase that tests multi-database from the beginning, eliminating any theoretical +risk of overlooking it. + +The Phase 0 baseline test suite and zero-regression guarantee remain unchanged. + +--- + +## Table of Contents + +1. [Why Multi-Database Is Not a Separate Concern](#why-multi-database-is-not-a-separate-concern) +2. [What Changes vs. the Phased Plan](#what-changes-vs-the-phased-plan) +3. [Revised Phase Structure](#revised-phase-structure) +4. [Phase 0: Baseline Test Suite](#phase-0-baseline-test-suite) +5. [Phase 1: Plugin with Full Parity + Multi-Database (TDD)](#phase-1-plugin-with-full-parity--multi-database-tdd) +6. [Phase 2: Issue 16 Improvements](#phase-2-issue-16-improvements) +7. [Phase 3: Deprecate Built-in Driver](#phase-3-deprecate-built-in-driver-deferred) +8. [Why This Is Safe — Zero Regression Guarantee](#why-this-is-safe--zero-regression-guarantee) +9. [RPC Adapter Blockers](#rpc-adapter-blockers) +10. [Open Questions](#open-questions) + +--- + +## Why Multi-Database Is Not a Separate Concern + +The RPC protocol makes multi-database support **emergent from correct implementation**: + +1. **Every RPC call includes `params.database`** — The host sets this to the target + database before calling the plugin. The plugin must read it to connect at all. + +2. **PostgreSQL requires per-database connections** — You cannot `USE other_db` + mid-session. Each database needs its own TCP connection. This means the pool + key MUST include the database name regardless of whether "multi-database" is a + stated goal. + +3. **The plugin is stateless between calls** — There is no "current database" + concept in the plugin. Each call receives full connection parameters including + the database to target. + +4. **The host does all routing** — The frontend (PR 402) handles sidebar tree + expansion, tab database tracking, and routing params construction. The plugin + just connects to whatever it's told. + +### What a Correctly-Built Plugin Pool Looks Like + +```rust +// This is the ONLY correct implementation — it naturally supports multi-database +fn pool_key(params: &ConnectionParams) -> String { + format!("{}:{}:{}:{}", params.host, params.port, params.database, params.user) +} + +async fn get_or_create_pool(params: &ConnectionParams) -> Result { + let key = pool_key(params); + // Return existing pool for this database, or create a new one + // ... +} +``` + +A developer building this plugin would write this code on day one because it's +the only way to connect to PostgreSQL. You cannot accidentally build a +single-database-only plugin — the protocol doesn't allow it. + +### The Only Multi-Database-Specific Items + +| Item | Effort | Why it's trivial | +| ---- | ------ | ---------------- | +| `get_databases` returns all databases | One SQL query | `SELECT datname FROM pg_database WHERE datallowconn` | +| Fall back to `"postgres"` maintenance DB | One-line default | `let db = params.database.or("postgres")` | +| `ref_schema` in ForeignKey results | One field in FK query | Add `nsp2.nspname AS ref_schema` to existing JOIN | + +These are not architectural decisions — they're checklist completeness items that +belong alongside all other method implementations. + +--- + +## What Changes vs. the Phased Plan + +| Aspect | Original (Phases 1+2 separate) | This Alternative (Combined) | +| ------ | ------------------------------ | --------------------------- | +| Plugin build phases | Phase 1 (parity) → Phase 2 (multi-db) | Single Phase 1 (parity + multi-db) | +| Testing approach | Phase 0 tests single-db, Phase 2 adds multi-db tests | Phase 0 tests BOTH from the start | +| Pool implementation | Same code either way | Same code either way | +| Phase 0 scope | 50+ tests, single database | 55+ tests, includes multi-database scenarios | +| Total phases | 5 (0-4) | 4 (0-3) | +| Risk | Theoretical: could build single-db pools accidentally | Eliminated: tests catch it immediately | +| Phase 0 seed script | Single database | Two databases (test primary + test secondary) | + +**The actual plugin code is identical.** The difference is purely in **test scope** +and **verification confidence** — which aligns exactly with the requirement for +zero-regression proof. + +--- + +## Revised Phase Structure + +```text +PREREQUISITE: 3 Tabularis Core PRs (RpcDriver fixes) + ↓ +Phase 0: Baseline test suite (includes multi-database scenarios) + ↓ +Phase 1: Build plugin "postgres-plugin" — full parity including multi-database + ↓ +Phase 2: Issue #16 improvements (sequences, JSONB editing, etc.) + ↓ +Phase 3: Deprecate built-in driver (deferred decision) +``` + +--- + +## Phase 0: Baseline Test Suite + +Phase 0 is identical to the original plan with one key addition: the test seed +creates **two databases** and the test suite includes multi-database scenarios. + +### Seed Script Addition + +```sql +-- tests/fixtures/postgres_seed.sql + +-- Primary test database (tabularis_test) — same as before +CREATE SCHEMA IF NOT EXISTS test_schema; +CREATE TABLE test_schema.all_types ( ... ); +-- ... all existing seed tables ... + +-- SECOND database for multi-database testing +-- (created via separate connection to maintenance DB) +CREATE DATABASE tabularis_test_secondary; + +-- In tabularis_test_secondary: +CREATE SCHEMA IF NOT EXISTS secondary_schema; +CREATE TABLE secondary_schema.remote_lookup ( + id SERIAL PRIMARY KEY, + code TEXT UNIQUE +); +``` + +### Additional Multi-Database Tests (Added to Phase 0) + +```text +tests/integration/postgres/ +└── multi_database.rs + ├── test_get_databases_lists_both + ├── test_get_schemas_on_secondary_database + ├── test_get_tables_on_secondary_database + ├── test_execute_query_on_secondary_database + ├── test_pool_reuse_same_database + ├── test_pool_isolation_different_databases + └── test_fallback_to_postgres_maintenance_db +``` + +### Phase 0 Success Criteria (Updated) + +- [ ] All existing integration tests pass in CI (un-ignored, PG service running) +- [ ] 55+ new integration tests covering full API surface + multi-database +- [ ] Golden files captured for every public method +- [ ] Multi-database golden files (schemas/tables from secondary database) +- [ ] Parity harness infrastructure ready +- [ ] Seed script creates TWO databases with comprehensive test schemas +- [ ] CI runs in < 5 minutes with PG service + +--- + +## Phase 1: Plugin with Full Parity + Multi-Database (TDD) + +### Phase 1 Goal + +A standalone Rust plugin that implements every method the built-in PostgreSQL +driver supports — including multi-database routing — passing the same test suite +that validates the built-in driver. Built iteratively using Test-Driven Development: +one method at a time, watching tests go from red to green. + +### TDD Workflow + +Phase 0 produces a test suite that passes against the built-in driver. At the +start of Phase 1, the same suite is pointed at the plugin. Every test is RED +because the plugin doesn't exist yet. Implementation proceeds method by method: + +```text +START: 0/55 tests GREEN (plugin binary doesn't exist) + +Sprint 1 — Foundation (scaffold + connection) +───────────────────────────────────────────── + cargo init → main.rs with JSON-RPC loop → rpc.rs router + Implement: initialize, ping, test_connection, shutdown + Run tests → 3/55 GREEN (connection tests pass) + +Sprint 2 — Schema Discovery +──────────────────────────── + Implement: get_databases, get_schemas, get_tables + Run tests → 8/55 GREEN + +Sprint 3 — Column & Key Metadata +────────────────────────────────── + Implement: get_columns, get_indexes, get_foreign_keys + Port: extract/ submodules (needed for type-aware column reading) + Run tests → 18/55 GREEN + +Sprint 4 — Query Execution +─────────────────────────── + Implement: execute_query, execute_query_batch, count_query + Port: extract/ for result value extraction (all PG types) + Run tests → 26/55 GREEN + +Sprint 5 — CRUD Operations +─────────────────────────── + Implement: insert_record, update_record, delete_record + Port: binding.rs (enum CASTs, UUID handling, array bindings) + Run tests → 35/55 GREEN + +Sprint 6 — Views & Materialized Views +─────────────────────────────────────── + Implement: get_views, get_view_definition, get_view_columns, + create_view, alter_view, drop_view, + get_materialized_views, get_mv_definition, + get_mv_columns, refresh_materialized_view + Run tests → 41/55 GREEN + +Sprint 7 — Routines & Triggers +─────────────────────────────── + Implement: get_routines, get_routine_parameters, + get_routine_definition, build_routine_call_sql, + routine_create_template, get_routine_edit_script, + drop_routine, get_triggers, get_trigger_definition, + create_trigger, drop_trigger, update_trigger + Run tests → 48/55 GREEN + +Sprint 8 — DDL, EXPLAIN, BLOB +────────────────────────────── + Implement: get_create_table_sql, get_add_column_sql, + get_alter_column_sql, get_create_index_sql, + drop_index, get_create_foreign_key_sql, drop_foreign_key, + explain_query_plan, save_blob_to_file, + fetch_blob_as_data_url, get_ai_schema_context + Run tests → 53/55 GREEN + +Sprint 9 — Multi-Database & Polish +──────────────────────────────────── + Verify: get_databases returns both test DBs + Verify: queries route to correct database + Verify: ref_schema populated in FK results + Fix: any remaining failures, edge cases + Run tests → 55/55 GREEN ✅ + +DONE: All tests green. Run golden file comparison. Run manual smoke test. +``` + +### The Red → Green Discipline + +At each sprint: + +1. **Run the full parity suite** — see exactly which tests are RED +2. **Pick the next batch of related methods** — implement them +3. **Run again** — confirm new tests are GREEN, nothing regressed +4. **Commit** — each commit message references which tests it turns green + +```bash +# Developer workflow at each sprint +cargo build --release +cp target/release/postgres-plugin ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ + +# Run parity suite against plugin +cargo test --features parity -- --nocapture +# Output: 26/55 passed, 29 failed (EXPECTED — haven't built those yet) + +# After implementing next batch: +cargo test --features parity -- --nocapture +# Output: 35/55 passed, 20 failed (PROGRESS — 9 new tests green) + +# Verify no regressions: +# Previously-green tests must stay green. If one goes RED, fix before moving on. +``` + +### What This Guarantees + +| Guarantee | Mechanism | +| --------- | --------- | +| No method is forgotten | Every method has a test from Phase 0. If the test is still RED, the method isn't done. | +| No silent regressions | The full suite runs at every sprint. A previously-GREEN test going RED is immediately visible. | +| Progress is measurable | "35/55 green" is an objective, unambiguous progress metric. | +| Parity is proven, not claimed | The same test produces the same assertion against both drivers. If it passes on both, they are equivalent by construction. | +| Implementation order is flexible | Sprints above are a suggested order. If a different order is easier, the tests don't care — they just need to all be GREEN eventually. | + +### What's Different From Original Phase 1 + +| Original Phase 1 | This Phase 1 | +| ----------------- | ------------ | +| Build plugin, then run tests | Tests exist first, guide implementation | +| `get_databases` not required | `get_databases` implemented and tested | +| No multi-db tests in parity suite | Multi-db tests included in parity suite | +| `ref_schema` not in FK results | `ref_schema` included from the start | +| Pool tested with one database | Pool tested with multiple databases | +| Progress measured by checklist | Progress measured by test count (objective) | + +### Plugin Structure + +```text +plugins/postgres-plugin/ +├── .tabularium +├── Cargo.toml +├── src/ +│ ├── main.rs # JSON-RPC stdin/stdout loop +│ ├── rpc.rs # Method dispatch router +│ ├── models.rs # ConnectionParams, shared types +│ ├── pool.rs # deadpool-postgres, keyed by host:port:db:user +│ ├── handlers/ +│ │ ├── metadata.rs # get_tables, get_columns, get_databases, etc. +│ │ ├── query.rs # execute_query, execute_query_batch +│ │ ├── crud.rs # insert_record, update_record, delete_record +│ │ ├── ddl.rs # get_create_table_sql, get_add_column_sql, etc. +│ │ ├── routines.rs # get_routines, build_routine_call_sql, etc. +│ │ ├── explain.rs # explain_query_plan +│ │ └── blob.rs # save_blob_to_file, fetch_blob_as_data_url +│ ├── binding.rs # Typed parameter binding (enum CAST, etc.) +│ ├── extract/ # Value extraction from PG rows +│ │ ├── mod.rs +│ │ ├── simple.rs +│ │ ├── array.rs +│ │ ├── range.rs +│ │ ├── multi_range.rs +│ │ ├── composite.rs +│ │ ├── enum_type.rs +│ │ └── advanced.rs +│ └── types.rs # 97+ data type declarations +└── tests/ + ├── metadata_test.rs + ├── query_test.rs + ├── crud_test.rs + ├── ddl_test.rs + └── multi_database_test.rs +``` + +### Phase 1 Success Criteria — Zero Wiggle Room + +Phase 1 is **not done** until: + +1. **55/55 parity tests GREEN** — Including multi-database tests. Zero RED. + This is binary: either all pass or it's not done. + +2. **Golden file comparison passes** — Plugin output matches built-in output + byte-for-byte for every captured method response. + +3. **Manual smoke test checklist** (all pass): + - [ ] Connect to PG via host/port + - [ ] Connect via connection string + - [ ] Connect via SSL (all modes) + - [ ] Browse schemas in sidebar + - [ ] Browse tables, views, materialized views, routines, triggers + - [ ] Execute SELECT with all PG types + - [ ] Inline edit: update text, number, boolean, date, enum, json, array + - [ ] Insert new row with auto-generated serial PK + - [ ] Delete row by single PK and composite PK + - [ ] BLOB: save bytea column to file, preview as data URL + - [ ] EXPLAIN: view query plan, view ANALYZE output + - [ ] Batch: run multi-statement script with BEGIN/COMMIT + - [ ] Batch: temp table persists across statements + - [ ] Batch: SET command persists across statements + - [ ] Startup script: SET search_path executes on connect + - [ ] DDL: create table, add column, alter column, create index, create FK + - [ ] Views: create, alter, drop + - [ ] Materialized views: list, inspect, refresh + - [ ] Routines: list, inspect, call function, call procedure + - [ ] Triggers: list, inspect, create, drop + - [ ] Multi-db: browse second database in sidebar + - [ ] Multi-db: execute query against second database + - [ ] Multi-db: get_schemas returns schemas from correct database + - [ ] Multi-db: FK with ref_schema navigates cross-schema + +4. **No regressions in existing frontend tests** — `pnpm test` passes unchanged. + +--- + +## Phase 2: Issue 16 Improvements + +Identical to original plan's Phase 3. Now Phase 2 since multi-db is absorbed +into Phase 1. + +**Important:** Before implementing any feature, check for existing open PRs that +already address it. Known in-flight: PR #427 (hstore editing), PR #222 (composite +PK). See `03-phase-2-issue-16.md` for the full coordination process. + +| Priority | Item | +| -------- | ---- | +| High | Sequence management (list, inspect, alter, reset) | +| High | JSONB inline editing (object/array manipulation) | +| High | Extension-aware type system (PostGIS, pgvector, ltree, hstore — **see PR #427**) | +| Medium | Partition table introspection | +| Medium | Row-level security policy display | +| Medium | Publication/subscription visibility | +| Medium | Advisory lock monitoring | +| Low | Query plan cost visualization improvements | +| Low | Table statistics (pg_stat_user_tables) display | + +--- + +## Phase 3: Deprecate Built-in Driver (Deferred) + +Identical to original plan's Phase 4. Decision deferred until Phase 1 parity is +proven. + +--- + +## Why This Is Safe — Zero Regression Guarantee + +The safety model has three layers: + +### Layer 1: Golden File Parity (Automated) + +Every public method's output is captured as a golden file against the built-in +driver. The plugin must produce byte-for-byte identical output. This runs in CI +on every commit. + +```text +Built-in: get_columns("all_types", "test_schema") → golden/get_columns_all_types.json +Plugin: get_columns("all_types", "test_schema") → must match exactly +``` + +### Layer 2: Integration Test Suite (Automated) + +55+ tests exercise every API method with real PostgreSQL. Parameterized to run +against both built-in and plugin. Any difference = test failure = CI red. + +```rust +#[test_case("postgres"; "built-in driver")] +#[test_case("postgres-plugin"; "plugin driver")] +async fn test_insert_with_enum_cast(driver: &str) { + // Same test, same assertions, both drivers must produce identical results +} +``` + +### Layer 3: Manual Smoke Test (Human Verification) + +24-item checklist performed manually before any release. Covers UX flows that +automated tests can't fully validate (sidebar navigation, inline editing feel, +error message quality). + +### What This Catches + +| Failure Mode | Caught By | +| ------------ | --------- | +| Missing method (returns -32601) | Golden file test fails (no output vs expected) | +| Wrong result shape | Golden file byte comparison fails | +| Type extraction bug (e.g., array renders differently) | Integration test + golden file | +| Pool keying error (wrong database) | Multi-database integration tests | +| Session state lost in batch | Batch integration tests (temp tables, SET) | +| Startup script not executed | Dedicated integration test | +| BLOB not working | BLOB round-trip integration test | +| Enum CAST missing (silent data corruption) | CRUD integration test with enum type | +| SSL connection failure | SSL integration test | +| Performance regression | Benchmark suite (separate, optional) | + +--- + +## RPC Adapter Blockers + +Identical to the original plan. These 3 Tabularis core PRs are prerequisites: + +| Issue | Resolution | +| ----- | ---------- | +| BLOB methods not forwarded | Extend RpcDriver to forward `save_blob_to_file` / `fetch_blob_as_data_url` (base64 over JSON) | +| Materialized views not forwarded | Extend RpcDriver to forward 4 MV methods | +| `map_inferred_type` not forwarded | Plugin declares mappings at `initialize`; host applies locally | + +Additionally, the plugin must handle these internally: + +| Issue | Plugin-Side Resolution | +| ----- | --------------------- | +| Query cancellation | Implement `pg_cancel_backend()` or connection drop internally | +| `execute_query_batch` session state | Use single connection for entire batch | +| Startup script execution | `after_connect` hook in internal pool | +| 120s hard timeout | Document limitation; propose configurable timeout later | + +--- + +## Open Questions + +1. **Core PRs timing** — Should the 3 RpcDriver fixes be submitted before or + during Phase 0 development? They can be parallelized. + +2. **PR 402 merge dependency** — The multi-database frontend routing lives in + PR 402. If it hasn't merged by the time Phase 1 is ready, multi-database + testing can only be done at the RPC level (calling the plugin directly), not + through the full Tabularis UI. Is RPC-level verification sufficient for the + multi-db smoke tests? + +3. **Bundling strategy** — Should the plugin be bundled with Tabularis distribution + or installed from registry? + +4. **BLOB protocol** — Base64 over JSON (33% overhead) vs shared temp files? + +5. **Query cancellation** — Add a `cancel_query` RPC method to the protocol? + +6. **Plugin versioning** — Manifest field for minimum compatible Tabularis version? + +7. **Phase 0 parallelization** — Can Phase 0 test writing and Core PRs happen + simultaneously? (Yes — they touch different code.) + +--- + +## Comparison: This Plan vs. Original Phased Plan + +| Dimension | Original (5 phases) | This Alternative (4 phases, TDD) | +| --------- | ------------------- | -------------------------------- | +| Methodology | Build first, test after | Tests first, build to pass them (TDD) | +| Plugin code | Identical | Identical | +| Pool architecture | Same | Same | +| Test coverage | Multi-db added in Phase 2 | Multi-db tested from Phase 0 | +| Confidence in multi-db | Proven in Phase 2 | Proven in Phase 1 | +| Progress tracking | Checklist-based (subjective) | Test count (0/55 → 55/55, objective) | +| Regression detection | End-of-phase verification | Every sprint (previously-green must stay green) | +| Implementation order | Implicit (build everything, then test) | Explicit sprints, flexible ordering | +| Total effort | Same | Same (7 extra tests in Phase 0) | +| Risk of parity gap | Detected at end of Phase 1 | Detected immediately at each sprint | +| Simpler to explain | 5 phases with small Phase 2 | 4 phases, TDD-driven, each substantive | + +**Bottom line:** This plan is better because it gives continuous, objective proof +of progress and catches regressions at every step — not just at the end. The test +suite IS the specification. Implementation is done when all tests are green. From 895f9846edd354e7eb9e3c52dff753fad38fdbf0 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 14:42:03 -0400 Subject: [PATCH 03/10] Adapt manifest and Cargo.toml for standalone repo - Cargo.toml: add the missing license field (Apache-2.0), matching every sibling plugin repo. - .tabularium: change name from postgres-plugin to postgresql and drop the redundant id field. This is a permanent registry slug once published, and postgresql matches this repo's own install-path and executable naming (already postgresql/postgresql-plugin throughout the README) as well as the sibling-plugin convention of a bare engine-name slug with no separate id field. - README.md/CLAUDE.md/CHANGELOG.md: update status now that the source has landed as a parallel copy, note the CP-4 gate hasn't formally closed, and point the migration-plan link at the now-local docs/planning/ copy. --- .tabularium | 3 +-- CHANGELOG.md | 23 ++++++++++++++++++++--- CLAUDE.md | 47 +++++++++++++++++++++++++++++++++++++++-------- Cargo.toml | 1 + README.md | 16 ++++++++-------- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/.tabularium b/.tabularium index 17e1bee..580e30f 100644 --- a/.tabularium +++ b/.tabularium @@ -1,7 +1,6 @@ { "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", - "id": "postgres-plugin", - "name": "postgres-plugin", + "name": "postgresql", "version": "0.1.0", "description": "PostgreSQL plugin driver for Tabularis (parity implementation)", "kind": "driver", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d05d9a..976a92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### Added +- Plugin source (`Cargo.toml`, `Cargo.lock`, `.tabularium`, `src/`) imported + from `TabularisDB/tabularis`'s `plugins/postgres-plugin/` at commit + `ad765f3a` (82/82 parity tests green per that commit). This is a parallel + copy — the in-tree source has not been removed, and the two copies are + kept in sync manually pending a later decision to deprecate the in-tree + copy. +- `docs/planning/`: the 8 design documents that shaped this migration + (phase docs, both migration-plan variants, and the feature-gap audit + feeding Phase 2), copied from `tabularis`'s `.github/planning/`. +- `src/lib.rs` and `src/bin/test_plugin.rs`: extracted the plugin's module + tree into a library crate so the justfile's `repl` recipe (a local + JSON-RPC REPL) has a real binary to run, matching the oracle/dynamodb + sibling plugins' structure. - Repo scaffolding: `LICENSE` (Apache-2.0), `.gitignore`, `.editorconfig`, `CODEOWNERS`, `rust-toolchain.toml` (pinning `rustfmt`/`clippy`), `.github/dependabot.yml`, `justfile` (build/test/lint/fmt/dev-install/ @@ -15,6 +28,10 @@ - `README.md` and `CLAUDE.md` describing the plugin's purpose, architecture, and current migration status. -The plugin source itself (`Cargo.toml`, `src/`, `.tabularium`) has not been -migrated from `TabularisDB/tabularis`'s `plugins/postgres-plugin/` yet — this -release holds only the repo-level basics staged ahead of that migration. +### Changed + +- `.tabularium`'s `name` field changed from `postgres-plugin` to + `postgresql` (and the redundant `id` field dropped) to match this repo's + own install-path/executable naming and the sibling-plugin convention of a + bare engine-name slug. This field is a permanent registry slug once + published, so it was fixed before any release. diff --git a/CLAUDE.md b/CLAUDE.md index 3f5b8b5..37553f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,17 +2,19 @@ ## Status -This repo currently holds only repo-level scaffolding (license, CI/release -workflow shape, contributor docs). The plugin source itself -(`Cargo.toml`, `src/`, `.tabularium`) has not been migrated yet — it still -lives at `TabularisDB/tabularis`'s `plugins/postgres-plugin/`. Do not assume -the CI workflow passes or that a `cargo build` will succeed until that -migration lands. +The plugin source has landed here as a **parallel copy** of the in-tree +implementation at `TabularisDB/tabularis`'s `plugins/postgres-plugin/`, +which remains the source of truth for now — nothing has been removed from +there, and the two copies are kept in sync manually until a later, separate +decision to deprecate the in-tree copy (see `docs/planning/ +04-phase-3-deprecate-builtin.md`). The org's CP-4 beta-release gate (80/80 +parity, 72 baseline, 26 golden, manual smoke, core-team sync — see +`docs/planning/02-phase-1-plugin-build.md`) has not formally closed even +though the latest source commit claims 82/82 parity; treat that as +proceeding ahead of the documented trigger point, not as the gate being met. ## Build & Test -Once the source has been migrated: - ```bash cargo build # Debug build cargo build --release # Release build @@ -21,6 +23,35 @@ cargo clippy --all-targets -- -D warnings # Lint cargo fmt --all # Format ``` +## Cross-Repo Parity Check + +This repo has no live-database parity suite of its own — the 82-test +byte-for-byte comparison against the built-in driver lives in +`tabularis`'s `src-tauri/tests/postgres_integration/parity*.rs` and is not +duplicated here (see `docs/planning/02-phase-1-plugin-build.md`'s "Repo +Extraction" section for the open question on where those tests should live +long-term). That suite resolves the plugin binary purely through the +`POSTGRES_PLUGIN_BIN` env var, so it can validate this repo's binary with +zero changes on the `tabularis` side. Re-run this any time this repo's +source diverges from the in-tree copy, to catch parity drift immediately: + +```bash +# 1. Build the release binary from this repo +cargo build --release +STANDALONE_BIN="$PWD/target/release/postgresql-plugin" + +# 2. Point tabularis's existing (unmodified) parity suite at it +cd /path/to/tabularis +bash tests/fixtures/seed_postgres.sh +POSTGRES_PLUGIN_BIN="$STANDALONE_BIN" RUST_TEST_THREADS=1 \ + cargo test --manifest-path src-tauri/Cargo.toml --test postgres_integration parity -- --include-ignored +``` + +Expected result: the same 82/82 that the in-tree binary produces. Any test +going RED here means the extraction changed behavior and must be fixed +before merging — the same red→green discipline used throughout the +migration, now applied across the repo boundary. + ## Architecture Rules - All RPC handlers are async and return `serde_json::Value`. diff --git a/Cargo.toml b/Cargo.toml index 3b7da35..cfc35cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "postgresql-plugin" version = "0.1.0" edition = "2021" description = "PostgreSQL plugin driver for Tabularis" +license = "Apache-2.0" publish = false [[bin]] diff --git a/README.md b/README.md index 78cb4d9..520bd84 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ directly into the Tabularis application. It is byte-for-byte behaviorally identical to that built-in driver, proven by an 82-test parity suite that runs both drivers against the same live database and compares every response. -> ⚠️ **Work in progress** — this repo currently holds only the repo-level -> basics (license, CI/release workflow shape, contributor docs). The actual -> plugin source (`Cargo.toml`, `src/`, `.tabularium` manifest) has not been -> migrated yet — it still lives at -> [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin) -> pending the CP-4 extraction (see -> [the migration plan](https://github.com/TabularisDB/tabularis/blob/main/.github/planning/postgres-plugin/02-phase-1-plugin-build.md#repo-extraction--timing-and-open-question)). -> The CI workflow in this repo will not pass until that source lands. +> ⚠️ **Work in progress** — the plugin source has landed here as a +> parallel copy of the in-tree implementation at +> [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin), +> which remains the source of truth for now — nothing has been removed +> from there yet, and the two copies are kept in sync manually. See +> [the migration plan](./docs/planning/02-phase-1-plugin-build.md#repo-extraction--timing-and-open-question) +> for background on the extraction timing and the CP-4 beta-release gate, +> which has not yet formally closed. ## Table of Contents From c21a11944ac0e4c75250660fb832c5c84585b03e Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 14:51:42 -0400 Subject: [PATCH 04/10] Extract lib.rs, add test_plugin REPL binary, fix pre-existing lint gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural change: main.rs was bin-only with no lib.rs, so the justfile's repl recipe (cargo run --bin test_plugin) had no binary to run. Moved the module tree into src/lib.rs (pub mod ...) following the oracle/dynamodb sibling plugins' pattern, reduced main.rs to the stdio loop, and added src/bin/test_plugin.rs — a local REPL adapted from oracle's version but using #[tokio::main] since this crate's rpc::handle_line is async. Also fixes 11 pre-existing clippy warnings (redundant closures, manual split_once, unnecessary map_or/is_some_and, one #[allow(too_many_arguments)] on a plain SQL-string builder) and applies cargo fmt --all. Confirmed both issues are pre-existing in tabularis's in-tree copy too (same commands, same failures) — this repo's ci.yml is simply the first CI to actually run clippy/fmt --check against this source, since pg-integration.yml only runs cargo build/test. Verified: cargo build, cargo test (72/72 unit tests), cargo clippy --all-targets -- -D warnings, and cargo fmt --all -- --check all pass. Manually smoke-tested test_plugin via a bare method name. --- Cargo.toml | 4 + src/bin/test_plugin.rs | 56 ++++++++++ src/binding.rs | 34 +++--- src/binding_tests.rs | 8 +- src/client.rs | 9 +- src/client_tests.rs | 6 +- src/extract.rs | 81 ++++++++------ src/handlers/blob.rs | 29 +++-- src/handlers/connection.rs | 4 +- src/handlers/crud.rs | 53 +++++++-- src/handlers/ddl.rs | 130 +++++++++++++++++----- src/handlers/ddl_tests.rs | 16 ++- src/handlers/metadata.rs | 213 +++++++++++++++++++++++++++++-------- src/handlers/query.rs | 33 ++++-- src/lib.rs | 19 ++++ src/main.rs | 12 +-- src/models.rs | 1 - src/rpc.rs | 20 +++- 18 files changed, 559 insertions(+), 169 deletions(-) create mode 100644 src/bin/test_plugin.rs create mode 100644 src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cfc35cf..fc02bf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ publish = false name = "postgresql-plugin" path = "src/main.rs" +[[bin]] +name = "test_plugin" +path = "src/bin/test_plugin.rs" + [dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } diff --git a/src/bin/test_plugin.rs b/src/bin/test_plugin.rs new file mode 100644 index 0000000..3e5157f --- /dev/null +++ b/src/bin/test_plugin.rs @@ -0,0 +1,56 @@ +//! Local REPL that drives the real JSON-RPC dispatch over stdio. +//! +//! Usage: `just repl` (or `cargo run --bin test_plugin`). +//! +//! Two input modes: +//! * A full JSON-RPC object (e.g. `{"method":"get_tables","params":{...}}`) +//! is forwarded verbatim to the dispatcher. +//! * A bare method name (e.g. `get_schemas`) is wrapped in a stub request, +//! handy for quickly hitting static methods. +//! +//! Type `exit` / `quit` or press Ctrl-D to leave. + +use std::io::{self, BufRead, Write}; + +use postgresql_plugin::rpc::handle_line; +use serde_json::json; + +#[tokio::main] +async fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut out = stdout.lock(); + + println!("test_plugin — enter a JSON-RPC request or a bare method name. `exit` to quit."); + + let mut next_id: u64 = 1; + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + let cmd = line.trim(); + if cmd.is_empty() { + continue; + } + if cmd == "exit" || cmd == "quit" { + break; + } + + let request_line = if cmd.starts_with('{') { + cmd.to_string() + } else { + let request = json!({ + "jsonrpc": "2.0", + "method": cmd, + "params": { "params": {}, "schema": null, "query": "" }, + "id": next_id, + }); + next_id += 1; + request.to_string() + }; + + let response = handle_line(&request_line).await; + let pretty = + serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()); + writeln!(out, "{pretty}").ok(); + out.flush().ok(); + } +} diff --git a/src/binding.rs b/src/binding.rs index 96be353..8505ff9 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -68,7 +68,11 @@ pub fn bind_pg_value( // mismatch for json/jsonb columns. if let Some(ref bt) = base_type { if (bt == "JSON" || bt == "JSONB") && !matches!(value, Value::String(_) | Value::Null) { - let ty = if bt == "JSONB" { Type::JSONB } else { Type::JSON }; + let ty = if bt == "JSONB" { + Type::JSONB + } else { + Type::JSON + }; return Ok(BoundValue { sql: format!("${}", placeholder_idx), param: Some((Box::new(value), ty)), @@ -169,18 +173,18 @@ fn bind_pg_string( match bt { "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" | "SERIAL" | "BIGSERIAL" => { - let i: i64 = s - .parse() - .map_err(|_| format!("Cannot bind '{}' as integer for target type {}", s, bt))?; + let i: i64 = s.parse().map_err(|_| { + format!("Cannot bind '{}' as integer for target type {}", s, bt) + })?; return Ok(BoundValue { sql: format!("CAST(${} AS bigint)", placeholder_idx), param: Some((Box::new(i), Type::INT8)), }); } "NUMERIC" | "DECIMAL" => { - let d: Decimal = s - .parse() - .map_err(|_| format!("Cannot bind '{}' as numeric for target type {}", s, bt))?; + let d: Decimal = s.parse().map_err(|_| { + format!("Cannot bind '{}' as numeric for target type {}", s, bt) + })?; return Ok(BoundValue { sql: format!("CAST(${} AS numeric)", placeholder_idx), param: Some((Box::new(d), Type::NUMERIC)), @@ -268,8 +272,8 @@ fn bind_pg_enum_string(s: &str, qualified_enum: &str, placeholder_idx: usize) -> fn decode_blob_wire_format(value: &str) -> Option> { let rest = value.strip_prefix("BLOB:")?; // Skip the size field, then the mime field. - let after_size = rest.splitn(2, ':').nth(1)?; - let base64_data = after_size.splitn(2, ':').nth(1)?; + let (_, after_size) = rest.split_once(':')?; + let (_, base64_data) = after_size.split_once(':')?; base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data).ok() } @@ -281,7 +285,13 @@ fn json_array_to_pg_literal(arr: &[Value]) -> Result { let part = match elem { Value::String(s) => format!("'{}'", s.replace('\'', "''")), Value::Number(n) => n.to_string(), - Value::Bool(b) => if *b { "TRUE".to_string() } else { "FALSE".to_string() }, + Value::Bool(b) => { + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } Value::Null => "NULL".to_string(), Value::Array(nested) => json_array_to_pg_literal(nested)?, Value::Object(_) => return Err("Unsupported array element type".to_string()), @@ -320,7 +330,7 @@ pub fn bind_pk_value( } } Value::String(s) => { - let is_uuid_type = base_type.as_deref().map_or(true, |t| t == "UUID"); + let is_uuid_type = base_type.as_deref().is_none_or(|t| t == "UUID"); if is_uuid_type { if let Ok(uuid) = s.parse::() { return Ok(BoundValue { @@ -330,7 +340,7 @@ pub fn bind_pk_value( } } - let is_int_type = base_type.as_deref().map_or(true, |t| { + let is_int_type = base_type.as_deref().is_none_or(|t| { matches!( t, "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" diff --git a/src/binding_tests.rs b/src/binding_tests.rs index 278467d..6deca50 100644 --- a/src/binding_tests.rs +++ b/src/binding_tests.rs @@ -144,12 +144,8 @@ mod bind_pg_value_tests { enum_type: Some("\"public\".\"status\""), allow_default: false, }; - let bound = bind_pg_value( - json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), - 1, - &options, - ) - .unwrap(); + let bound = + bind_pg_value(json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), 1, &options).unwrap(); assert_eq!(bound.sql, "CAST($1 AS \"public\".\"status\")"); } diff --git a/src/client.rs b/src/client.rs index 58d7e08..a08c267 100644 --- a/src/client.rs +++ b/src/client.rs @@ -231,14 +231,18 @@ fn get_or_create_pool(params: &ConnectionParams) -> Result { let key = connection_key(params); { - let pools = POOLS.lock().map_err(|_| "pool cache lock poisoned".to_string())?; + let pools = POOLS + .lock() + .map_err(|_| "pool cache lock poisoned".to_string())?; if let Some(pool) = pools.get(&key) { return Ok(pool.clone()); } } let pool = build_pool(params)?; - let mut pools = POOLS.lock().map_err(|_| "pool cache lock poisoned".to_string())?; + let mut pools = POOLS + .lock() + .map_err(|_| "pool cache lock poisoned".to_string())?; // Another call may have raced us to create this pool between the read // above and this write — keep whichever is already cached. Ok(pools.entry(key).or_insert(pool).clone()) @@ -288,4 +292,3 @@ fn build_tls_connector() -> Result { #[cfg(test)] #[path = "client_tests.rs"] mod client_tests; - diff --git a/src/client_tests.rs b/src/client_tests.rs index 1dbccf6..18b5b57 100644 --- a/src/client_tests.rs +++ b/src/client_tests.rs @@ -67,7 +67,11 @@ fn get_or_create_pool_reuses_cached_entry_for_identical_params() { let before = POOLS.lock().unwrap().len(); get_or_create_pool(&p).expect("first call creates and caches a pool"); let after_first = POOLS.lock().unwrap().len(); - assert_eq!(after_first, before + 1, "first call should insert one entry"); + assert_eq!( + after_first, + before + 1, + "first call should insert one entry" + ); assert!(POOLS.lock().unwrap().contains_key(&key)); get_or_create_pool(&p).expect("second call should hit the cache"); diff --git a/src/extract.rs b/src/extract.rs index 96ea28c..cf6279e 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -21,10 +21,10 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { // NULL check: try to get as Option first match col_type { - ref t if *t == Type::BOOL => try_extract::(row, index, |v| JsonValue::Bool(v)), - ref t if *t == Type::INT2 => try_extract::(row, index, |v| JsonValue::from(v)), - ref t if *t == Type::INT4 => try_extract::(row, index, |v| JsonValue::from(v)), - ref t if *t == Type::INT8 => try_extract::(row, index, |v| i64_to_json(v)), + ref t if *t == Type::BOOL => try_extract::(row, index, JsonValue::Bool), + ref t if *t == Type::INT2 => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INT4 => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INT8 => try_extract::(row, index, i64_to_json), ref t if *t == Type::FLOAT4 => try_extract::(row, index, |v| { serde_json::Number::from_f64(v as f64) .map(JsonValue::Number) @@ -35,15 +35,20 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { .map(JsonValue::Number) .unwrap_or(JsonValue::Null) }), - ref t if *t == Type::NUMERIC => try_extract::(row, index, |v| { - JsonValue::String(v.to_string()) - }), - ref t if *t == Type::TEXT || *t == Type::VARCHAR || *t == Type::BPCHAR || *t == Type::NAME => { + ref t if *t == Type::NUMERIC => { + try_extract::(row, index, |v| JsonValue::String(v.to_string())) + } + ref t + if *t == Type::TEXT + || *t == Type::VARCHAR + || *t == Type::BPCHAR + || *t == Type::NAME => + { try_extract::(row, index, JsonValue::String) } - ref t if *t == Type::UUID => try_extract::(row, index, |v| { - JsonValue::String(v.to_string()) - }), + ref t if *t == Type::UUID => { + try_extract::(row, index, |v| JsonValue::String(v.to_string())) + } ref t if *t == Type::DATE => try_extract::(row, index, |v| { JsonValue::String(v.format("%Y-%m-%d").to_string()) }), @@ -65,19 +70,20 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { } ref t if *t == Type::BYTEA => try_extract::>(row, index, |v| { let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v); - JsonValue::String(format!( - "BLOB:{}:application/octet-stream:{}", - v.len(), - b64 - )) + JsonValue::String(format!("BLOB:{}:application/octet-stream:{}", v.len(), b64)) }), ref t if *t == Type::INET || *t == Type::CIDR => { try_extract::(row, index, JsonValue::from) } ref t if *t == Type::MACADDR => try_extract::(row, index, JsonValue::from), - ref t if *t == Type::OID => try_extract::(row, index, |v| JsonValue::from(v)), - ref t if *t == Type::INT4_RANGE || *t == Type::INT8_RANGE || *t == Type::NUM_RANGE - || *t == Type::TS_RANGE || *t == Type::TSTZ_RANGE || *t == Type::DATE_RANGE => + ref t if *t == Type::OID => try_extract::(row, index, JsonValue::from), + ref t + if *t == Type::INT4_RANGE + || *t == Type::INT8_RANGE + || *t == Type::NUM_RANGE + || *t == Type::TS_RANGE + || *t == Type::TSTZ_RANGE + || *t == Type::DATE_RANGE => { try_extract_range(row, index) } @@ -144,11 +150,7 @@ fn i64_to_json(v: i64) -> JsonValue { /// Helper: try to extract a typed value from the row, returning JsonValue::Null /// on any failure (NULL column, type mismatch, etc.). -fn try_extract<'a, T>( - row: &'a Row, - index: usize, - map: impl FnOnce(T) -> JsonValue, -) -> JsonValue +fn try_extract<'a, T>(row: &'a Row, index: usize, map: impl FnOnce(T) -> JsonValue) -> JsonValue where T: tokio_postgres::types::FromSql<'a>, { @@ -184,7 +186,10 @@ fn try_extract_range(row: &Row, index: usize) -> JsonValue { struct RangeValue(String); impl<'a> FromSql<'a> for RangeValue { - fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { + fn from_sql( + ty: &Type, + raw: &'a [u8], + ) -> Result> { let subtype = match ty.kind() { Kind::Range(t) => t.clone(), _ => return Err("expected a range type".into()), @@ -275,8 +280,12 @@ fn extract_range_bound(subtype: &Type, buf: &mut &[u8]) -> Option { /// numeric, date/timestamp). Falls back to Null for anything else. fn extract_simple_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue { match *ty { - Type::INT4 => i32::from_sql(ty, buf).map(JsonValue::from).unwrap_or(JsonValue::Null), - Type::INT8 => i64::from_sql(ty, buf).map(i64_to_json).unwrap_or(JsonValue::Null), + Type::INT4 => i32::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + Type::INT8 => i64::from_sql(ty, buf) + .map(i64_to_json) + .unwrap_or(JsonValue::Null), Type::NUMERIC => Decimal::from_sql(ty, buf) .map(|v| JsonValue::String(v.to_string())) .unwrap_or(JsonValue::Null), @@ -440,15 +449,27 @@ impl From for JsonValue { let mut s = String::new(); if v.years != 0 { - let unit = if v.years == 1 || v.years == -1 { "year" } else { "years" }; + let unit = if v.years == 1 || v.years == -1 { + "year" + } else { + "years" + }; s.push_str(&format!("{} {} ", v.years, unit)); } if v.months != 0 { - let unit = if v.months == 1 || v.months == -1 { "month" } else { "months" }; + let unit = if v.months == 1 || v.months == -1 { + "month" + } else { + "months" + }; s.push_str(&format!("{} {} ", v.months, unit)); } if v.days != 0 { - let unit = if v.days == 1 || v.days == -1 { "day" } else { "days" }; + let unit = if v.days == 1 || v.days == -1 { + "day" + } else { + "days" + }; s.push_str(&format!("{} {} ", v.days, unit)); } if v.hours != 0 || v.minutes != 0 || v.seconds != 0 || v.microseconds != 0 { diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs index 96e614f..4f40819 100644 --- a/src/handlers/blob.rs +++ b/src/handlers/blob.rs @@ -18,8 +18,14 @@ pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); - let file_path = params.get("file_path").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let file_path = params + .get("file_path") + .and_then(Value::as_str) + .unwrap_or(""); let pk_map = params .get("pk_map") .and_then(Value::as_object) @@ -39,7 +45,10 @@ pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let pk_map = params .get("pk_map") .and_then(Value::as_object) @@ -59,8 +68,14 @@ async fn fetch_blob_bytes( pk_map: &serde_json::Map, schema: &str, ) -> Result, String> { - let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); - let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; let query = format!( @@ -86,7 +101,9 @@ async fn fetch_blob_bytes( /// `application/octet-stream`. Matches `encode_blob_full` in /// `src-tauri/src/drivers/common/blob.rs`. fn encode_blob_full(data: &[u8]) -> String { - let mime_type = infer::get(data).map(|k| k.mime_type()).unwrap_or("application/octet-stream"); + let mime_type = infer::get(data) + .map(|k| k.mime_type()) + .unwrap_or("application/octet-stream"); let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data); format!("BLOB:{}:{}:{}", data.len(), mime_type, b64) } diff --git a/src/handlers/connection.rs b/src/handlers/connection.rs index b5d2385..27d590d 100644 --- a/src/handlers/connection.rs +++ b/src/handlers/connection.rs @@ -2,9 +2,9 @@ use serde_json::Value; -use crate::rpc::{ok_response, error_response}; -use crate::models::{ConnectionParams, inner_params}; use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; /// Receive plugin settings from the host. Currently a no-op. pub async fn initialize(id: Value, _params: &Value) -> Value { diff --git a/src/handlers/crud.rs b/src/handlers/crud.rs index b8d4c45..2449872 100644 --- a/src/handlers/crud.rs +++ b/src/handlers/crud.rs @@ -16,7 +16,10 @@ use crate::rpc::{error_response, ok_response}; pub async fn insert_record(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let data = params .get("data") .and_then(Value::as_object) @@ -35,7 +38,11 @@ async fn exec_insert( data: serde_json::Map, schema: &str, ) -> Result { - let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); // Stable column order: iterate the map once into a Vec (matches the // builtin's "lock in an arbitrary-but-consistent order" behavior). @@ -46,8 +53,12 @@ async fn exec_insert( return client::execute_typed(conn_params, &query, &[]).await; } - let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); - let enum_types = client::get_enum_column_types(conn_params, schema, table).await.unwrap_or_default(); + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table) + .await + .unwrap_or_default(); let mut cols: Vec = Vec::with_capacity(entries.len()); let mut sql_fragments: Vec = Vec::with_capacity(entries.len()); @@ -88,7 +99,10 @@ async fn exec_insert( pub async fn update_record(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); let new_val = params.get("new_val").cloned().unwrap_or(Value::Null); let pk_map = params @@ -111,10 +125,18 @@ async fn exec_update( new_val: Value, schema: &str, ) -> Result { - let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); - let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); - let enum_types = client::get_enum_column_types(conn_params, schema, table).await.unwrap_or_default(); + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table) + .await + .unwrap_or_default(); let options = BindOptions { column_type: column_types.get(col_name).map(String::as_str), @@ -152,7 +174,10 @@ async fn exec_update( pub async fn delete_record(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let pk_map = params .get("pk_map") .and_then(Value::as_object) @@ -171,9 +196,15 @@ async fn exec_delete( pk_map: &serde_json::Map, schema: &str, ) -> Result { - let qualified = format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), table.replace('"', "\"\"")); + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); - let column_types = client::get_column_types_map(conn_params, table, schema).await.unwrap_or_default(); + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; diff --git a/src/handlers/ddl.rs b/src/handlers/ddl.rs index 928c3b3..27d4a3d 100644 --- a/src/handlers/ddl.rs +++ b/src/handlers/ddl.rs @@ -16,32 +16,50 @@ use crate::rpc::{error_response, ok_response}; use crate::utils::identifiers::qualified; pub async fn get_create_table_sql(id: Value, params: &Value) -> Value { - let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let columns: Vec = params .get("columns") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); - ok_response(id, Value::from(vec![build_create_table_sql(table_name, &columns, schema)])) + ok_response( + id, + Value::from(vec![build_create_table_sql(table_name, &columns, schema)]), + ) } pub async fn get_add_column_sql(id: Value, params: &Value) -> Value { let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let column: Option = params .get("column") .and_then(|v| serde_json::from_value(v.clone()).ok()); match column { - Some(column) => ok_response(id, Value::from(vec![build_add_column_sql(table, &column, schema)])), + Some(column) => ok_response( + id, + Value::from(vec![build_add_column_sql(table, &column, schema)]), + ), None => error_response(id, -32602, "Invalid params: missing or malformed 'column'"), } } pub async fn get_alter_column_sql(id: Value, params: &Value) -> Value { let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let old_column: Option = params .get("old_column") .and_then(|v| serde_json::from_value(v.clone()).ok()); @@ -56,15 +74,28 @@ pub async fn get_alter_column_sql(id: Value, params: &Value) -> Value { Err(e) => error_response(id, -32603, &e), } } - _ => error_response(id, -32602, "Invalid params: missing or malformed old_column/new_column"), + _ => error_response( + id, + -32602, + "Invalid params: missing or malformed old_column/new_column", + ), } } pub async fn get_create_index_sql(id: Value, params: &Value) -> Value { let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let index_name = params.get("index_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); - let is_unique = params.get("is_unique").and_then(Value::as_bool).unwrap_or(false); + let index_name = params + .get("index_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let is_unique = params + .get("is_unique") + .and_then(Value::as_bool) + .unwrap_or(false); let columns: Vec = params .get("columns") .and_then(|v| serde_json::from_value(v.clone()).ok()) @@ -72,7 +103,9 @@ pub async fn get_create_index_sql(id: Value, params: &Value) -> Value { ok_response( id, - Value::from(vec![build_create_index_sql(table, index_name, &columns, is_unique, schema)]), + Value::from(vec![build_create_index_sql( + table, index_name, &columns, is_unique, schema, + )]), ) } @@ -80,11 +113,20 @@ pub async fn get_create_foreign_key_sql(id: Value, params: &Value) -> Value { let table = params.get("table").and_then(Value::as_str).unwrap_or(""); let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); let column = params.get("column").and_then(Value::as_str).unwrap_or(""); - let ref_table = params.get("ref_table").and_then(Value::as_str).unwrap_or(""); - let ref_column = params.get("ref_column").and_then(Value::as_str).unwrap_or(""); + let ref_table = params + .get("ref_table") + .and_then(Value::as_str) + .unwrap_or(""); + let ref_column = params + .get("ref_column") + .and_then(Value::as_str) + .unwrap_or(""); let on_delete = params.get("on_delete").and_then(Value::as_str); let on_update = params.get("on_update").and_then(Value::as_str); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); ok_response( id, @@ -96,8 +138,14 @@ pub async fn get_create_foreign_key_sql(id: Value, params: &Value) -> Value { pub async fn drop_index(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let index_name = params.get("index_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let index_name = params + .get("index_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!("DROP INDEX {}", qualified(schema, index_name)); match client::execute_typed(&conn_params, &query, &[]).await { @@ -110,7 +158,10 @@ pub async fn drop_foreign_key(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "ALTER TABLE {} DROP CONSTRAINT \"{}\"", @@ -199,7 +250,12 @@ fn build_add_column_sql(table: &str, column: &ColumnDefinition, schema: &str) -> /// Normalize a data type string for cast-compatibility comparison: /// strip a trailing `(...)` and uppercase. E.g. `"varchar(255)"` -> `"VARCHAR"`. fn extract_base_type(data_type: &str) -> String { - data_type.split('(').next().unwrap_or(data_type).trim().to_uppercase() + data_type + .split('(') + .next() + .unwrap_or(data_type) + .trim() + .to_uppercase() } /// Whether an ALTER COLUMN TYPE from `old_type` to `new_type` can rely on @@ -210,7 +266,14 @@ fn is_implicit_cast_compatible(old_type: &str, new_type: &str) -> bool { } const COMPATIBLE_GROUPS: &[&[&str]] = &[ - &["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], + &[ + "SMALLINT", + "INTEGER", + "BIGINT", + "SERIAL", + "BIGSERIAL", + "SMALLSERIAL", + ], &["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], &["CHAR", "VARCHAR", "TEXT", "NAME", "CITEXT"], &["TIMESTAMP", "TIMESTAMPTZ"], @@ -236,7 +299,10 @@ fn build_alter_column_sql( let mut stmts = Vec::new(); if old_column.name != new_column.name { - stmts.push(format!("ALTER TABLE {} RENAME COLUMN {} TO {}", tbl, old_name, new_name)); + stmts.push(format!( + "ALTER TABLE {} RENAME COLUMN {} TO {}", + tbl, old_name, new_name + )); } let col_ref = &new_name; @@ -260,9 +326,15 @@ fn build_alter_column_sql( if old_column.is_nullable != new_column.is_nullable { if new_column.is_nullable { - stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", tbl, col_ref)); + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", + tbl, col_ref + )); } else { - stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", tbl, col_ref)); + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", + tbl, col_ref + )); } } @@ -273,7 +345,10 @@ fn build_alter_column_sql( tbl, col_ref, default )); } else { - stmts.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", tbl, col_ref)); + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", + tbl, col_ref + )); } } @@ -283,7 +358,13 @@ fn build_alter_column_sql( Ok(stmts) } -fn build_create_index_sql(table: &str, index_name: &str, columns: &[String], is_unique: bool, schema: &str) -> String { +fn build_create_index_sql( + table: &str, + index_name: &str, + columns: &[String], + is_unique: bool, + schema: &str, +) -> String { let unique = if is_unique { "UNIQUE " } else { "" }; let cols: Vec = columns.iter().map(|c| quote_ident(c)).collect(); format!( @@ -295,6 +376,7 @@ fn build_create_index_sql(table: &str, index_name: &str, columns: &[String], is_ ) } +#[allow(clippy::too_many_arguments)] fn build_create_foreign_key_sql( table: &str, fk_name: &str, diff --git a/src/handlers/ddl_tests.rs b/src/handlers/ddl_tests.rs index 3d26c8d..5549b78 100644 --- a/src/handlers/ddl_tests.rs +++ b/src/handlers/ddl_tests.rs @@ -122,7 +122,8 @@ mod add_column { default_value: Some("0".to_string()), }; let sql = build_add_column_sql("all_types", &col, "test_schema"); - assert!(sql.contains("ALTER TABLE \"test_schema\".\"all_types\" ADD COLUMN \"new_col\" INTEGER")); + assert!(sql + .contains("ALTER TABLE \"test_schema\".\"all_types\" ADD COLUMN \"new_col\" INTEGER")); assert!(sql.contains("DEFAULT 0")); } } @@ -144,7 +145,9 @@ mod alter_column { let old = column("col_text", "TEXT"); let new = column("col_text", "VARCHAR(500)"); let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); - assert!(stmts.iter().any(|s| s.contains("TYPE VARCHAR(500)") && !s.contains("USING"))); + assert!(stmts + .iter() + .any(|s| s.contains("TYPE VARCHAR(500)") && !s.contains("USING"))); } #[test] @@ -275,7 +278,14 @@ mod create_foreign_key { #[test] fn on_delete_and_on_update_actions_are_appended() { let sql = build_create_foreign_key_sql( - "t", "fk", "c", "ref_t", "ref_c", Some("CASCADE"), Some("RESTRICT"), "public", + "t", + "fk", + "c", + "ref_t", + "ref_c", + Some("CASCADE"), + Some("RESTRICT"), + "public", ); assert!(sql.ends_with("ON DELETE CASCADE ON UPDATE RESTRICT")); } diff --git a/src/handlers/metadata.rs b/src/handlers/metadata.rs index 38aae90..bde49de 100644 --- a/src/handlers/metadata.rs +++ b/src/handlers/metadata.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use crate::client; -use crate::models::{ConnectionParams, inner_params}; +use crate::models::{inner_params, ConnectionParams}; use crate::rpc::{error_response, not_implemented, ok_response}; pub async fn get_databases(id: Value, params: &Value) -> Value { @@ -72,7 +72,10 @@ pub async fn get_tables(id: Value, params: &Value) -> Value { pub async fn get_columns(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT @@ -140,7 +143,9 @@ fn row_to_table_column(r: &tokio_postgres::Row) -> Value { }; let is_auto_increment = is_identity == "YES" - || column_default.as_deref().map_or(false, |d| d.contains("nextval")); + || column_default + .as_deref() + .is_some_and(|d| d.contains("nextval")); let is_nullable = is_nullable_str == "YES"; @@ -161,7 +166,9 @@ fn row_to_table_column(r: &tokio_postgres::Row) -> Value { }); if let Some(dv) = default_value { - col.as_object_mut().unwrap().insert("default_value".to_string(), json!(dv)); + col.as_object_mut() + .unwrap() + .insert("default_value".to_string(), json!(dv)); } if let Some(len) = char_max_len.and_then(|v| u64::try_from(v).ok()) { col.as_object_mut() @@ -175,7 +182,10 @@ fn row_to_table_column(r: &tokio_postgres::Row) -> Value { pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT @@ -250,7 +260,10 @@ pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { pub async fn get_indexes(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let table = params.get("table").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT @@ -312,7 +325,10 @@ pub async fn get_indexes(id: Value, params: &Value) -> Value { } pub async fn get_views(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); match client::query_strings( &conn_params, @@ -335,8 +351,14 @@ pub async fn get_views(id: Value, params: &Value) -> Value { pub async fn get_view_definition(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let qualified = crate::utils::identifiers::qualified(schema, view_name); @@ -362,8 +384,14 @@ pub async fn get_view_definition(id: Value, params: &Value) -> Value { pub async fn get_view_columns(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT @@ -412,9 +440,18 @@ pub async fn get_view_columns(id: Value, params: &Value) -> Value { pub async fn create_view(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let definition = params.get("definition").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "CREATE VIEW {} AS {}", @@ -429,9 +466,18 @@ pub async fn create_view(id: Value, params: &Value) -> Value { pub async fn alter_view(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let definition = params.get("definition").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "CREATE OR REPLACE VIEW {} AS {}", @@ -446,8 +492,14 @@ pub async fn alter_view(id: Value, params: &Value) -> Value { pub async fn drop_view(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "DROP VIEW IF EXISTS {}", @@ -461,7 +513,10 @@ pub async fn drop_view(id: Value, params: &Value) -> Value { pub async fn get_materialized_views(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); match client::query_strings( &conn_params, @@ -484,8 +539,14 @@ pub async fn get_materialized_views(id: Value, params: &Value) -> Value { pub async fn get_materialized_view_columns(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); // Materialized views are not exposed via information_schema.columns, so // their columns must be read from the system catalog. @@ -525,12 +586,20 @@ pub async fn get_materialized_view_columns(id: Value, params: &Value) -> Value { } } -pub async fn get_materialized_view_definition(id: Value, _params: &Value) -> Value { not_implemented(id, "get_materialized_view_definition") } +pub async fn get_materialized_view_definition(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_materialized_view_definition") +} pub async fn refresh_materialized_view(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let view_name = params.get("view_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "REFRESH MATERIALIZED VIEW {}", @@ -538,13 +607,20 @@ pub async fn refresh_materialized_view(id: Value, params: &Value) -> Value { ); match client::execute_typed(&conn_params, &query, &[]).await { Ok(_) => ok_response(id, Value::Null), - Err(e) => error_response(id, -32603, &format!("Failed to refresh materialized view: {}", e)), + Err(e) => error_response( + id, + -32603, + &format!("Failed to refresh materialized view: {}", e), + ), } } pub async fn get_routines(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); // PG 11+ uses prokind; older versions use proisagg/proiswindow flags. // CI runs PG 16, so we use the modern query. @@ -583,8 +659,14 @@ pub async fn get_routines(id: Value, params: &Value) -> Value { pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let routine_name = params.get("routine_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let routine_name = params + .get("routine_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let return_type_query = r#" SELECT data_type, routine_type @@ -592,7 +674,13 @@ pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { WHERE routine_schema = $1 AND routine_name = $2 LIMIT 1 "#; - let routine_info = match client::query_rows(&conn_params, return_type_query, &[&schema, &routine_name]).await { + let routine_info = match client::query_rows( + &conn_params, + return_type_query, + &[&schema, &routine_name], + ) + .await + { Ok(rows) => rows, Err(e) => return error_response(id, -32603, &e), }; @@ -603,7 +691,8 @@ pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { let routine_type: String = info.try_get("routine_type").unwrap_or_default(); if routine_type == "FUNCTION" { let data_type: String = info.try_get("data_type").unwrap_or_default(); - if !data_type.eq_ignore_ascii_case("void") && !data_type.eq_ignore_ascii_case("trigger") { + if !data_type.eq_ignore_ascii_case("void") && !data_type.eq_ignore_ascii_case("trigger") + { parameters.push(json!({ "name": "", "data_type": data_type, @@ -643,8 +732,14 @@ pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { pub async fn get_routine_definition(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let routine_name = params.get("routine_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let routine_name = params + .get("routine_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT pg_get_functiondef(p.oid) as definition @@ -668,7 +763,10 @@ pub async fn get_routine_definition(id: Value, params: &Value) -> Value { pub async fn get_triggers(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT @@ -708,9 +806,18 @@ pub async fn get_triggers(id: Value, params: &Value) -> Value { pub async fn get_trigger_definition(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let trigger_name = params.get("trigger_name").and_then(Value::as_str).unwrap_or(""); - let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let trigger_name = params + .get("trigger_name") + .and_then(Value::as_str) + .unwrap_or(""); + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = r#" SELECT pg_get_triggerdef(t.oid, true) AS definition @@ -738,7 +845,10 @@ pub async fn get_trigger_definition(id: Value, params: &Value) -> Value { pub async fn create_trigger(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let trigger_sql = params.get("trigger_sql").and_then(Value::as_str).unwrap_or(""); + let trigger_sql = params + .get("trigger_sql") + .and_then(Value::as_str) + .unwrap_or(""); match client::execute_typed(&conn_params, trigger_sql, &[]).await { Ok(_) => ok_response(id, Value::Null), @@ -748,9 +858,18 @@ pub async fn create_trigger(id: Value, params: &Value) -> Value { pub async fn drop_trigger(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); - let trigger_name = params.get("trigger_name").and_then(Value::as_str).unwrap_or(""); - let table_name = params.get("table_name").and_then(Value::as_str).unwrap_or(""); - let schema = params.get("schema").and_then(Value::as_str).unwrap_or("public"); + let trigger_name = params + .get("trigger_name") + .and_then(Value::as_str) + .unwrap_or(""); + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); let query = format!( "DROP TRIGGER IF EXISTS {} ON {}", @@ -763,6 +882,12 @@ pub async fn drop_trigger(id: Value, params: &Value) -> Value { } } -pub async fn get_schema_snapshot(id: Value, _params: &Value) -> Value { not_implemented(id, "get_schema_snapshot") } -pub async fn get_all_columns_batch(id: Value, _params: &Value) -> Value { not_implemented(id, "get_all_columns_batch") } -pub async fn get_all_foreign_keys_batch(id: Value, _params: &Value) -> Value { not_implemented(id, "get_all_foreign_keys_batch") } +pub async fn get_schema_snapshot(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_schema_snapshot") +} +pub async fn get_all_columns_batch(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_all_columns_batch") +} +pub async fn get_all_foreign_keys_batch(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_all_foreign_keys_batch") +} diff --git a/src/handlers/query.rs b/src/handlers/query.rs index 030ff4d..ab2c327 100644 --- a/src/handlers/query.rs +++ b/src/handlers/query.rs @@ -6,13 +6,16 @@ use std::time::Instant; use crate::client; use crate::extract::extract_value; -use crate::models::{ConnectionParams, inner_params}; +use crate::models::{inner_params, ConnectionParams}; use crate::rpc::{error_response, ok_response}; pub async fn execute_query(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let query = params.get("query").and_then(Value::as_str).unwrap_or(""); - let limit = params.get("limit").and_then(Value::as_u64).map(|v| v as u32); + let limit = params + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as u32); let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; let schema = params.get("schema").and_then(Value::as_str); @@ -27,9 +30,16 @@ pub async fn execute_query_batch(id: Value, params: &Value) -> Value { let queries: Vec = params .get("queries") .and_then(Value::as_array) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); - let limit = params.get("limit").and_then(Value::as_u64).map(|v| v as u32); + let limit = params + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as u32); let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; let schema = params.get("schema").and_then(Value::as_str); @@ -77,7 +87,10 @@ pub async fn execute_query_batch(id: Value, params: &Value) -> Value { pub async fn explain_query(id: Value, params: &Value) -> Value { let conn_params = ConnectionParams::from_value(inner_params(params)); let query = params.get("query").and_then(Value::as_str).unwrap_or(""); - let analyze = params.get("analyze").and_then(Value::as_bool).unwrap_or(false); + let analyze = params + .get("analyze") + .and_then(Value::as_bool) + .unwrap_or(false); let schema = params.get("schema").and_then(Value::as_str); let explain_sql = if analyze { @@ -119,10 +132,7 @@ async fn exec_query( // Set search_path if schema is specified if let Some(s) = schema { - let set_path = format!( - "SET search_path TO \"{}\"", - s.replace('"', "\"\"") - ); + let set_path = format!("SET search_path TO \"{}\"", s.replace('"', "\"\"")); pg_client .batch_execute(&set_path) .await @@ -172,7 +182,10 @@ async fn exec_query_on_client( if rows.is_empty() { // Get columns from the statement if possible let columns: Vec = if let Ok(stmt) = pg_client.prepare(&final_query).await { - stmt.columns().iter().map(|c| c.name().to_string()).collect() + stmt.columns() + .iter() + .map(|c| c.name().to_string()) + .collect() } else { vec![] }; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ebfb562 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +//! PostgreSQL driver plugin for Tabularis. +//! +//! The crate is split into a thin library (this file plus the modules below) +//! and two binaries: `postgresql-plugin` (the real stdio JSON-RPC server) and +//! `test_plugin` (a local REPL that drives the same dispatch code). Keeping +//! the logic in a library is what lets the REPL exercise the exact same code +//! path. +#![allow(dead_code)] + +pub mod binding; +#[cfg(test)] +mod binding_tests; +pub mod client; +pub mod error; +pub mod extract; +pub mod handlers; +pub mod models; +pub mod rpc; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index c77e61d..3d1fa45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,20 +5,10 @@ //! Reads newline-delimited JSON-RPC 2.0 requests from stdin and writes //! responses (one JSON object per line) to stdout. All handler logic is //! async (tokio) since the database pool requires an async runtime. -#![allow(dead_code)] use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; -mod binding; -#[cfg(test)] -mod binding_tests; -mod client; -mod error; -mod extract; -mod handlers; -mod models; -mod rpc; -mod utils; +use postgresql_plugin::rpc; #[tokio::main] async fn main() { diff --git a/src/models.rs b/src/models.rs index 95cf497..9b8aefb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -67,4 +67,3 @@ pub struct ColumnDefinition { pub is_auto_increment: bool, pub default_value: Option, } - diff --git a/src/rpc.rs b/src/rpc.rs index e30ae0b..1b24690 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -38,9 +38,15 @@ pub async fn handle_line(line: &str) -> Value { "get_view_definition" => handlers::metadata::get_view_definition(id, ¶ms).await, "get_view_columns" => handlers::metadata::get_view_columns(id, ¶ms).await, "get_materialized_views" => handlers::metadata::get_materialized_views(id, ¶ms).await, - "get_materialized_view_columns" => handlers::metadata::get_materialized_view_columns(id, ¶ms).await, - "get_materialized_view_definition" => handlers::metadata::get_materialized_view_definition(id, ¶ms).await, - "refresh_materialized_view" => handlers::metadata::refresh_materialized_view(id, ¶ms).await, + "get_materialized_view_columns" => { + handlers::metadata::get_materialized_view_columns(id, ¶ms).await + } + "get_materialized_view_definition" => { + handlers::metadata::get_materialized_view_definition(id, ¶ms).await + } + "refresh_materialized_view" => { + handlers::metadata::refresh_materialized_view(id, ¶ms).await + } "get_routines" => handlers::metadata::get_routines(id, ¶ms).await, "get_routine_parameters" => handlers::metadata::get_routine_parameters(id, ¶ms).await, "get_routine_definition" => handlers::metadata::get_routine_definition(id, ¶ms).await, @@ -48,7 +54,9 @@ pub async fn handle_line(line: &str) -> Value { "get_trigger_definition" => handlers::metadata::get_trigger_definition(id, ¶ms).await, "get_schema_snapshot" => handlers::metadata::get_schema_snapshot(id, ¶ms).await, "get_all_columns_batch" => handlers::metadata::get_all_columns_batch(id, ¶ms).await, - "get_all_foreign_keys_batch" => handlers::metadata::get_all_foreign_keys_batch(id, ¶ms).await, + "get_all_foreign_keys_batch" => { + handlers::metadata::get_all_foreign_keys_batch(id, ¶ms).await + } // View mutation "create_view" => handlers::metadata::create_view(id, ¶ms).await, @@ -72,7 +80,9 @@ pub async fn handle_line(line: &str) -> Value { "get_add_column_sql" => handlers::ddl::get_add_column_sql(id, ¶ms).await, "get_alter_column_sql" => handlers::ddl::get_alter_column_sql(id, ¶ms).await, "get_create_index_sql" => handlers::ddl::get_create_index_sql(id, ¶ms).await, - "get_create_foreign_key_sql" => handlers::ddl::get_create_foreign_key_sql(id, ¶ms).await, + "get_create_foreign_key_sql" => { + handlers::ddl::get_create_foreign_key_sql(id, ¶ms).await + } "drop_index" => handlers::ddl::drop_index(id, ¶ms).await, "drop_foreign_key" => handlers::ddl::drop_foreign_key(id, ¶ms).await, From 98a8edc864cd8e958543ec7d260b390b3f7a37fc Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 15:03:06 -0400 Subject: [PATCH 05/10] State intent for this repo to become the primary plugin home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once signed off, TabularisDB/tabularis PR #577 pivots from building the plugin in-tree to removing the built-in driver. Nothing changes in tabularis for now — this repo only receives additions until that decision is made. --- CLAUDE.md | 26 ++++++++++++++++---------- README.md | 18 ++++++++++-------- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37553f9..71fb529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,16 +2,22 @@ ## Status -The plugin source has landed here as a **parallel copy** of the in-tree -implementation at `TabularisDB/tabularis`'s `plugins/postgres-plugin/`, -which remains the source of truth for now — nothing has been removed from -there, and the two copies are kept in sync manually until a later, separate -decision to deprecate the in-tree copy (see `docs/planning/ -04-phase-3-deprecate-builtin.md`). The org's CP-4 beta-release gate (80/80 -parity, 72 baseline, 26 golden, manual smoke, core-team sync — see -`docs/planning/02-phase-1-plugin-build.md`) has not formally closed even -though the latest source commit claims 82/82 parity; treat that as -proceeding ahead of the documented trigger point, not as the gate being met. +This repo is intended to become the **primary home** for the PostgreSQL +plugin, pending sign-off. Once that happens, `TabularisDB/tabularis` PR #577 +pivots from building the plugin in-tree to removing the built-in driver — +see `docs/planning/04-phase-3-deprecate-builtin.md`. For now, leave +`tabularis`'s `plugins/postgres-plugin/` untouched; this repo only receives +additions, nothing is removed from there. + +The plugin source has landed here as a copy of the in-tree implementation +at commit `ad765f3a` (Phase 1 byte-for-byte parity proven: 82/82 parity, +72/72 baseline, 26/26 golden tests, 72 plugin unit tests — see +`docs/planning/02-phase-1-plugin-build.md` and +[`tabularis` PR #577](https://github.com/TabularisDB/tabularis/pull/577)). +Sign-off to promote this repo to primary is still pending several items +from that PR's own checklist: cross-platform build verification (only +macOS ARM confirmed so far), the 24-item manual smoke test, a security +audit pass, and a frontend regression check (`pnpm test` in `tabularis`). ## Build & Test diff --git a/README.md b/README.md index 520bd84..a400687 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,16 @@ directly into the Tabularis application. It is byte-for-byte behaviorally identical to that built-in driver, proven by an 82-test parity suite that runs both drivers against the same live database and compares every response. -> ⚠️ **Work in progress** — the plugin source has landed here as a -> parallel copy of the in-tree implementation at -> [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin), -> which remains the source of truth for now — nothing has been removed -> from there yet, and the two copies are kept in sync manually. See -> [the migration plan](./docs/planning/02-phase-1-plugin-build.md#repo-extraction--timing-and-open-question) -> for background on the extraction timing and the CP-4 beta-release gate, -> which has not yet formally closed. +> ⚠️ **Work in progress** — this repo is intended to become the **primary +> home** for the PostgreSQL plugin, pending sign-off. The plugin source has +> landed here (Phase 1 byte-for-byte parity proven: 82/82 parity, 72/72 +> baseline, 26/26 golden tests — see +> [the migration plan](./docs/planning/02-phase-1-plugin-build.md)), but +> the [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin) +> copy is left untouched for now. Once this repo is signed off as the beta +> release source of truth, [`tabularis` PR #577](https://github.com/TabularisDB/tabularis/pull/577) +> will pivot from building the plugin in-tree to removing the built-in +> driver. ## Table of Contents From d18781cc01ec5b8ee9338d95efaaf39a872ae11e Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 15:30:52 -0400 Subject: [PATCH 06/10] Fix startup_script and connection_string parity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A security-audit pass (checking the copied source against the org's Security Audit Checklist in docs/planning/02-phase-1-plugin-build.md) found two genuine gaps the 82/82 parity suite doesn't exercise: 1. startup_script was completely unimplemented — no field on ConnectionParams, no execution logic. The builtin driver runs a caller-supplied SQL script on every new pooled connection via a deadpool post_create hook, with a preflight validation pass for clear error attribution (src-tauri/src/pool_manager.rs). Ported that pattern: build_pool() now preflights the script on a throwaway transaction (rolled back) before attaching it as a post_create hook, and the pool cache key folds in the script so editing it forces a fresh pool. Verified against a live PostgreSQL instance: SET search_path persists on the pooled connection, and a broken script fails fast with "Startup script failed: ..." rather than a misleading connection error. 2. connection_string was parsed into ConnectionParams but never consumed by build_pool() — despite being documented in the README and declared in .tabularium. Now parsed via tokio_postgres::Config::from_str and takes precedence over the discrete fields when present, matching the documented "alternative to the discrete fields above" behavior. Verified: a full postgres:// URL connects with no discrete fields; a malformed one returns a clean error instead of crashing; an empty string correctly falls back to discrete fields. Also corrected the README's ssl_ca/ssl_cert/ssl_key documentation — only ssl_ca (custom CA pinning for verify-ca/verify-full) is actually implemented, matching the builtin PostgreSQL driver, which likewise has no PG client-certificate support (that only exists in the builtin's MySQL driver). Re-ran the full verification suite after these changes: cargo build/test (72/72)/clippy/fmt all pass, and — critically — re-ran tabularis's unmodified 82-test parity suite against the rebuilt standalone binary via POSTGRES_PLUGIN_BIN: still 82/82 GREEN, confirming zero regression. --- CHANGELOG.md | 20 ++++ Cargo.lock | 10 ++ Cargo.toml | 1 + README.md | 3 +- src/client.rs | 212 +++++++++++++++++++++++++++++++++++++----- src/client_tests.rs | 20 ++-- src/handlers/query.rs | 4 +- src/models.rs | 2 + 8 files changed, 239 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 976a92d..4aa0231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ### Added +- `startup_script` support: SQL supplied on the connection now runs on every + new pooled connection via a `deadpool-postgres` `post_create` hook, with a + preflight validation pass so a broken script fails fast with a clearly + attributed `Startup script failed: ...` error instead of a misleading + connection error. Matches the builtin driver's + `run_postgres_startup_script` behavior (`src-tauri/src/pool_manager.rs`). + Found missing during a security-audit pass — no parity test exercises + this field, so the 82/82 parity suite didn't catch the gap. +- `connection_string` support: when present, it's parsed via + `tokio_postgres::Config::from_str` and takes precedence over the discrete + host/port/database/username/password fields, matching the README's + documented behavior. Previously the field was parsed into + `ConnectionParams` but silently never consumed by `build_pool()`. Also + found during the security-audit pass. - Plugin source (`Cargo.toml`, `Cargo.lock`, `.tabularium`, `src/`) imported from `TabularisDB/tabularis`'s `plugins/postgres-plugin/` at commit `ad765f3a` (82/82 parity tests green per that commit). This is a parallel @@ -35,3 +49,9 @@ own install-path/executable naming and the sibling-plugin convention of a bare engine-name slug. This field is a permanent registry slug once published, so it was fixed before any release. +- README's connection config table: `ssl_ca`/`ssl_cert`/`ssl_key` were + documented as a single group ("If using `verify-ca`/`verify-full`"), but + only `ssl_ca` (custom CA pinning) is actually implemented — matches the + builtin PostgreSQL driver, which also has no client-certificate support + (unlike its MySQL driver). Documentation corrected to describe only what + the plugin (and builtin) actually do. diff --git a/Cargo.lock b/Cargo.lock index c3d9cc1..5a15b1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -816,6 +816,7 @@ dependencies = [ "log", "rust_decimal", "rustls", + "rustls-pemfile", "rustls-platform-verifier", "serde", "serde_json", @@ -1046,6 +1047,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index fc02bf5..bbe1f7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ deadpool-postgres = "0.14" tokio-postgres-rustls = "0.13" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls-platform-verifier = "0.6" +rustls-pemfile = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" chrono = { version = "0.4", features = ["serde"] } diff --git a/README.md b/README.md index a400687..aa5f86a 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,9 @@ both drivers against the same live database and compares every response. | `username` | Database user | Yes (unless using `connection_string`) | | `password` | Database password | If required by the server | | `ssl_mode` | `disable`, `require`, `verify-ca`, or `verify-full` | No | -| `ssl_ca` / `ssl_cert` / `ssl_key` | Paths to SSL certificate material | If using `verify-ca`/`verify-full` | +| `ssl_ca` | Path to a custom CA bundle PEM file, used to validate the server's certificate under `verify-ca`/`verify-full` instead of the system trust store | No | | `connection_string` | Full `postgres://user:pass@host:port/db` URL, as an alternative to the discrete fields above | No | +| `startup_script` | SQL run on every new pooled connection (e.g. `SET search_path = ...`) before it's handed to a query | No | ## Supported PostgreSQL Data Types diff --git a/src/client.rs b/src/client.rs index a08c267..900a4e5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -19,6 +19,7 @@ //! that gap. use std::collections::HashMap; +use std::str::FromStr; use std::sync::{LazyLock, Mutex}; use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime}; @@ -33,7 +34,7 @@ static POOLS: LazyLock>> = LazyLock::new(|| Mutex::n /// Build a connection pool from the given params and verify connectivity /// by acquiring one client and running `SELECT 1`. pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { - let pool = get_or_create_pool(params)?; + let pool = get_or_create_pool(params).await?; let client = pool .get() .await @@ -53,7 +54,7 @@ pub async fn query_strings( query_params: &[&(dyn ToSql + Sync)], column: &str, ) -> Result, String> { - let pool = get_or_create_pool(params)?; + let pool = get_or_create_pool(params).await?; let client = pool .get() .await @@ -76,7 +77,7 @@ pub async fn query_rows( query: &str, query_params: &[&(dyn ToSql + Sync)], ) -> Result, String> { - let pool = get_or_create_pool(params)?; + let pool = get_or_create_pool(params).await?; let client = pool .get() .await @@ -96,7 +97,7 @@ pub async fn execute_typed( query: &str, typed_params: &[(&(dyn ToSql + Sync), Type)], ) -> Result { - let pool = get_or_create_pool(params)?; + let pool = get_or_create_pool(params).await?; let client = pool .get() .await @@ -120,7 +121,7 @@ pub async fn query_typed( query: &str, typed_params: &[(&(dyn ToSql + Sync), Type)], ) -> Result, String> { - let pool = get_or_create_pool(params)?; + let pool = get_or_create_pool(params).await?; let client = pool .get() .await @@ -208,26 +209,28 @@ fn quote_qualified_type(type_schema: &str, type_name: &str) -> String { /// Get the cached pool for these connection params, creating and caching one /// on first use. Public for use by query handlers that need direct pool /// access (e.g. to acquire one client for a multi-statement batch). -pub fn build_pool_pub(params: &ConnectionParams) -> Result { - get_or_create_pool(params) +pub async fn build_pool_pub(params: &ConnectionParams) -> Result { + get_or_create_pool(params).await } /// Identifies a connection target for pool-cache purposes. -/// Matches on host:port:database:user — sufficient for this plugin's scope -/// (no per-connection TLS-mode/connection_id refinement, unlike the builtin). +/// Matches on host:port:database:user (plus the startup script, so editing +/// it forces a fresh pool) — sufficient for this plugin's scope (no +/// per-connection TLS-mode/connection_id refinement, unlike the builtin). fn connection_key(params: &ConnectionParams) -> String { format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}", params.host.as_deref().unwrap_or(""), params.port.unwrap_or(5432), params.database.as_deref().unwrap_or(""), params.username.as_deref().unwrap_or(""), + params.startup_script.as_deref().unwrap_or(""), ) } /// Return the cached pool for this connection's identity, or build and cache /// a new one if this is the first request for that identity. -fn get_or_create_pool(params: &ConnectionParams) -> Result { +async fn get_or_create_pool(params: &ConnectionParams) -> Result { let key = connection_key(params); { @@ -239,7 +242,7 @@ fn get_or_create_pool(params: &ConnectionParams) -> Result { } } - let pool = build_pool(params)?; + let pool = build_pool(params).await?; let mut pools = POOLS .lock() .map_err(|_| "pool cache lock poisoned".to_string())?; @@ -249,27 +252,144 @@ fn get_or_create_pool(params: &ConnectionParams) -> Result { } /// Build a deadpool-postgres pool for the given connection parameters. -fn build_pool(params: &ConnectionParams) -> Result { +/// +/// When `connection_string` is set, it takes precedence over the discrete +/// host/port/database/username/password fields — matching the README's +/// documented behavior ("as an alternative to the discrete fields above"). +async fn build_pool(params: &ConnectionParams) -> Result { let mut cfg = Config::new(); - cfg.host = params.host.clone(); - cfg.port = params.port; - cfg.dbname = params.database.clone(); - cfg.user = params.username.clone(); - cfg.password = params.password.clone(); + + match params + .connection_string + .as_deref() + .filter(|s| !s.trim().is_empty()) + { + Some(conn_str) => { + let parsed = tokio_postgres::Config::from_str(conn_str) + .map_err(|e| format!("Invalid connection string: {e}"))?; + let host = parsed.get_hosts().first().and_then(|h| match h { + tokio_postgres::config::Host::Tcp(host) => Some(host.clone()), + #[cfg(unix)] + tokio_postgres::config::Host::Unix(_) => None, + }); + cfg.host = host; + cfg.port = parsed.get_ports().first().copied(); + cfg.dbname = parsed.get_dbname().map(str::to_string); + cfg.user = parsed.get_user().map(str::to_string); + cfg.password = parsed + .get_password() + .map(|p| String::from_utf8_lossy(p).into_owned()); + } + None => { + cfg.host = params.host.clone(); + cfg.port = params.port; + cfg.dbname = params.database.clone(); + cfg.user = params.username.clone(); + cfg.password = params.password.clone(); + } + } + cfg.manager = Some(ManagerConfig { recycling_method: RecyclingMethod::Fast, }); + let script = params + .startup_script + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + if needs_tls(params) { - let tls_config = build_tls_connector()?; - cfg.create_pool(Some(Runtime::Tokio1), MakeRustlsConnect::new(tls_config)) + let tls_config = build_tls_connector(params)?; + let tls = MakeRustlsConnect::new(tls_config); + if let Some(script) = script { + preflight_startup_script(&cfg, tls.clone(), script).await?; + } + let mut builder = cfg + .builder(tls) + .map_err(|e| format!("Pool creation failed (TLS): {e}"))? + .runtime(Runtime::Tokio1); + if let Some(script) = script { + builder = builder.post_create(startup_script_hook(script)); + } + builder + .build() .map_err(|e| format!("Pool creation failed (TLS): {e}")) } else { - cfg.create_pool(Some(Runtime::Tokio1), NoTls) + if let Some(script) = script { + preflight_startup_script(&cfg, NoTls, script).await?; + } + let mut builder = cfg + .builder(NoTls) + .map_err(|e| format!("Pool creation failed: {e}"))? + .runtime(Runtime::Tokio1); + if let Some(script) = script { + builder = builder.post_create(startup_script_hook(script)); + } + builder + .build() .map_err(|e| format!("Pool creation failed: {e}")) } } +/// Format a startup-script execution failure so the surfaced error clearly +/// names the startup script as the cause, instead of reading like a bad host +/// or wrong credentials. +fn startup_script_error(err: impl std::fmt::Display) -> String { + format!("Startup script failed: {err}") +} + +/// Build the `post_create` hook that runs the startup script on every new +/// pooled connection (matches the builtin driver's `post_create` hook — see +/// `src-tauri/src/pool_manager.rs`). +fn startup_script_hook(script: &str) -> deadpool_postgres::Hook { + let script = script.to_string(); + deadpool_postgres::Hook::async_fn(move |client, _metrics| { + let script = script.clone(); + Box::pin(async move { + client + .batch_execute(&script) + .await + .map_err(|e| deadpool_postgres::HookError::message(startup_script_error(e)))?; + Ok(()) + }) + }) +} + +/// Validate the startup script on a throwaway connection so a broken script +/// fails fast with a clearly attributed error, **without** applying its side +/// effects (the script runs inside a transaction that is rolled back). This +/// preflight exists only for early, well-labelled failures — the per-pool +/// `post_create` hook is the single place the script actually takes effect. +/// Matches the builtin driver's `run_postgres_startup_script` preflight. +async fn preflight_startup_script(cfg: &Config, tls: T, script: &str) -> Result<(), String> +where + T: tokio_postgres::tls::MakeTlsConnect + Clone + Sync + Send + 'static, + T::Stream: Sync + Send, + T::TlsConnect: Sync + Send, + >::Future: Send, +{ + let pg_config = cfg + .get_pg_config() + .map_err(|e| format!("Pool creation failed: {e}"))?; + let (mut client, connection) = pg_config + .connect(tls) + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let driver = tokio::spawn(async move { + let _ = connection.await; + }); + let outcome: Result<(), tokio_postgres::Error> = async { + let tx = client.transaction().await?; + tx.batch_execute(script).await?; + tx.rollback().await + } + .await; + drop(client); + driver.abort(); + outcome.map_err(startup_script_error) +} + /// Determine whether TLS should be used based on ssl_mode. fn needs_tls(params: &ConnectionParams) -> bool { matches!( @@ -278,10 +398,35 @@ fn needs_tls(params: &ConnectionParams) -> bool { ) } -/// Build a rustls ClientConfig using the platform certificate verifier. -fn build_tls_connector() -> Result { +/// Build a rustls ClientConfig. `verify-ca`/`verify-full` validate the +/// server's certificate chain — against a caller-supplied CA bundle +/// (`ssl_ca`) when present, or the platform trust store otherwise. `require` +/// forces TLS without certificate validation (matches the builtin driver's +/// `require` behavior — see `src-tauri/src/pool_manager.rs`). +fn build_tls_connector(params: &ConnectionParams) -> Result { use rustls_platform_verifier::BuilderVerifierExt; + let user_ca = params.ssl_ca.as_deref().filter(|s| !s.trim().is_empty()); + + let needs_cert_validation = matches!( + params.ssl_mode.as_deref(), + Some("verify-ca" | "verify-full") + ); + + if needs_cert_validation { + if let Some(ca_path) = user_ca { + let roots = load_roots_from_pem(ca_path)?; + let verifier = + rustls::client::WebPkiServerVerifier::builder(std::sync::Arc::new(roots)) + .build() + .map_err(|e| format!("Failed to build certificate verifier: {e}"))?; + return Ok(rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth()); + } + } + let config = rustls::ClientConfig::builder() .with_platform_verifier() .map_err(|e| format!("Failed to build platform TLS verifier: {e}"))? @@ -289,6 +434,27 @@ fn build_tls_connector() -> Result { Ok(config) } +/// Load root certificates from a PEM file (used for `ssl_ca`-pinned +/// `verify-ca`/`verify-full` connections). +fn load_roots_from_pem(path: &str) -> Result { + let pem = + std::fs::read(path).map_err(|e| format!("Failed to read ssl_ca file '{path}': {e}"))?; + let mut roots = rustls::RootCertStore::empty(); + let mut cursor = std::io::Cursor::new(&pem[..]); + for cert in rustls_pemfile::certs(&mut cursor) { + let cert = cert.map_err(|e| format!("Failed to parse ssl_ca '{path}': {e}"))?; + roots + .add(cert) + .map_err(|e| format!("Failed to add ssl_ca cert from '{path}': {e}"))?; + } + if roots.is_empty() { + return Err(format!( + "ssl_ca '{path}' contained no PEM CERTIFICATE blocks" + )); + } + Ok(roots) +} + #[cfg(test)] #[path = "client_tests.rs"] mod client_tests; diff --git a/src/client_tests.rs b/src/client_tests.rs index 18b5b57..4de1e53 100644 --- a/src/client_tests.rs +++ b/src/client_tests.rs @@ -17,6 +17,7 @@ fn params(host: &str, port: u16, db: &str, user: &str) -> ConnectionParams { ssl_cert: None, ssl_key: None, connection_string: None, + startup_script: None, } } @@ -55,17 +56,20 @@ fn connection_key_is_stable_for_identical_params() { assert_eq!(a, b); } -#[test] -fn get_or_create_pool_reuses_cached_entry_for_identical_params() { +#[tokio::test] +async fn get_or_create_pool_reuses_cached_entry_for_identical_params() { // deadpool's Pool::new is lazy (no connection attempt at creation - // time), so this exercises only the cache bookkeeping, not real - // connectivity. Use a key unlikely to collide with other tests - // running in the same process. + // time) as long as no startup script is set — this test's `params()` + // helper leaves startup_script as None, so this exercises only the + // cache bookkeeping, not real connectivity. Use a key unlikely to + // collide with other tests running in the same process. let p = params("cache-test-host-unique", 5432, "db", "user"); let key = connection_key(&p); let before = POOLS.lock().unwrap().len(); - get_or_create_pool(&p).expect("first call creates and caches a pool"); + get_or_create_pool(&p) + .await + .expect("first call creates and caches a pool"); let after_first = POOLS.lock().unwrap().len(); assert_eq!( after_first, @@ -74,7 +78,9 @@ fn get_or_create_pool_reuses_cached_entry_for_identical_params() { ); assert!(POOLS.lock().unwrap().contains_key(&key)); - get_or_create_pool(&p).expect("second call should hit the cache"); + get_or_create_pool(&p) + .await + .expect("second call should hit the cache"); let after_second = POOLS.lock().unwrap().len(); assert_eq!( after_second, after_first, diff --git a/src/handlers/query.rs b/src/handlers/query.rs index ab2c327..5eeacc9 100644 --- a/src/handlers/query.rs +++ b/src/handlers/query.rs @@ -44,7 +44,7 @@ pub async fn execute_query_batch(id: Value, params: &Value) -> Value { let schema = params.get("schema").and_then(Value::as_str); // Acquire ONE connection for the entire batch (session state must survive) - let pool = match client::build_pool_pub(&conn_params) { + let pool = match client::build_pool_pub(&conn_params).await { Ok(p) => p, Err(e) => return error_response(id, -32603, &e), }; @@ -124,7 +124,7 @@ async fn exec_query( page: u32, schema: Option<&str>, ) -> Result { - let pool = client::build_pool_pub(conn_params)?; + let pool = client::build_pool_pub(conn_params).await?; let pg_client = pool .get() .await diff --git a/src/models.rs b/src/models.rs index 9b8aefb..0b7eedb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -19,6 +19,7 @@ pub struct ConnectionParams { pub ssl_cert: Option, pub ssl_key: Option, pub connection_string: Option, + pub startup_script: Option, } impl ConnectionParams { @@ -46,6 +47,7 @@ impl ConnectionParams { ssl_cert: get_str("ssl_cert"), ssl_key: get_str("ssl_key"), connection_string: get_str("connection_string"), + startup_script: get_str("startup_script"), } } } From 8efe85f2b88cacfbc5b832ecacdf1cb990dffd47 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 18:27:53 -0400 Subject: [PATCH 07/10] Validate file_path before writing in save_blob_to_file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security audit flagged this as a pre-existing gap shared with the builtin driver: std::fs::write(file_path, bytes) ran with zero validation in either driver, so a bad path (empty, nonexistent parent directory, or a path that's itself a directory) wasted a full DB round-trip before failing with a bare OS error number. Adds validate_writable_file_path() as a fast-fail pre-check — empty path, existing-directory path, and missing-parent-directory path are now rejected immediately with a clearly attributed message and JSON-RPC code -32602 (Invalid params), before any query runs. Genuine write-time failures (e.g. permission denied) still surface from std::fs::write, now wrapped with the failing path for clarity instead of a bare OS message. This is deliberately not a security boundary — file_path comes from the Tabularis frontend's native save-file dialog, not an untrusted network caller — just a fast-fail for the common mistakes a raw OS error doesn't name clearly. The builtin driver's identical gap is unaffected; this fix is plugin-only, matching the earlier finding that the built-in and plugin share this behavior today. Added 5 unit tests for validate_writable_file_path(). Manually verified against the live PostgreSQL instance: empty path, missing parent directory, and directory-as-path all now fail fast with -32602; the happy-path write and the permission-denied write-time failure both still work as before. Re-ran tabularis's unmodified 82-test parity suite (including parity_blob_save_to_file specifically) against the rebuilt binary — still 82/82 GREEN, zero regression. --- src/handlers/blob.rs | 32 ++++++++++++++++++++++++++++++- src/handlers/blob_tests.rs | 39 +++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs index 4f40819..25d355a 100644 --- a/src/handlers/blob.rs +++ b/src/handlers/blob.rs @@ -32,10 +32,14 @@ pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { .cloned() .unwrap_or_default(); + if let Err(e) = validate_writable_file_path(file_path) { + return error_response(id, -32602, &e); + } + match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await { Ok(bytes) => match std::fs::write(file_path, bytes) { Ok(_) => ok_response(id, Value::Null), - Err(e) => error_response(id, -32603, &e.to_string()), + Err(e) => error_response(id, -32603, &format!("Failed to write '{file_path}': {e}")), }, Err(e) => error_response(id, -32603, &e), } @@ -95,6 +99,32 @@ async fn fetch_blob_bytes( row.try_get::<_, Vec>(0).map_err(|e| e.to_string()) } +/// Sanity-check `file_path` before spending a DB round-trip on a write that's +/// going to fail anyway. Deliberately permissive — this is not a security +/// boundary (the path comes from the Tabularis frontend's native save-file +/// dialog, not directly from an untrusted network caller) but a fast-fail for +/// the common mistakes `std::fs::write`'s raw OS error doesn't clearly name: +/// an empty path, a parent directory that doesn't exist, or a path that's +/// itself an existing directory. +fn validate_writable_file_path(file_path: &str) -> Result<(), String> { + if file_path.trim().is_empty() { + return Err("file_path must not be empty".to_string()); + } + let path = std::path::Path::new(file_path); + if path.is_dir() { + return Err(format!( + "file_path '{file_path}' is a directory, not a file" + )); + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() && !parent.is_dir() => Err(format!( + "file_path '{file_path}': parent directory '{}' does not exist", + parent.display() + )), + _ => Ok(()), + } +} + /// Encode raw bytes into the canonical BLOB wire format: /// `"BLOB:::"`. MIME type is sniffed from the /// content's magic bytes; unrecognized content falls back to diff --git a/src/handlers/blob_tests.rs b/src/handlers/blob_tests.rs index 29bde76..8fde688 100644 --- a/src/handlers/blob_tests.rs +++ b/src/handlers/blob_tests.rs @@ -2,7 +2,7 @@ //! repo convention (`.rules/rust.md` #4/#5) — loaded via //! `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`. -use super::encode_blob_full; +use super::{encode_blob_full, validate_writable_file_path}; #[test] fn encodes_size_mime_and_base64() { @@ -26,3 +26,40 @@ fn sniffs_recognized_magic_bytes() { let wire = encode_blob_full(&bytes); assert!(wire.starts_with("BLOB:8:image/png:")); } + +mod validate_writable_file_path_tests { + use super::validate_writable_file_path; + + #[test] + fn rejects_empty_path() { + assert!(validate_writable_file_path("").is_err()); + assert!(validate_writable_file_path(" ").is_err()); + } + + #[test] + fn rejects_existing_directory() { + let err = validate_writable_file_path("/tmp") + .expect_err("an existing directory must be rejected"); + assert!(err.contains("directory")); + } + + #[test] + fn rejects_nonexistent_parent_directory() { + let err = validate_writable_file_path("/this-dir-should-not-exist-xyz/out.bin") + .expect_err("a missing parent directory must be rejected"); + assert!(err.contains("parent directory")); + } + + #[test] + fn accepts_writable_path_in_existing_directory() { + // /tmp always exists in the test environment; the target file itself + // need not exist yet (that's the whole point of a "save to" path). + assert!(validate_writable_file_path("/tmp/some-file-that-need-not-exist.bin").is_ok()); + } + + #[test] + fn accepts_relative_path_with_no_directory_component() { + // A bare filename (no parent) is valid — writes to the plugin's CWD. + assert!(validate_writable_file_path("output.bin").is_ok()); + } +} From 319978de920d56adeffeadd67539e06a983fb897 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 18:49:32 -0400 Subject: [PATCH 08/10] Add periodic idle-pool eviction to close the "pool cleanup" audit gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the org's "pool cleanup on shutdown" security-audit checklist item against the host's actual process lifecycle (tabularis's src-tauri/src/plugins/driver.rs): the host never sends the plugin a shutdown JSON-RPC call at all — it kills the process outright (Child::kill(), an unconditional SIGKILL on Unix, no grace period). No sibling plugin implements a shutdown handler either, so that checklist wording describes something structurally unreachable, not a real gap. Comparing all 5 sibling plugin repos surfaced the actual, exercisable gap instead: dynamodb and sqlserver both run a periodic background task that evicts idle connection-pool entries (every 10 minutes), while this plugin (like oracle/libsql/mongodb) never evicted anything — POOLS only grew for the life of the process. A long session connecting to many distinct targets would pin idle TCP connections and pool memory indefinitely. TDD, matching this migration's established discipline: - RED: added cleanup_idle_pools_evicts_pools_with_no_checked_out_connections in client_tests.rs before the function existed — confirmed compile failure. - GREEN: added client::cleanup_idle_pools(), porting sqlserver's exact eviction predicate (pool.status().size > pool.status().available — keep only pools with an outstanding checked-out connection). - Rewrote main.rs to the worker-pool architecture the cleanup task needs a home in (4 workers + 1 writer + 1 cleanup task, coordinated via a tokio::sync::watch shutdown signal on stdin EOF), ported directly from sqlserver's main.rs. Required adding + Send to binding::PgParam's boxed ToSql trait object — the only type in the crate that wasn't already Send, needed once handler futures could be moved onto separate spawned tasks. Verified: the host correlates JSON-RPC responses by id via a HashMap, not arrival order, so out-of-order responses from concurrent workers are already safe under the existing protocol — confirmed live by piping 5 concurrent requests including a deliberate pg_sleep(0.3) and observing it return last while faster requests returned first, each with correct id correlation. Also manually re-verified execute_query_batch's single- connection session-state guarantee (temp table created in statement 1, visible in statement 3) is unaffected by the architecture change. cargo build/test (78/78)/clippy/fmt all pass; no Cargo.lock drift. Re-ran tabularis's unmodified 82-test parity suite against the rebuilt release binary via POSTGRES_PLUGIN_BIN — still 82/82 GREEN, zero regression. --- CHANGELOG.md | 28 +++++++++++ Cargo.toml | 2 +- src/binding.rs | 2 +- src/client.rs | 12 +++++ src/client_tests.rs | 23 ++++++++- src/main.rs | 115 ++++++++++++++++++++++++++++++++++++++------ 6 files changed, 164 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa0231..d203af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ ### Added +- `main.rs` rewritten to a worker-pool architecture (4 workers + a single + writer task + a dedicated pool-cleanup task, coordinated via a + `tokio::sync::watch` shutdown signal on stdin EOF), matching the + sqlserver/dynamodb sibling plugins. A slow query on one connection no + longer blocks a concurrent `ping` or metadata call on another; the host + already tolerates out-of-order responses (it correlates by JSON-RPC `id` + via a `HashMap`, not arrival order), so this required no protocol change. +- Periodic idle-pool eviction: every 10 minutes, `client::cleanup_idle_pools()` + drops cached connection pools that currently have no checked-out + connections, so a long-running session that has connected to many + distinct targets doesn't pin idle TCP connections and pool memory for + the plugin's lifetime. Matches the sqlserver/dynamodb sibling plugins' + pattern exactly (`pool.status().size > pool.status().available` as the + keep predicate). Found missing during the same security-audit pass that + flagged "pool cleanup on shutdown" — investigation showed the host never + sends the plugin a `shutdown` RPC call at all (it kills the process + outright), so that specific checklist wording described something + unreachable; comparing sibling plugins surfaced this as the real, + exercisable gap instead. Added test-first (TDD): a unit test asserting + an idle pool gets evicted, written and confirmed RED (`cleanup_idle_pools` + didn't exist) before the function was implemented to GREEN. +- `save_blob_to_file` now validates `file_path` (empty, existing-directory, + or missing-parent-directory) before spending a DB round-trip on a write + that would fail anyway — a clearly attributed `-32602` error instead of + a bare OS error number surfacing after the query already ran. Not a + security boundary (the path comes from the frontend's native save + dialog), just a fast-fail. The builtin driver's identical gap is + untouched; this fix is plugin-only. Found during the security-audit pass. - `startup_script` support: SQL supplied on the connection now runs on every new pooled connection via a `deadpool-postgres` `post_create` hook, with a preflight validation pass so a broken script fails fast with a clearly diff --git a/Cargo.toml b/Cargo.toml index bbe1f7a..af9d155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ name = "test_plugin" path = "src/bin/test_plugin.rs" [dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } deadpool-postgres = "0.14" tokio-postgres-rustls = "0.13" diff --git a/src/binding.rs b/src/binding.rs index 8505ff9..ce482ea 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -16,7 +16,7 @@ use serde_json::Value; use tokio_postgres::types::{ToSql, Type}; use uuid::Uuid; -pub type PgParam = Box; +pub type PgParam = Box; pub type TypedPgParam = (PgParam, Type); pub struct BoundValue { diff --git a/src/client.rs b/src/client.rs index 900a4e5..6db136f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -251,6 +251,18 @@ async fn get_or_create_pool(params: &ConnectionParams) -> Result { Ok(pools.entry(key).or_insert(pool).clone()) } +/// Drop pools that currently have no checked-out connections. Called +/// periodically so long-idle sessions don't linger for the plugin's +/// lifetime — matches the sqlserver/dynamodb sibling plugins' pattern. +pub fn cleanup_idle_pools() { + if let Ok(mut pools) = POOLS.lock() { + pools.retain(|_, pool| { + let status = pool.status(); + status.size > status.available + }); + } +} + /// Build a deadpool-postgres pool for the given connection parameters. /// /// When `connection_string` is set, it takes precedence over the discrete diff --git a/src/client_tests.rs b/src/client_tests.rs index 4de1e53..7f8bf26 100644 --- a/src/client_tests.rs +++ b/src/client_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for `client.rs`. Sibling test file per repo convention //! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod client_tests;`. -use super::{connection_key, get_or_create_pool, POOLS}; +use super::{cleanup_idle_pools, connection_key, get_or_create_pool, POOLS}; use crate::models::ConnectionParams; fn params(host: &str, port: u16, db: &str, user: &str) -> ConnectionParams { @@ -87,3 +87,24 @@ async fn get_or_create_pool_reuses_cached_entry_for_identical_params() { "second call with identical params must not create a new entry" ); } + +#[tokio::test] +async fn cleanup_idle_pools_evicts_pools_with_no_checked_out_connections() { + // A freshly-built, never-connected pool has status().size == + // status().available == 0 (deadpool's Pool::new is lazy) — no + // checked-out connections, so it must be evicted as idle/unused. + let p = params("cleanup-test-host-unique", 5432, "db", "user"); + let key = connection_key(&p); + + get_or_create_pool(&p) + .await + .expect("pool should be created and cached"); + assert!(POOLS.lock().unwrap().contains_key(&key)); + + cleanup_idle_pools(); + + assert!( + !POOLS.lock().unwrap().contains_key(&key), + "an idle pool with no checked-out connections must be evicted" + ); +} diff --git a/src/main.rs b/src/main.rs index 3d1fa45..c9727c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,41 +5,126 @@ //! Reads newline-delimited JSON-RPC 2.0 requests from stdin and writes //! responses (one JSON object per line) to stdout. All handler logic is //! async (tokio) since the database pool requires an async runtime. +//! +//! Requests are fanned out to a small worker pool so a slow query on one +//! connection does not block a `ping` or metadata call on another. Responses +//! are funneled through a single writer task so concurrent handlers never +//! interleave bytes on stdout. A dedicated background task periodically +//! evicts idle connection pools (see `client::cleanup_idle_pools`) so a +//! long-running session that has connected to many distinct targets doesn't +//! pin idle TCP connections and pool memory for the plugin's lifetime. +//! Matches the sqlserver/dynamodb sibling plugins' architecture. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, watch, Mutex}; +use tokio::time::interval; -use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; +const WORKER_POOL_SIZE: usize = 4; -use postgresql_plugin::rpc; +// Bounded so a burst of requests applies backpressure to the stdin reader +// instead of buffering unboundedly in memory. +const REQUEST_QUEUE_CAPACITY: usize = 64; + +const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes #[tokio::main] async fn main() { - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut reader = BufReader::new(stdin); - let mut out = stdout; - let mut line = String::new(); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let cleanup_handle = tokio::spawn(run_pool_cleanup(shutdown_rx)); + + let (req_tx, req_rx) = mpsc::channel::(REQUEST_QUEUE_CAPACITY); + let req_rx = Arc::new(Mutex::new(req_rx)); + + let (resp_tx, resp_rx) = mpsc::unbounded_channel::(); + let writer_handle = tokio::spawn(run_writer(resp_rx)); + + let worker_handles: Vec<_> = (0..WORKER_POOL_SIZE) + .map(|_| tokio::spawn(run_worker(req_rx.clone(), resp_tx.clone()))) + .collect(); + drop(resp_tx); + + run_reader(req_tx).await; + let _ = shutdown_tx.send(true); + + for handle in worker_handles { + let _ = handle.await; + } + let _ = writer_handle.await; + let _ = cleanup_handle.await; +} + +async fn run_pool_cleanup(mut shutdown_rx: watch::Receiver) { + let mut timer = interval(POOL_CLEANUP_INTERVAL); loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) | Err(_) => break, - Ok(_) => {} + tokio::select! { + _ = timer.tick() => postgresql_plugin::client::cleanup_idle_pools(), + _ = shutdown_rx.changed() => break, } + } +} + +async fn run_reader(req_tx: mpsc::Sender) { + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + + loop { + let line = match lines.next_line().await { + Ok(Some(line)) => line, + Ok(None) => break, + Err(err) => { + eprintln!("stdin read error, exiting: {err}"); + break; + } + }; + let trimmed = line.trim(); if trimmed.is_empty() { continue; } - let response = rpc::handle_line(trimmed).await; - let mut body = match serde_json::to_string(&response) { + // Blocks when the queue is full, applying backpressure to reading. + if req_tx.send(trimmed.to_string()).await.is_err() { + break; + } + } +} + +async fn run_worker( + req_rx: Arc>>, + resp_tx: mpsc::UnboundedSender, +) { + loop { + let line = { + let mut rx = req_rx.lock().await; + rx.recv().await + }; + let Some(line) = line else { break }; + + let response = postgresql_plugin::rpc::handle_line(&line).await; + let body = match serde_json::to_string(&response) { Ok(s) => s, Err(err) => format!( "{{\"jsonrpc\":\"2.0\",\"error\":{{\"code\":-32603,\"message\":\"serialization failed: {err}\"}},\"id\":null}}" ), }; + + if resp_tx.send(body).is_err() { + break; + } + } +} + +async fn run_writer(mut resp_rx: mpsc::UnboundedReceiver) { + let mut stdout = tokio::io::stdout(); + while let Some(mut body) = resp_rx.recv().await { body.push('\n'); - if out.write_all(body.as_bytes()).await.is_err() { + if stdout.write_all(body.as_bytes()).await.is_err() { break; } - let _ = out.flush().await; + let _ = stdout.flush().await; } } From d1d545ab8797803c97eee520b7af1c085d164828 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 19:32:23 -0400 Subject: [PATCH 09/10] Raise the CI bar: manifest validation, markdownlint, release smoke test, cargo audit, live-DB integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveying CI across every Tabularis plugin repo showed this repo already matched or exceeded the norm (no sibling runs cargo audit; only 2 of 11 Rust siblings gate on clippy/fmt at all). Rather than settle for meeting convention, add four concrete pieces of automation that set a genuinely higher bar, plus document two more considered and deferred: - .tabularium manifest validation: npx @tabularium/cli validate against the live registry schema (registry.tabularis.dev/manifest.schema.json ?kind=driver — the same URL our own $schema field already points to). Tried raw ajv first; it rejected our own $schema field as an unknown additionalProperty, while the org's own CLI validates clean, correctly handling meta-keys. Catches a malformed manifest automatically instead of relying on careful manual review — we got name/id wrong once by hand earlier this session. - markdownlint-cli as an enforced CI job instead of a manually-run habit. - A release-binary smoke test: pipe a trivial "initialize" JSON-RPC request into each freshly-built platform binary and assert a non-error response, before it ever ships in a zip. Skipped for linux-arm64 only — that leg is cross-compiled and the binary can't execute on the x86_64 build runner without QEMU emulation. Caught a real bug in my own first draft locally: initialize legitimately returns "result":null, so a naive ".result != null" assertion would have failed on a correct response; fixed to check for absence of an "error" key instead, verified against both a good and a synthetic bad response before committing. - cargo audit via rustsec/audit-check (the maintained fork; actions-rs's is archived), on every push/PR and a weekly schedule — the schedule closes the "CVE disclosed after merge, dependency unchanged" gap a PR-only trigger would miss. - tests/live_db.rs: a self-contained integration test against a real postgres:16 container (first top-level tests/ dir in this repo — existing tests are all pure #[cfg(test)] unit tests per the .rules/rust.md #4/#5 sibling-file convention, which doesn't fit a live-DB test). Covers connect, a basic query, an insert, and — deliberately — the startup_script and connection_string handlers found completely uncovered anywhere during this session's security-audit pass, plus a broken-startup-script case verifying the clear "Startup script failed: ..." attribution still holds. Closes the actual biggest CI gap: nothing here previously verified the binary against a live database automatically, only ever done manually via the cross-repo parity gate. Explicitly NOT the cross-repo 82-test parity suite — that stays a manual/periodic check against tabularis, per the org's own unresolved "where do the parity tests live" question (docs/planning/02-phase-1-plugin-build.md). - docs/planning/ci-hardening-deferred.md: documents two further ideas (dependency-review-action on PRs; SBOM generation via cargo-cyclonedx) considered and deliberately not implemented now, with the rationale for each, so they aren't lost. Verified: all new CI logic tested locally before being wired into workflows (@tabularium/cli validate → "ok"; the jq/pwsh smoke-test assertions against a real release binary, both success and synthetic failure cases; all 6 live_db.rs tests passing against a real PostgreSQL 16 instance). Re-ran the full standalone suite after landing — cargo build/test (78/78 unit tests, unaffected)/clippy/fmt all pass, no Cargo.lock drift, markdownlint clean across the whole repo. --- .github/workflows/ci.yml | 69 +++++++ .github/workflows/release.yml | 26 +++ CHANGELOG.md | 30 +++ docs/planning/ci-hardening-deferred.md | 44 +++++ tests/live_db.rs | 253 +++++++++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 docs/planning/ci-hardening-deferred.md create mode 100644 tests/live_db.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af1e24..858536b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # Weekly cargo-audit sweep to catch newly-disclosed CVEs in deps that + # haven't otherwise changed. Off-peak minute, not :00/:30. + - cron: "17 6 * * 1" jobs: test: @@ -27,3 +31,68 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + + validate-manifest: + name: Validate .tabularium manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate against the live registry schema + run: npx --yes @tabularium/cli validate .tabularium --registry https://registry.tabularis.dev --kind driver + + markdownlint: + name: Markdown lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run markdownlint + run: npx --yes markdownlint-cli "**/*.md" + + audit: + name: Security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + live-db-integration: + name: Live PostgreSQL integration + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build + + - name: Run live-database integration test + env: + POSTGRES_PLUGIN_BIN: ${{ github.workspace }}/target/debug/postgresql-plugin + PGHOST: 127.0.0.1 + PGPORT: 54320 + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: testdb + run: cargo test --test live_db -- --test-threads=1 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e68261..668717e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,32 @@ jobs: Copy-Item ".tabularium" $stage Compress-Archive -Path "$stage\*" -DestinationPath "postgresql-plugin-${{ matrix.platform-label }}.zip" + # linux-arm64 is cross-compiled via `cross` on an x86_64 runner — that + # binary is a foreign architecture and cannot execute here without + # QEMU emulation, so this leg is skipped rather than adding that + # complexity for one platform. + - name: Smoke test binary (unix) + if: runner.os != 'Windows' && !matrix.cross + run: | + response=$(echo '{"jsonrpc":"2.0","method":"initialize","id":1}' | ./staging/postgresql-plugin${{ matrix.binary-suffix }}) + echo "$response" + echo "$response" | jq -e 'has("error") | not' > /dev/null || { + echo "::error::Binary did not return a valid initialize response" + exit 1 + } + + - name: Smoke test binary (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $response = '{"jsonrpc":"2.0","method":"initialize","id":1}' | & ".\staging\postgresql-plugin${{ matrix.binary-suffix }}" + Write-Output $response + $parsed = $response | ConvertFrom-Json + if ($null -ne $parsed.error) { + Write-Error "Binary did not return a valid initialize response" + exit 1 + } + - name: Stash artifact uses: actions/upload-artifact@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d203af9..4aa1113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ ### Added +- CI hardening — deliberately set a higher bar than the sibling plugin + repos and the org's own documented requirements (no sibling runs + `cargo audit`; only 2 of 11 Rust siblings gate on clippy/fmt at all): + - `.tabularium` manifest validation against the live registry schema via + `@tabularium/cli validate`, catching a malformed manifest automatically + (we got `name`/`id` wrong once by hand earlier this session). + - `markdownlint-cli` as an enforced CI job, not a manually-run habit. + - A release-binary smoke test: pipe a trivial `initialize` JSON-RPC + request into each freshly-built platform binary and assert a valid + (non-error) response before it ships in a zip. Skipped for `linux-arm64` + only, since that leg is cross-compiled and the binary can't execute on + the x86_64 build runner without QEMU emulation. + - `cargo audit` (via `rustsec/audit-check`) for supply-chain + vulnerabilities, on every push/PR and a weekly schedule (catches CVEs + disclosed after merge against unchanged dependencies). + - `tests/live_db.rs`: a self-contained live-`postgres:16`-container + integration test (first top-level `tests/` dir in this repo — existing + tests are all pure unit tests via the `.rules/rust.md` #4/#5 + sibling-file convention). Covers connect, a basic query, an insert, and + the `startup_script`/`connection_string` handlers found completely + uncovered during the security-audit pass — closes the actual biggest + gap in this repo's CI: nothing previously verified the binary against + a real database automatically. Deliberately NOT the cross-repo 82-test + parity suite (that stays a manual/periodic check against `tabularis`, + per the "Repo Extraction" open question in + `docs/planning/02-phase-1-plugin-build.md`). + - Two further hardening ideas — `dependency-review-action` on PRs and + SBOM generation via `cargo-cyclonedx` — were considered and + deliberately deferred rather than implemented now; see + `docs/planning/ci-hardening-deferred.md` for the rationale. - `main.rs` rewritten to a worker-pool architecture (4 workers + a single writer task + a dedicated pool-cleanup task, coordinated via a `tokio::sync::watch` shutdown signal on stdin EOF), matching the diff --git a/docs/planning/ci-hardening-deferred.md b/docs/planning/ci-hardening-deferred.md new file mode 100644 index 0000000..a831a06 --- /dev/null +++ b/docs/planning/ci-hardening-deferred.md @@ -0,0 +1,44 @@ +# CI hardening — deferred items + +**Status:** not implemented; documented here so the ideas aren't lost. + +While setting a higher CI bar than the sibling plugin repos (see the +`ci.yml`/`release.yml` history around the manifest-validation, markdownlint, +release-binary-smoke-test, `cargo audit`, and live-database-integration-test +additions), two further items were identified and deliberately deferred +rather than implemented immediately: + +## `dependency-review-action` on PRs + +[`actions/dependency-review-action`](https://github.com/actions/dependency-review-action) +flags newly-introduced vulnerable or license-incompatible dependencies +**in the diff of a specific PR**, rather than scanning the whole dependency +tree. This is complementary to, not a replacement for, the `cargo audit` +job already added: `cargo audit` catches every known vulnerability in the +full tree (including ones that predate the PR and ones newly disclosed +against unchanged deps, via its weekly schedule run), while +`dependency-review-action` is specifically useful for catching "this PR +just added a bad dependency" as an inline PR check before merge. + +Not implemented yet because `cargo audit` already covers the core +supply-chain-vulnerability need for a single-crate Rust repo this size; +the PR-diff-specific framing adds most value on repos with frequent +dependency churn or many contributors, which doesn't describe this repo's +current state. + +## SBOM generation (`cargo-cyclonedx`) + +Publishing a [CycloneDX](https://cyclonedx.org/) SBOM (Software Bill of +Materials) alongside each GitHub release, generated via +[`cargo-cyclonedx`](https://github.com/CycloneDX/cyclonedx-rust-cargo), +would let downstream consumers (or automated scanners) inspect this +plugin's exact dependency tree per release without needing to check out +the tagged commit and inspect `Cargo.lock` themselves. + +Not implemented yet because it's genuinely ahead of the curve for this +repo's current maturity — no consumer has asked for it, no sibling plugin +in the org does this, and it doesn't close a gap the way the other four +additions do. Worth revisiting once this repo is signed off as the +primary plugin home (see `CLAUDE.md`'s "Status" section) and/or once +there's an actual downstream consumer or compliance requirement asking +for it. diff --git a/tests/live_db.rs b/tests/live_db.rs new file mode 100644 index 0000000..ef48a54 --- /dev/null +++ b/tests/live_db.rs @@ -0,0 +1,253 @@ +//! Live-database integration test — a self-contained smoke test that +//! actually talks to a real PostgreSQL instance, unlike every other test in +//! this crate (all pure `#[cfg(test)]` unit tests, see `.rules/rust.md` +//! #4/#5). Closes the biggest gap in this repo's own CI: nothing here +//! previously verified the built binary against a live database +//! automatically — that only ever happened via a manual cross-repo parity +//! check against `tabularis`'s test suite. +//! +//! This is deliberately NOT the cross-repo 82-test parity suite (that stays +//! a manual/periodic check against `tabularis`, per the "Repo Extraction" +//! open question in `docs/planning/02-phase-1-plugin-build.md`). It's a +//! small self-check covering connect, a basic query, an insert, and the two +//! handlers found completely uncovered during the security-audit pass this +//! migration did (`startup_script`, `connection_string`). +//! +//! # Running locally +//! +//! Point `POSTGRES_PLUGIN_BIN` at a debug build and run against any +//! PostgreSQL 16 instance (defaults below match this session's local +//! Podman container: `postgres:16`, user `postgres`, password `password`, +//! db `testdb`, port `54320`): +//! +//! ```bash +//! cargo build +//! POSTGRES_PLUGIN_BIN=target/debug/postgresql-plugin cargo test --test live_db -- --test-threads=1 +//! ``` + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use serde_json::{json, Value}; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn conn_params() -> Value { + json!({ + "host": env_or("PGHOST", "127.0.0.1"), + "port": env_or("PGPORT", "54320").parse::().expect("PGPORT must be a valid port"), + "username": env_or("PGUSER", "postgres"), + "password": env_or("PGPASSWORD", "password"), + "database": env_or("PGDATABASE", "testdb"), + }) +} + +/// A running plugin process, driven over its stdin/stdout exactly like a +/// real host would — same shape as the manual JSON-RPC smoke tests run +/// throughout this migration. +struct Plugin { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Plugin { + fn spawn() -> Self { + let bin = std::env::var("POSTGRES_PLUGIN_BIN").expect("POSTGRES_PLUGIN_BIN must be set"); + let mut child = Command::new(bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to spawn plugin binary"); + let stdin = child.stdin.take().expect("no stdin"); + let stdout = BufReader::new(child.stdout.take().expect("no stdout")); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + /// Send one JSON-RPC request and return its parsed response. + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let request = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": id, + }); + let mut line = serde_json::to_string(&request).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write to plugin stdin"); + self.stdin.flush().expect("flush plugin stdin"); + + let mut response_line = String::new(); + self.stdout + .read_line(&mut response_line) + .expect("read from plugin stdout"); + let response: Value = + serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response"); + assert_eq!( + response.get("id").and_then(Value::as_u64), + Some(id), + "response id must match the request that produced it" + ); + response + } + + /// Call and assert the response carries a `result`, not an `error`. + fn call_ok(&mut self, method: &str, params: Value) -> Value { + let response = self.call(method, params); + assert!( + response.get("error").is_none(), + "{method} returned an error: {:?}", + response.get("error") + ); + response + .get("result") + .cloned() + .unwrap_or_else(|| panic!("{method} returned neither result nor error")) + } +} + +impl Drop for Plugin { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn test_connection_succeeds_against_live_database() { + let mut plugin = Plugin::spawn(); + plugin.call_ok("test_connection", json!({ "params": conn_params() })); +} + +#[test] +fn execute_query_returns_rows_from_live_database() { + let mut plugin = Plugin::spawn(); + let result = plugin.call_ok( + "execute_query", + json!({ "params": conn_params(), "query": "SELECT 1 AS one" }), + ); + let rows = result + .get("rows") + .and_then(Value::as_array) + .expect("rows array"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0], json!(1)); +} + +#[test] +fn insert_record_persists_a_row() { + let mut plugin = Plugin::spawn(); + let params = conn_params(); + + // Self-contained: create (and reset) our own scratch table rather than + // depending on tabularis's seed fixtures, since this test must not + // require anything outside this repo. + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "CREATE TABLE IF NOT EXISTS live_db_test_scratch \ + (id SERIAL PRIMARY KEY, name TEXT, value INTEGER)", + }), + ); + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "TRUNCATE live_db_test_scratch RESTART IDENTITY" }), + ); + + let affected = plugin.call_ok( + "insert_record", + json!({ + "params": params, + "table": "live_db_test_scratch", + "schema": "public", + "data": { "name": "smoke-test", "value": 42 }, + }), + ); + assert_eq!(affected, json!(1), "insert should affect exactly one row"); + + let result = plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "SELECT name, value FROM live_db_test_scratch", + }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], json!(["smoke-test", 42])); +} + +#[test] +fn connection_string_connects_with_no_discrete_fields() { + let mut plugin = Plugin::spawn(); + let p = conn_params(); + let conn_str = format!( + "postgres://{}:{}@{}:{}/{}", + p["username"].as_str().unwrap(), + p["password"].as_str().unwrap(), + p["host"].as_str().unwrap(), + p["port"].as_u64().unwrap(), + p["database"].as_str().unwrap(), + ); + + plugin.call_ok( + "test_connection", + json!({ "params": { "connection_string": conn_str } }), + ); +} + +#[test] +fn startup_script_runs_on_every_pooled_connection() { + let mut plugin = Plugin::spawn(); + let mut params = conn_params(); + params["startup_script"] = json!("SET search_path = public, pg_catalog"); + + plugin.call_ok("test_connection", json!({ "params": params })); + + let result = plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "SHOW search_path" }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + let search_path = rows[0][0].as_str().unwrap(); + assert!( + search_path.contains("public"), + "startup_script's SET search_path should have taken effect, got: {search_path}" + ); +} + +#[test] +fn broken_startup_script_fails_fast_with_clear_attribution() { + let mut plugin = Plugin::spawn(); + let mut params = conn_params(); + // Use a host/port/database unique to this test so it can't reuse a + // pool already cached (and validated) by another test in this file — + // the pool cache key folds in startup_script, but a fresh identity is + // the clearest way to guarantee a first-use preflight actually runs. + params["startup_script"] = json!("THIS IS NOT VALID SQL"); + + let response = plugin.call("test_connection", json!({ "params": params })); + let error = response + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .expect("a broken startup script must produce a JSON-RPC error"); + assert!( + error.starts_with("Startup script failed:"), + "error should be clearly attributed to the startup script, got: {error}" + ); +} From 34b57165b397f431058539894b5829681f61bc43 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Thu, 6 Aug 2026 20:05:19 -0400 Subject: [PATCH 10/10] Fix the 3 CI jobs that failed on their first real run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three new-CI-job failures were verified locally before this fix: 1. Test job: the existing `cargo test` (no target filter) also picked up tests/live_db.rs, which panics immediately ("POSTGRES_PLUGIN_BIN must be set") without a running PostgreSQL instance — that job has no service container. Scoped to `cargo test --lib --bins`; the live-DB test stays exclusively in the dedicated live-db-integration job, which passed on the same run. 2. Markdown lint job: docs/planning/.markdownlint.json's MD024/MD060 overrides only applied when markdownlint-cli was invoked from inside that directory (how it was tested locally throughout this session) — the CI step's root-level `**/*.md` glob never picked up the scoped config, so the ~100 pre-existing violations in the imported planning docs (already known and previously handled) surfaced again. Merged both configs into one root .markdownlint.json rather than keeping two files. Also added .markdownlintignore for target/, since the same glob was incidentally linting a vendored README copied into build output by rust_decimal's build script — harmless locally (no target/ exists in a fresh CI checkout) but wrong regardless, and would trip up anyone running the same command locally. 3. Security audit job: cargo-audit correctly found RUSTSEC-2026-0235 (a real vulnerability in rkyv 0.7.46) — reachable only because rust_decimal declares rkyv as an optional dependency behind a feature we never enable (only "db-tokio-postgres" and "serde" are on). cargo audit scans the full Cargo.lock graph regardless of active features, so this is a lockfile-only entry with no path into what we actually ship — confirmed via `nm -D target/release/postgresql-plugin | grep rkyv`, zero symbols. Added a documented `ignore:` entry for that specific advisory ID rather than suppressing the whole job; the unrelated rustls-pemfile "unmaintained" warning surfaces as a log warning but was never what failed the job. Verified: cargo test --lib --bins (78/78), markdownlint-cli "**/*.md" from the repo root (matching the CI step exactly), and tests/live_db.rs (6/6, against the live PostgreSQL instance) all pass locally with these fixes in place. No Cargo.lock drift. --- .github/workflows/ci.yml | 16 +++++++++++++++- .markdownlint.json | 4 +++- .markdownlintignore | 2 ++ CHANGELOG.md | 25 +++++++++++++++++++++++++ docs/planning/.markdownlint.json | 5 ----- 5 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 .markdownlintignore delete mode 100644 docs/planning/.markdownlint.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 858536b..b58067d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,10 @@ jobs: run: cargo build - name: Run tests - run: cargo test + # --lib --bins excludes tests/live_db.rs: that's a live-database + # integration test requiring a running PostgreSQL instance, covered + # by the dedicated live-db-integration job below. + run: cargo test --lib --bins - name: Clippy run: cargo clippy --all-targets -- -D warnings @@ -59,6 +62,17 @@ jobs: - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} + # RUSTSEC-2026-0235: vulnerable rkyv 0.7.46, pulled in transitively + # by rust_decimal's own optional "rkyv" feature declaration in its + # Cargo.toml — we never enable that feature (only "db-tokio-postgres" + # and "serde"), and confirmed no rkyv symbols are linked into the + # release binary (`nm -D target/release/postgresql-plugin | grep + # rkyv` — no output). cargo-audit scans the full Cargo.lock graph + # regardless of which optional features are active, so this is a + # lockfile-only entry with no reachable code path in what we ship. + # Re-check this ignore whenever rust_decimal is upgraded, in case a + # newer release changes what's declared as optional. + ignore: RUSTSEC-2026-0235 live-db-integration: name: Live PostgreSQL integration diff --git a/.markdownlint.json b/.markdownlint.json index 1dd8622..f0dfe2e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,6 +1,8 @@ { "default": true, "MD013": false, + "MD024": { "siblings_only": true }, "MD033": false, - "MD041": false + "MD041": false, + "MD060": false } diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..297fdef --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +target/ +node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa1113..0a98350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,31 @@ SBOM generation via `cargo-cyclonedx` — were considered and deliberately deferred rather than implemented now; see `docs/planning/ci-hardening-deferred.md` for the rationale. + +### Fixed + +- Three of the new CI jobs above failed on their first real run and were + fixed: + - `Test` job: the existing `cargo test` (no target filter) tried to run + `tests/live_db.rs` too, which panics immediately without a running + PostgreSQL instance. Scoped to `cargo test --lib --bins`, leaving the + live-DB test to its own dedicated `live-db-integration` job. + - `Markdown lint` job: `docs/planning/.markdownlint.json`'s scoped + override (`MD024`/`MD060`) only applied when markdownlint was invoked + from within that directory — the CI step's root-level `**/*.md` glob + never picked it up. Merged the scoped overrides into the single root + `.markdownlint.json` instead of maintaining two config files. Also + added `.markdownlintignore` (`target/`) since the glob was + incidentally linting vendored third-party docs copied into build + output by a dependency's build script. + - `Security audit` job: `cargo audit` correctly found a real advisory, + RUSTSEC-2026-0235 (vulnerable `rkyv` 0.7.46) — but it's pulled in only + because `rust_decimal` lists it as an optional dependency behind a + feature (`rkyv`) we never enable; confirmed no `rkyv` symbols are + linked into the release binary. Added a documented `ignore:` entry for + that specific advisory ID, since `cargo audit` scans the full + `Cargo.lock` graph regardless of which optional features are active. + - `main.rs` rewritten to a worker-pool architecture (4 workers + a single writer task + a dedicated pool-cleanup task, coordinated via a `tokio::sync::watch` shutdown signal on stdin EOF), matching the diff --git a/docs/planning/.markdownlint.json b/docs/planning/.markdownlint.json deleted file mode 100644 index 7bf87b1..0000000 --- a/docs/planning/.markdownlint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "MD013": false, - "MD024": { "siblings_only": true }, - "MD060": false -}