feat(dynamodb): support querying, editing, and exporting

This commit is contained in:
zipg
2026-08-17 03:27:44 +08:00
committed by GitHub
parent 92ab9bb0dc
commit e97c9ab5d7
73 changed files with 4493 additions and 134 deletions
+4 -4
View File
@@ -337,8 +337,8 @@ jobs:
RUSTC_WRAPPER: sccache
# The fast lane skips only system font discovery while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
@@ -416,8 +416,8 @@ jobs:
RUSTC_WRAPPER: sccache
# The fast lane skips only system font discovery while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
RUST_FAST_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher
RUST_FULL_FEATURES: dbx/duckdb-sidecar,dbx/dynamodb,dbx/mq-admin,dbx/sqlite-sqlcipher,dbx/system-fonts,dbx-core/duckdb-sidecar,dbx-core/dynamodb,dbx-core/mq-admin,dbx-core/sqlite-sqlcipher,dbx-core/system-fonts,dbx-web/duckdb-sidecar,dbx-web/dynamodb,dbx-web/mq-admin,dbx-web/sqlite-sqlcipher,dbx-web/system-fonts
# Fork PRs cannot read repository secrets, so retain the GHA backend for them.
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
steps:
+1 -1
View File
@@ -535,7 +535,7 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
run: |
cargo zigbuild --release -p dbx-web --target ${{ matrix.target }} \
--no-default-features --features "duckdb-sidecar,mq-admin"
--no-default-features --features "duckdb-sidecar,dynamodb,mq-admin"
- name: Package static browser runtime
env:
Generated
+497 -48
View File
@@ -481,6 +481,18 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-credential-types"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"zeroize",
]
[[package]]
name = "aws-lc-rs"
version = "1.17.3"
@@ -505,6 +517,307 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "aws-runtime"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-http 0.64.0",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"bytes-utils",
"fastrand",
"http 1.5.0",
"http-body 1.1.0",
"percent-encoding",
"pin-project-lite",
"tracing",
"uuid",
]
[[package]]
name = "aws-sdk-dynamodb"
version = "1.104.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f04c47115cc8d46dcc94a9a81e7a3384cea859283c1a737729691d4221f11584"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http 0.63.6",
"aws-smithy-json",
"aws-smithy-observability 0.2.6",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sigv4"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f"
dependencies = [
"aws-credential-types",
"aws-smithy-http 0.64.0",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"form_urlencoded",
"hex",
"hmac 0.13.0",
"http 0.2.12",
"http 1.5.0",
"percent-encoding",
"sha2 0.11.0",
"time",
"tracing",
]
[[package]]
name = "aws-smithy-async"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
dependencies = [
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "aws-smithy-http"
version = "0.63.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"bytes-utils",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tracing",
]
[[package]]
name = "aws-smithy-http"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"bytes-utils",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tracing",
]
[[package]]
name = "aws-smithy-http-client"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
"h2 0.4.15",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper 1.11.0",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.9",
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tower",
"tracing",
]
[[package]]
name = "aws-smithy-json"
version = "0.62.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema 0.1.0",
"aws-smithy-types",
]
[[package]]
name = "aws-smithy-observability"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c"
dependencies = [
"aws-smithy-runtime-api",
]
[[package]]
name = "aws-smithy-observability"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c"
dependencies = [
"aws-smithy-runtime-api",
]
[[package]]
name = "aws-smithy-runtime"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
dependencies = [
"aws-smithy-async",
"aws-smithy-http 0.64.0",
"aws-smithy-http-client",
"aws-smithy-observability 0.3.0",
"aws-smithy-runtime-api",
"aws-smithy-schema 0.2.0",
"aws-smithy-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0",
"http-body-util",
"pin-project-lite",
"pin-utils",
"tokio",
"tracing",
]
[[package]]
name = "aws-smithy-runtime-api"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
"aws-smithy-types",
"bytes",
"http 0.2.12",
"http 1.5.0",
"pin-project-lite",
"tokio",
"tracing",
"zeroize",
]
[[package]]
name = "aws-smithy-runtime-api-macros"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "aws-smithy-schema"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"http 1.5.0",
]
[[package]]
name = "aws-smithy-schema"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
"http 1.5.0",
]
[[package]]
name = "aws-smithy-types"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
dependencies = [
"base64-simd",
"bytes",
"bytes-utils",
"futures-core",
"http 0.2.12",
"http 1.5.0",
"http-body 0.4.6",
"http-body 1.1.0",
"http-body-util",
"itoa",
"num-integer",
"pin-project-lite",
"pin-utils",
"ryu",
"serde",
"time",
"tokio",
"tokio-util",
]
[[package]]
name = "aws-types"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-schema 0.2.0",
"aws-smithy-types",
"rustc_version",
"tracing",
]
[[package]]
name = "axum"
version = "0.8.9"
@@ -516,10 +829,10 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper",
"hyper 1.11.0",
"hyper-util",
"itoa",
"matchit",
@@ -550,8 +863,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"mime",
"pin-project-lite",
@@ -585,6 +898,16 @@ version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
[[package]]
name = "base64-simd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
dependencies = [
"outref",
"vsimd",
]
[[package]]
name = "base64ct"
version = "1.8.3"
@@ -872,6 +1195,16 @@ dependencies = [
"serde",
]
[[package]]
name = "bytes-utils"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
dependencies = [
"bytes",
"either",
]
[[package]]
name = "bzip2"
version = "0.6.1"
@@ -1725,6 +2058,7 @@ dependencies = [
"arc-swap",
"argon2",
"async-trait",
"aws-sdk-dynamodb",
"base64 0.22.1",
"bytes",
"calamine",
@@ -3106,6 +3440,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "h2"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.15"
@@ -3117,7 +3470,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http",
"http 1.5.0",
"indexmap 2.14.0",
"slab",
"tokio",
@@ -3331,6 +3684,17 @@ dependencies = [
"markup5ever",
]
[[package]]
name = "http"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -3341,6 +3705,17 @@ dependencies = [
"itoa",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.1.0"
@@ -3348,7 +3723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
"http 1.5.0",
]
[[package]]
@@ -3359,8 +3734,8 @@ checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http 1.5.0",
"http-body 1.1.0",
"pin-project-lite",
]
@@ -3394,6 +3769,30 @@ dependencies = [
"zeroize",
]
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.11.0"
@@ -3404,9 +3803,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"h2 0.4.15",
"http 1.5.0",
"http-body 1.1.0",
"httparse",
"httpdate",
"itoa",
@@ -3416,16 +3815,32 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"log",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"http 1.5.0",
"hyper 1.11.0",
"hyper-util",
"rustls 0.23.43",
"rustls-native-certs 0.8.4",
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
@@ -3440,7 +3855,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper 1.11.0",
"hyper-util",
"native-tls",
"tokio",
@@ -3458,14 +3873,14 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"http 1.5.0",
"http-body 1.1.0",
"hyper 1.11.0",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.5",
"system-configuration",
"tokio",
"tower-service",
@@ -3773,7 +4188,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"socket2 0.6.5",
"widestring",
"windows-registry 0.6.1",
"windows-result 0.4.1",
@@ -4502,7 +4917,7 @@ dependencies = [
"serde_with",
"sha1 0.11.0",
"sha2 0.11.0",
"socket2",
"socket2 0.6.5",
"stringprep",
"strsim",
"take_mut",
@@ -4567,7 +4982,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"http 1.5.0",
"httparse",
"memchr",
"mime",
@@ -4611,7 +5026,7 @@ dependencies = [
"rand 0.10.2",
"rustls 0.23.43",
"serde",
"socket2",
"socket2 0.6.5",
"thiserror 2.0.19",
"tokio",
"tokio-rustls 0.26.4",
@@ -5234,6 +5649,12 @@ dependencies = [
"thiserror 2.0.19",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.14.0-rc.7"
@@ -5559,6 +5980,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.5"
@@ -5985,7 +6412,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.43",
"socket2",
"socket2 0.6.5",
"thiserror 2.0.19",
"tokio",
"tracing",
@@ -6023,7 +6450,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -6208,7 +6635,7 @@ dependencies = [
"rustls-native-certs 0.8.4",
"ryu",
"sha1_smol",
"socket2",
"socket2 0.6.5",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
@@ -6278,6 +6705,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -6304,12 +6737,12 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"h2 0.4.15",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper 1.11.0",
"hyper-rustls 0.27.9",
"hyper-tls",
"hyper-util",
"js-sys",
@@ -6350,11 +6783,11 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper 1.11.0",
"hyper-rustls 0.27.9",
"hyper-util",
"js-sys",
"log",
@@ -6525,7 +6958,7 @@ dependencies = [
"bytes",
"flume",
"futures-util",
"http",
"http 1.5.0",
"log",
"rustls-native-certs 0.7.3",
"rustls-pemfile 2.2.0",
@@ -7558,6 +7991,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -7995,7 +8438,7 @@ dependencies = [
"glob",
"gtk",
"heck 0.5.0",
"http",
"http 1.5.0",
"jni 0.21.1",
"libc",
"log",
@@ -8264,7 +8707,7 @@ dependencies = [
"dirs",
"flate2",
"futures-util",
"http",
"http 1.5.0",
"infer",
"log",
"minisign-verify",
@@ -8311,7 +8754,7 @@ dependencies = [
"cookie",
"dpi",
"gtk",
"http",
"http 1.5.0",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
@@ -8334,7 +8777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [
"gtk",
"http",
"http 1.5.0",
"jni 0.21.1",
"log",
"objc2",
@@ -8366,7 +8809,7 @@ dependencies = [
"dom_query",
"dunce",
"glob",
"http",
"http 1.5.0",
"infer",
"json-patch",
"log",
@@ -8625,7 +9068,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.5",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -8670,7 +9113,7 @@ dependencies = [
"postgres-protocol",
"postgres-types",
"rand 0.10.2",
"socket2",
"socket2 0.6.5",
"tokio",
"tokio-util",
"whoami",
@@ -8908,8 +9351,8 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"http-range-header",
"httpdate",
@@ -9048,7 +9491,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"http 1.5.0",
"httparse",
"log",
"rand 0.8.7",
@@ -9068,7 +9511,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"http 1.5.0",
"httparse",
"log",
"rand 0.9.5",
@@ -9358,6 +9801,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "vswhom"
version = "0.1.0"
@@ -10393,7 +10842,7 @@ dependencies = [
"dunce",
"gdkx11",
"gtk",
"http",
"http 1.5.0",
"javascriptcore-rs",
"jni 0.21.1",
"libc",
+1 -1
View File
@@ -75,7 +75,7 @@ dev: node_modules/.modules.yaml check-tauri-dev-port
$(PNPM) dev:tauri
dev-fast: node_modules/.modules.yaml check-tauri-dev-port
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar
$(PNPM) tauri dev -- --no-default-features --features duckdb-sidecar,dynamodb
dev-web: node_modules/.modules.yaml
$(PNPM) dev:web
@@ -0,0 +1 @@
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="a" x1="-15.53" y1="90.53" x2="90.53" y2="-15.53" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.70667 0 0 1.70667 0 0)"><stop offset="0" stop-color="#2e27ad"/><stop offset="1" stop-color="#527fff"/></linearGradient></defs><path fill="url(#a)" d="M0 0h128v128H0z"/><path d="M56.745 21.266c-14.062 0-29.013 4.164-29.013 11.947v13.736a6.93 6.93 0 002.577 5.037 6.946 6.946 0 00-2.577 5.12v13.653a6.93 6.93 0 002.577 5.12A6.946 6.946 0 0027.732 81v13.654a2.133 2.133 0 000 .356c.46 7.493 15.138 11.52 29.013 11.52 13.876 0 28.553-4.027 29.014-11.553a1.28 1.28 0 000-.36V80.963a6.827 6.827 0 00-2.577-5.12 6.93 6.93 0 002.577-5.12v-1.637l-11.28 11.263a27.7 27.7 0 005.836-2.373 4.403 4.403 0 012.03 3.073c0 3.124-6.62 6.521-16.45 7.904a72.073 72.073 0 01-9.15.646H55.04c-13.79-.324-23.894-4.676-23.894-8.533a4.403 4.403 0 012.034-3.07c5.359 2.987 14.043 4.76 23.566 4.76h.19l.954-3.413h-1.144c-9.386 0-18.088-1.862-22.713-4.797-1.843-1.212-2.87-2.523-2.887-3.667v-7.903c5.223 3.994 15.634 6.077 25.6 6.077 1.349 0 2.7 0 4.014-.12l.97-3.414c-1.707.12-3.294.17-4.984.17-14.643 0-25.6-4.488-25.6-8.533a4.403 4.403 0 012.034-3.073c4.71 2.645 11.944 4.3 20.153 4.676l.087-3.413c-8.073-.393-15.36-2.133-19.387-4.71-1.826-1.178-2.852-2.51-2.887-3.67v-7.883c5.223 3.993 15.634 6.076 25.6 6.076h.377l1.81-3.413h-2.187c-14.643 0-25.6-4.489-25.6-8.533 0-4.045 10.957-8.534 25.6-8.534a61.44 61.44 0 0114.644 1.707h9.37c-5.12-3.123-13.961-5.12-24.014-5.12zm12.8 8.517a1.707 1.707 0 00-1.516.936L57.789 51.2a1.707 1.707 0 001.516 2.527h9.66l-9.59 31.93a1.707 1.707 0 00.784 1.963 1.707 1.707 0 00.853.24 1.707 1.707 0 001.317-.58l37.546-39.253a1.707 1.707 0 00-1.23-2.883h-9.573l4.367-13.127a1.707 1.707 0 00-1.707-2.233zm.957 3.43h18.773l-4.37 13.106a1.707 1.707 0 001.707 2.254h7.953l-29.883 31.23 8.21-27.307a1.707 1.707 0 00-.273-1.5 1.707 1.707 0 00-1.367-.683h-9.18zm-34.237 11.62a2.133 2.133 0 00-2.133 2.133 2.133 2.133 0 002.133 2.133 2.133 2.133 0 002.134-2.133 2.133 2.133 0 00-2.134-2.133zm0 23.893a2.133 2.133 0 00-2.133 2.133 2.133 2.133 0 002.133 2.134 2.133 2.133 0 002.134-2.134 2.133 2.133 0 00-2.134-2.133zm-5.12 18.11c5.223 3.96 15.634 6.04 25.6 6.04 9.967 0 20.378-2.08 25.6-6.04v7.85c0 4.028-10.956 8.533-25.6 8.533-14.643 0-25.6-4.454-25.6-8.533zm5.12 5.783a2.133 2.133 0 00-2.133 2.134 2.133 2.133 0 002.133 2.133 2.133 2.133 0 002.134-2.133 2.133 2.133 0 00-2.134-2.134z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1075,6 +1075,14 @@ const driverProfiles: Record<
access: { type: "access", port: 0, user: "", label: "Microsoft Access", icon: "access" },
mongodb: { type: "mongodb", port: 27017, user: "", label: "MongoDB", icon: "mongodb" },
"mongodb-legacy": { type: "mongodb", port: 27017, user: "", label: "MongoDB (Legacy)", icon: "mongodb" },
dynamodb: {
type: "dynamodb",
port: 443,
user: "",
label: "Amazon DynamoDB",
icon: "dynamodb",
host: "dynamodb.us-east-1.amazonaws.com",
},
clickhouse: {
type: "clickhouse",
port: 8123,
@@ -2363,6 +2371,11 @@ function applyProfile(val: string, preserveConnectionFields = false) {
if (profile.type === "sqlite") {
form.value.database = undefined;
}
if (profile.type === "dynamodb") {
form.value.database = "us-east-1";
form.value.connection_string = undefined;
form.value.ssl = true;
}
if (profile.type === "h2") {
h2ConnectionMode.value = "file";
form.value.host = "";
@@ -2781,6 +2794,7 @@ const iconTypeMap: Record<string, string> = {
access: "access",
redis: "redis",
mongodb: "mongodb",
dynamodb: "dynamodb",
duckdb: "duckdb",
clickhouse: "clickhouse",
sqlserver: "sqlserver",
@@ -2871,6 +2885,7 @@ const dbOptions: DbOption[] = [
{ value: "opentenbase", label: "OpenTenBase" },
{ value: "mysql", label: "MySQL" },
{ value: "mongodb", label: "MongoDB" },
{ value: "dynamodb", label: "Amazon DynamoDB" },
{ value: "redis", label: "Redis" },
{ value: "oracle", label: "Oracle" },
{ value: "sqlite", label: "SQLite" },
@@ -2987,7 +3002,7 @@ const dbCategoryDefinitions: Array<{
{
key: "document",
titleKey: "connection.databaseCategoryDocument",
optionValues: ["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
optionValues: ["mongodb", "dynamodb", "redis", "elasticsearch", "easysearch", "meilisearch", "hbase", "manticoresearch", "cassandra"],
},
{
key: "graph_ai",
@@ -3799,6 +3814,15 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
throw new Error(t("connection.kingbaseDatabaseRequired"));
}
}
if (config.db_type === "dynamodb") {
config.database = config.database?.trim() || "us-east-1";
config.username = config.username.trim();
config.password = config.password.trim();
config.connection_string = config.connection_string?.trim() || undefined;
if (!config.username || !config.password) {
throw new Error(t("connection.dynamodbCredentialsRequired"));
}
}
if (config.db_type === "gaussdb") {
const serialized = serializeGaussdbHosts(gaussdbHostEntries.value);
config.host = serialized.host;
@@ -6659,6 +6683,38 @@ function openExternalUrl(url: string) {
</div>
</template>
<!-- DynamoDB: endpoint, region, AWS credentials -->
<template v-else-if="form.db_type === 'dynamodb'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbEndpoint") }}</Label>
<Input v-model="form.host" class="col-span-2" placeholder="dynamodb.us-east-1.amazonaws.com" />
<Input v-model.number="form.port" type="number" class="col-span-1" min="1" max="65535" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input v-model="form.ssl" type="checkbox" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbRegion") }}</Label>
<Input v-model="form.database" class="col-span-3" placeholder="us-east-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbAccessKeyId") }}</Label>
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.dynamodbSecretAccessKey") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.dynamodbSessionToken") }}</Label>
<PasswordInput v-model="form.connection_string" class="col-span-3" :placeholder="t('connection.dynamodbSessionTokenPlaceholder')" />
</div>
</template>
<!-- MongoDB: URL or form -->
<template v-else-if="form.db_type === 'mongodb'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -18,6 +18,7 @@ import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Switch } from "@/components/ui/switch";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
@@ -55,6 +56,7 @@ import {
type ElasticsearchQueryType,
} from "@/lib/app/documentStoreProvider";
import {
formatDocumentStoreIdLabel,
isDocumentStoreIdentityField,
normalizeDocumentStoreRouting,
parseDocumentStoreInputValue,
@@ -87,6 +89,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
@@ -95,6 +98,7 @@ import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
const { t } = useI18n();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const connectionStore = useConnectionStore();
@@ -108,6 +112,7 @@ const props = defineProps<{
type JsonRecord = Record<string, unknown>;
type ViewMode = "document" | "table";
const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
@@ -224,14 +229,15 @@ watch(
const pageTotal = computed(() => paginationTotal.value);
const documentPageCount = computed(() => (pageTotal.value === undefined ? undefined : Math.max(1, Math.ceil(pageTotal.value / pageSize.value))));
const canGoNextPage = computed(() =>
canGoNextDocumentPage({
const canGoNextPage = computed(() => {
if (documentStoreProvider.value.kind === "dynamodb") return dynamodbHasNextCursor.value;
return canGoNextDocumentPage({
page: page.value,
pageSize: pageSize.value,
rowCount: documents.value.length,
paginationTotal: pageTotal.value,
}),
);
});
});
const documentRequestLimit = computed(() => {
if (documentStoreProvider.value.kind !== "elasticsearch" || paginationTotal.value === undefined) return pageSize.value;
return documentPageRequestLimit(page.value, pageSize.value, paginationTotal.value);
@@ -244,6 +250,8 @@ const tableFindPaneStyle = computed(() => {
const documentFontStyle = computed(() => documentViewerFontStyle(settingsStore.editorSettings));
const documentStoreLabels = computed(() => ({
documentsLabel: documentStoreProvider.value.documentsLabel({ total: total.value ?? 0, totalIsExact: totalIsExact.value, t }),
filterInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.filter") : documentStoreProvider.value.filterInputLabel,
sortInputLabel: documentStoreProvider.value.kind === "dynamodb" ? t("dynamodb.sortKey") : documentStoreProvider.value.sortInputLabel,
queryPreview: documentQueryPreview.value,
}));
@@ -269,6 +277,39 @@ const documentFilterFieldSearch = ref<Record<string, string>>({});
const documentFilterRules = ref<DocumentFilterRule[]>([]);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const dynamodbTableDescription = ref<DynamoDbTableDescription | null>(null);
const dynamodbIndexName = ref("__table__");
const dynamodbPageCursors = ref<Array<string | undefined>>([undefined]);
const dynamodbHasNextCursor = ref(false);
const dynamodbExactTotal = ref<number | undefined>();
let dynamodbExactCountKey: string | null = null;
const dynamodbIndexOptions = computed<Array<{ value: string; label: string; index?: DynamoDbIndexInfo }>>(() => [
{ value: "__table__", label: t("dynamodb.baseTable") },
...(dynamodbTableDescription.value?.indexes ?? []).map((index) => ({
value: index.name,
label: `${index.name} (${index.kind === "global" ? "GSI" : "LSI"} · ${index.projectionType})`,
index,
})),
]);
const dynamodbSelectedIndex = computed(() => {
if (dynamodbIndexName.value === "__table__") return undefined;
return dynamodbTableDescription.value?.indexes.find((index) => index.name === dynamodbIndexName.value);
});
const dynamodbPartialProjectionReadOnly = computed(() => documentStoreProvider.value.kind === "dynamodb" && !!dynamodbSelectedIndex.value && dynamodbSelectedIndex.value.projectionType !== "ALL");
const documentStoreEditable = computed(() => !dynamodbPartialProjectionReadOnly.value);
const documentStoreEditDisabledReason = computed(() => (dynamodbPartialProjectionReadOnly.value ? t("dynamodb.partialProjectionReadOnly", { projection: dynamodbSelectedIndex.value?.projectionType ?? "UNKNOWN" }) : undefined));
const dynamodbSelectedKey = computed(() => {
const table = dynamodbTableDescription.value;
if (!table) return null;
if (dynamodbIndexName.value === "__table__") {
return { partitionKey: table.partitionKey, sortKey: table.sortKey };
}
return dynamodbSelectedIndex.value ?? null;
});
const pendingDelete = ref<PendingDelete | null>(null);
const documentFilterComposingEditors = new Set<string>();
@@ -281,9 +322,7 @@ const selectedDoc = computed(() => {
});
const selectedDocumentIdLabel = computed(() => {
if (isNew.value) return "New";
const id = selectedDoc.value?._id;
if (id === undefined || id === null) return "";
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
return formatDocumentStoreIdLabel(selectedDoc.value?._id, documentStoreProvider.value.kind);
});
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
@@ -299,6 +338,12 @@ const deleteDetails = computed(() => {
if (!pending) return "";
if (pending.kind === "document") {
const id = documents.value[pending.index]?._id ?? "";
if (documentStoreProvider.value.kind === "dynamodb") {
return t("dynamodb.documentDetails", {
table: props.collection,
id: formatDocumentStoreIdLabel(id, "dynamodb"),
});
}
const displayId = mongoDocumentIdForGrid(id);
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
@@ -346,8 +391,9 @@ const gridResult = computed<QueryResult>(() => {
return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
if (documentStoreProvider.value.kind !== "mongodb") return undefined;
async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
const kind = documentStoreProvider.value.kind;
if (kind !== "mongodb" && kind !== "dynamodb") return undefined;
const connectionId = props.connectionId;
const database = props.database;
@@ -356,21 +402,23 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
const sort = currentDocumentSortJson(sortInput.value);
const exportSettings = settingsStore.editorSettings;
const batchSize = Math.max(1, Math.trunc(exportSettings.exportBatchSize));
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : Number.POSITIVE_INFINITY;
const rowLimit = exportSettings.exportRowLimitEnabled ? Math.max(0, Math.trunc(exportSettings.exportRowLimit)) : kind === "dynamodb" ? DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT : Number.POSITIVE_INFINITY;
const exportExecutionId = uuid();
const exportStartedAt = performance.now();
const exportedDocuments: JsonRecord[] = [];
let exportedCopyDocuments: JsonRecord[] | undefined = [];
let exportedCopyDocuments: JsonRecord[] | undefined = kind === "mongodb" ? [] : undefined;
let totalRows: number | null = null;
let cursor: string | undefined;
const seenCursors = new Set<string>();
while (exportedDocuments.length < rowLimit) {
const requestLimit = Math.min(batchSize, rowLimit - exportedDocuments.length);
const requestLimit = Math.min(batchSize, kind === "dynamodb" ? 1000 : Number.POSITIVE_INFINITY, rowLimit - exportedDocuments.length);
if (requestLimit <= 0) break;
const result = await api.documentFindDocuments(connectionId, database, collection, exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId);
const result = await api.documentFindDocuments(connectionId, database, collection, kind === "dynamodb" ? 0 : exportedDocuments.length, requestLimit, filter, undefined, sort, undefined, exportExecutionId, cursor);
const pageDocuments = result.documents.slice(0, requestLimit).map(asRecord);
exportedDocuments.push(...pageDocuments);
if (exportedCopyDocuments) {
if (kind === "mongodb" && exportedCopyDocuments) {
if (result.extended_documents?.length === result.documents.length) {
exportedCopyDocuments.push(...result.extended_documents.slice(0, pageDocuments.length).map(asRecord));
} else {
@@ -378,19 +426,36 @@ async function exportAllMongoDocuments(onProgress?: (info: { rowsExported: numbe
}
}
if (result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
if (kind === "mongodb" && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
if (kind === "dynamodb") {
cursor = result.next_cursor;
if (!cursor) break;
if (seenCursors.has(cursor)) throw new Error(t("dynamodb.repeatedCursor"));
seenCursors.add(cursor);
continue;
}
const reachedExactTotal = result.total_is_exact !== false && exportedDocuments.length >= result.total;
if (pageDocuments.length === 0 || pageDocuments.length < requestLimit || reachedExactTotal) break;
}
if (kind === "dynamodb") {
const truncatedByLimit = !!cursor && exportedDocuments.length >= rowLimit;
totalRows = truncatedByLimit ? null : exportedDocuments.length;
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
if (truncatedByLimit) {
toast(t("dynamodb.exportLimitReached", { count: rowLimit }), 6000);
}
}
const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null);
if (result.columns.length === 0) result.columns = gridResult.value.columns;
result.column_types = mongoDocumentGridColumnTypes(exportedDocuments, result.columns);
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : undefined;
result.affected_rows = exportedDocuments.length;
result.truncated = false;
result.has_more = false;
result.truncated = kind === "dynamodb" && !!cursor && exportedDocuments.length >= rowLimit;
result.has_more = result.truncated;
return result;
}
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
@@ -616,8 +681,38 @@ function resetDocumentFilterBuilder() {
documentFilterRules.value = documentFilterFieldOptions.value.length > 0 ? [createDocumentFilterRule()] : [];
}
function dynamodbCountFilterKey(filter: string | undefined): string {
return JSON.stringify([props.connectionId, props.collection, filter ?? ""]);
}
function resetDynamoDbExactCount() {
dynamodbExactCountKey = null;
dynamodbExactTotal.value = undefined;
}
function resetDynamoDbPagination(options: { preserveExactCount?: boolean } = {}) {
dynamodbPageCursors.value = [undefined];
dynamodbHasNextCursor.value = false;
paginationTotal.value = undefined;
if (!options.preserveExactCount) resetDynamoDbExactCount();
}
function currentDocumentFilter(): string | undefined {
return currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
const filter = currentDocumentFilterJson(filterInput.value, appliedDocumentFilter.value, documentStoreProvider.value.kind);
if (documentStoreProvider.value.kind !== "dynamodb" || dynamodbIndexName.value === "__table__") return filter;
const parsed = filter ? JSON.parse(filter) : {};
return JSON.stringify({ ...parsed, $index: dynamodbIndexName.value });
}
function selectDynamoDbIndex(value: unknown) {
const next = typeof value === "string" && value ? value : "__table__";
if (dynamodbIndexName.value === next) return;
dynamodbIndexName.value = next;
if (dynamodbPartialProjectionReadOnly.value && isEditing.value) cancelEdit();
sortInput.value = "";
page.value = 0;
resetDynamoDbPagination();
void load({ page: 0 });
}
function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
@@ -763,7 +858,30 @@ function documentStoreWriteApis(documentType?: string) {
};
}
function prepareDynamoDbDocumentIdentity(document: JsonRecord): { document: JsonRecord; id: string } {
const table = dynamodbTableDescription.value;
if (!table) throw new Error(t("dynamodb.tableMetadataUnavailable"));
const next = { ...document };
const metadataId = next._id && typeof next._id === "object" && !Array.isArray(next._id) ? (next._id as JsonRecord) : undefined;
const identity: JsonRecord = {};
const keys = [table.partitionKey, table.sortKey].filter((value): value is NonNullable<typeof value> => !!value);
for (const key of keys) {
const value = next[key.name] ?? metadataId?.[key.name];
if (value === undefined || value === null || value === "") {
throw new Error(t("dynamodb.keyRequired", { key: key.name }));
}
next[key.name] = value;
identity[key.name] = value;
}
next._id = identity;
return { document: next, id: serializeDocumentStoreId(identity, "dynamodb") };
}
async function gridSave(changes: DocumentGridChanges) {
if (!documentStoreEditable.value) {
throw new Error(documentStoreEditDisabledReason.value);
}
const cols = changes.columns;
const idColIdx = cols.indexOf("_id");
if (idColIdx < 0) throw new Error("No _id column");
@@ -818,8 +936,14 @@ async function gridSave(changes: DocumentGridChanges) {
if (!doc) continue;
const routing = isEs ? documentRoutingFromDocument(doc) : undefined;
const updated = buildPathIdentityUpdatedDocument(doc, dirtyCols, cols, kind);
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
const documentId = serializeDocumentStoreId(doc._id ?? id, kind);
if (kind === "dynamodb") {
const normalized = prepareDynamoDbDocumentIdentity(updated);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "update" });
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind));
continue;
}
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind, mode: "update" });
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, documentId, stringifyDocumentStoreValue(writeDocument, kind), routing);
continue;
}
@@ -848,7 +972,12 @@ async function gridSave(changes: DocumentGridChanges) {
const idValue = newRow[idColIdx];
const id = idValue === null || idValue === undefined || idValue === "" ? null : serializeDocumentStoreId(parseDocumentStoreInputValue(idValue, kind), kind);
const routing = isEs ? documentRoutingFromGridRow(newRow, cols) : undefined;
if (id) {
if (kind === "dynamodb") {
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, kind);
const normalized = prepareDynamoDbDocumentIdentity(doc);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind, mode: "insert" });
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(writeDocument, kind));
} else if (id) {
await api.documentUpdateDocument(props.connectionId, props.database, props.collection, id, stringifyDocumentStoreValue(doc, kind), routing);
} else {
await api.documentInsertDocument(props.connectionId, props.database, props.collection, stringifyDocumentStoreValue(doc, kind), routing);
@@ -861,6 +990,10 @@ async function gridSave(changes: DocumentGridChanges) {
}
if (isEs) resetElasticsearchTotals({ preservePaginationTotal: true });
if (kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
}
@@ -920,6 +1053,14 @@ function buildElasticsearchPartialUpdateDocument(changes: Map<number, MongoInput
return document;
}
function formatDynamoDbOperationPreview(action: "insert" | "put" | "delete", id: unknown, document?: Record<string, unknown>): string {
const operation = action === "insert" ? "INSERT ITEM" : action === "put" ? "PUT ITEM" : "DELETE ITEM";
const lines = [`DBX DYNAMODB ${operation}`, `table: ${JSON.stringify(props.collection)}`];
if (id !== undefined) lines.push("key:", stringifyDocumentStoreValue(id, "dynamodb", 2));
if (document) lines.push("item:", stringifyDocumentStoreValue(document, "dynamodb", 2));
return lines.join("\n");
}
async function previewDocumentChanges(changes: DocumentGridChanges): Promise<string[]> {
const { dirtyRows, deletedRows, newRows, newRowMeta, columns, rows } = changes;
const idColIdx = columns.indexOf("_id");
@@ -938,6 +1079,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const updateDoc = buildElasticsearchPartialUpdateDocument(dirtyCols, columns);
const routing = documentRoutingFromGridRow(row, columns);
stmts.push(`POST /${coll}/_update/${elasticsearchPathIdPreview(String(id))}${elasticsearchRoutingPreview(routing)}\n${stringifyDocumentStoreValue({ doc: updateDoc.$set ?? updateDoc }, "elasticsearch", 2)}`);
} else if (kind === "dynamodb") {
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
const documentId = sourceDocument._id ?? id;
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "dynamodb");
const normalized = prepareDynamoDbDocumentIdentity(updated);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "update" });
stmts.push(formatDynamoDbOperationPreview("put", documentId, writeDocument));
} else {
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
@@ -957,6 +1106,10 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const id = row?.[idColIdx];
if (id == null) continue;
if (isPathIdentityStore) {
if (kind === "dynamodb") {
stmts.push(formatDynamoDbOperationPreview("delete", documents.value[rowIdx]?._id ?? id));
continue;
}
if (!isEs) {
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
continue;
@@ -971,6 +1124,14 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
for (const [newRowIndex, newRow] of newRows.entries()) {
const doc = isPathIdentityStore ? buildPathIdentityInsertDocument(newRow, columns, kind) : buildMongoGridInsertDocument(newRow, columns, newRowMeta[newRowIndex]);
if (isPathIdentityStore) {
if (kind === "dynamodb") {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : undefined;
if (idValue !== null && idValue !== undefined && idValue !== "") doc._id = parseDocumentStoreInputValue(idValue, "dynamodb");
const normalized = prepareDynamoDbDocumentIdentity(doc);
const writeDocument = prepareDocumentStoreWriteDocument(normalized.document, { kind: "dynamodb", mode: "insert" });
stmts.push(formatDynamoDbOperationPreview("insert", JSON.parse(normalized.id), writeDocument));
continue;
}
if (!isEs) {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
@@ -1132,13 +1293,24 @@ async function load(options: { page?: number } = {}) {
const collection = props.collection;
const storeKind = documentStoreProvider.value.kind;
const filter = currentDocumentFilter();
if (storeKind === "dynamodb") {
const countKey = dynamodbCountFilterKey(filter);
if (dynamodbExactCountKey !== countKey) {
dynamodbExactCountKey = countKey;
dynamodbExactTotal.value = undefined;
}
}
const countRequest: LoadedDocumentQueryTotalCountRequest = { connectionId, database, collection, filter, generation: requestGeneration, storeKind };
if (storeKind === "elasticsearch" && elasticsearchCountKey !== null && elasticsearchCountKey !== elasticsearchCountFilterKey(filter)) {
resetElasticsearchTotals();
}
const sort = currentDocumentSortJson(sortInput.value);
const skip = requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId);
const cursor = storeKind === "dynamodb" ? dynamodbPageCursors.value[requestPage] : undefined;
if (storeKind === "dynamodb" && requestPage > 0 && !cursor) {
throw new Error(t("dynamodb.pageCursorUnavailable"));
}
const skip = storeKind === "dynamodb" ? 0 : requestPage * pageSize.value;
const result = await api.documentFindDocuments(connectionId, database, collection, skip, documentRequestLimit.value, filter, undefined, sort, undefined, executionId, cursor);
if (documentLoadExecutionId.value !== executionId) return;
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
const nextDocuments =
@@ -1159,6 +1331,12 @@ async function load(options: { page?: number } = {}) {
copyDocuments.value = nextCopyDocuments;
mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments;
loadedDocumentQueryTotalCountRequest = countRequest;
if (storeKind === "dynamodb") {
const nextCursors = dynamodbPageCursors.value.slice(0, requestPage + 1);
nextCursors[requestPage + 1] = result.next_cursor;
dynamodbPageCursors.value = nextCursors;
dynamodbHasNextCursor.value = !!result.next_cursor;
}
if (nextDocuments.length > 0) {
const keySet = new Set<string>();
keySet.add("_id");
@@ -1172,6 +1350,13 @@ async function load(options: { page?: number } = {}) {
}
if (storeKind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
} else if (storeKind === "dynamodb") {
cancelElasticsearchCount();
const lowerBound = requestPage * pageSize.value + nextDocuments.length + (result.next_cursor ? 1 : 0);
const exactTotal = dynamodbExactTotal.value ?? (!result.next_cursor ? lowerBound : undefined);
total.value = exactTotal ?? lowerBound;
totalIsExact.value = exactTotal !== undefined;
paginationTotal.value = exactTotal;
} else {
cancelElasticsearchCount();
const totals = resolveDocumentQueryTotals(result.total, result.total_is_exact !== false, {
@@ -1212,11 +1397,15 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
const exactCount = await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
const exactCount = request.storeKind === "dynamodb" ? await api.documentCountDocuments(request.connectionId, request.collection, request.filter) : await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
}
if (request.storeKind === "dynamodb") {
dynamodbExactCountKey = dynamodbCountFilterKey(request.filter);
dynamodbExactTotal.value = exactCount;
}
const totals = resolveDocumentQueryTotals(exactCount, true);
total.value = totals.total;
totalIsExact.value = totals.totalIsExact;
@@ -1226,6 +1415,7 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
async function refreshDocuments() {
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbExactCount();
await load();
}
@@ -1248,20 +1438,49 @@ async function cancelDocumentLoad() {
function applyFilter() {
page.value = 0;
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals();
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination();
void load();
}
function paginate(offset: number, limit: number) {
async function paginate(offset: number, limit: number) {
const normalizedLimit = normalizeResultPageSize(limit, pageSize.value);
const pageSizeChanged = normalizedLimit !== pageSize.value;
pageSize.value = normalizedLimit;
if (documentStoreProvider.value.kind === "dynamodb" && pageSizeChanged) {
page.value = 0;
resetDynamoDbPagination({ preserveExactCount: true });
await load({ page: 0 });
return;
}
const requestedPage = Math.floor(Math.max(0, offset) / normalizedLimit);
const nextPage = clampDocumentPage(requestedPage, normalizedLimit, paginationTotal.value);
void load({ page: nextPage });
if (documentStoreProvider.value.kind !== "dynamodb") {
await load({ page: nextPage });
return;
}
for (let cursorPage = 0; cursorPage <= nextPage; cursorPage += 1) {
if (cursorPage > 0 && !dynamodbPageCursors.value[cursorPage]) {
error.value = t("dynamodb.pageCursorUnavailable");
return;
}
if (cursorPage === nextPage) {
await load({ page: cursorPage });
return;
}
if (!dynamodbPageCursors.value[cursorPage + 1]) {
await load({ page: cursorPage });
}
}
}
function onSort(column: string, _columnIndex: number, direction: "asc" | "desc" | null) {
if (documentStoreProvider.value.kind === "dynamodb" && direction && dynamodbSelectedKey.value?.sortKey?.name !== column) {
error.value = t("dynamodb.sortKeyOnly", { key: dynamodbSelectedKey.value?.sortKey?.name || t("dynamodb.none") });
return;
}
sortInput.value = documentStoreProvider.value.sortInputForColumn(column, direction);
page.value = 0;
if (documentStoreProvider.value.kind === "dynamodb") resetDynamoDbPagination({ preserveExactCount: true });
void load();
}
@@ -1275,7 +1494,7 @@ function asRecord(value: unknown): JsonRecord {
function documentIdentity(doc: JsonRecord | undefined): string | null {
const id = doc?._id;
if (id === null || id === undefined) return null;
return typeof id === "object" ? JSON.stringify(id) : String(id);
return serializeDocumentStoreId(id, documentStoreProvider.value.kind);
}
function syncSelectedDocumentAfterLoad(previousSelectedIdx: number | null, previousSelectedId: string | null) {
@@ -1385,6 +1604,7 @@ function selectDoc(idx: number) {
}
function startNew() {
if (!documentStoreEditable.value) return;
selectedIdx.value = null;
editJson.value = emptyDocumentJson();
editFields.value = [createEditNode("", "", false, false)];
@@ -1395,6 +1615,7 @@ function startNew() {
}
function startEdit() {
if (!documentStoreEditable.value) return;
const doc = selectedDoc.value;
if (!doc) return;
// Issue #2952: open whole-document JSON editing by default (DBeaver-style), not field tree.
@@ -1563,6 +1784,9 @@ function resolveDocumentStorePathId(id: unknown): string | null {
function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, currentRouting: string | undefined): { writeId: string; writeRouting?: string } | null {
const kind = documentStoreProvider.value.kind;
if (kind === "dynamodb") {
return { writeId: prepareDynamoDbDocumentIdentity(doc).id };
}
const hasPayloadId = Object.prototype.hasOwnProperty.call(doc, "_id");
const writeId = hasPayloadId ? resolveDocumentStorePathId(doc._id) : resolveDocumentStorePathId(currentId);
if (!writeId) return null;
@@ -1571,7 +1795,7 @@ function resolveWriteIdentityFromEditor(doc: JsonRecord, currentId: unknown, cur
}
async function saveDoc() {
if (isSavingDocument.value) return;
if (isSavingDocument.value || !documentStoreEditable.value) return;
error.value = "";
isSavingDocument.value = true;
try {
@@ -1579,13 +1803,14 @@ async function saveDoc() {
if (!doc) return;
const kind = documentStoreProvider.value.kind;
const writeDocument = kind === "dynamodb" ? prepareDynamoDbDocumentIdentity(doc).document : doc;
if (isNew.value) {
const apis = documentStoreWriteApis();
const explicitId = kind === "mongodb" || doc._id === undefined || doc._id === null || doc._id === "" ? null : resolveDocumentStorePathId(doc._id);
const explicitId = kind === "mongodb" || writeDocument._id === undefined || writeDocument._id === null || writeDocument._id === "" ? null : resolveDocumentStorePathId(writeDocument._id);
await insertDocumentStoreDocumentCore({
kind,
document: doc,
document: writeDocument,
explicitId,
routing: normalizeDocumentStoreRouting(doc._routing),
apis,
@@ -1605,7 +1830,7 @@ async function saveDoc() {
}
const currentRouting = documentRoutingFromDocument(current);
const apis = documentStoreWriteApis(kind === "elasticsearch" ? documentTypeFromDocument(current) : undefined);
const write = resolveWriteIdentityFromEditor(doc, currentId, currentRouting);
const write = resolveWriteIdentityFromEditor(writeDocument, currentId, currentRouting);
if (!write) {
error.value = t("mongo.jsonIdRequired");
return;
@@ -1615,8 +1840,7 @@ async function saveDoc() {
write: { id: write.writeId, routing: write.writeRouting },
current: { id: deleteId, routing: kind === "elasticsearch" ? currentRouting : undefined },
});
// Rekey writes first then deletes; write failure leaves the old document intact.
await applyDocumentStoreIdentityPlan({ kind, plan, document: doc, apis });
await applyDocumentStoreIdentityPlan({ kind, plan, document: writeDocument, apis });
} else {
return;
}
@@ -1626,6 +1850,10 @@ async function saveDoc() {
documentEditMode.value = "fields";
editFields.value = [];
if (kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
if (selectedIdx.value !== null && documents.value[selectedIdx.value]) {
editJson.value = stringifyDocumentStoreValue(documents.value[selectedIdx.value], documentStoreProvider.value.kind, 2);
@@ -1649,6 +1877,10 @@ async function applyDeleteDoc(idx: number) {
editJson.value = "";
}
if (documentStoreProvider.value.kind === "elasticsearch") resetElasticsearchTotals({ preservePaginationTotal: true });
if (documentStoreProvider.value.kind === "dynamodb") {
page.value = 0;
resetDynamoDbPagination();
}
await load();
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
@@ -1656,6 +1888,7 @@ async function applyDeleteDoc(idx: number) {
}
function requestDeleteDoc(idx: number) {
if (!documentStoreEditable.value) return;
if (!settingsStore.editorSettings.confirmDangerousSqlExecution) {
void applyDeleteDoc(idx);
return;
@@ -1688,7 +1921,7 @@ function nextPage() {
}
function docPreview(doc: JsonRecord): string {
const id = doc._id || "";
const id = formatDocumentStoreIdLabel(doc._id, documentStoreProvider.value.kind);
const keys = Object.keys(doc)
.filter((k) => k !== "_id")
.slice(0, 3);
@@ -1724,6 +1957,15 @@ watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
documentViewerSearchActive.value = false;
});
async function loadDynamoDbTableDescription() {
if (documentStoreProvider.value.kind !== "dynamodb") return;
try {
dynamodbTableDescription.value = await api.dynamodbDescribeTable(props.connectionId, props.collection);
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : String(e);
}
}
onMounted(async () => {
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
try {
@@ -1731,6 +1973,7 @@ onMounted(async () => {
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
await loadDynamoDbTableDescription();
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
@@ -1807,7 +2050,26 @@ defineExpose({ focusSearch });
<span class="shrink-0 ml-1">{{ documentStoreLabels.documentsLabel }}</span>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="startNew"><Plus class="h-3 w-3" /></Button>
<div v-if="documentStoreProvider.kind === 'dynamodb' && dynamodbTableDescription" class="ml-1 flex min-w-0 items-center gap-1.5">
<Select :model-value="dynamodbIndexName" @update:model-value="selectDynamoDbIndex">
<SelectTrigger class="h-6 w-44 min-w-0 text-xs" :title="t('dynamodb.index')">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in dynamodbIndexOptions" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<span v-if="dynamodbSelectedKey" class="max-w-64 truncate font-mono text-[11px] text-muted-foreground" :title="t('dynamodb.keySummary', { partitionKey: dynamodbSelectedKey.partitionKey.name, sortKey: dynamodbSelectedKey.sortKey?.name || t('dynamodb.none') })">
PK: {{ dynamodbSelectedKey.partitionKey.name }}<template v-if="dynamodbSelectedKey.sortKey"> · SK: {{ dynamodbSelectedKey.sortKey.name }}</template>
</span>
<Badge v-if="dynamodbPartialProjectionReadOnly" variant="outline" class="h-5 rounded border-amber-500/50 px-1.5 text-[10px] text-amber-600 dark:text-amber-400" :title="documentStoreEditDisabledReason">
{{ dynamodbSelectedIndex?.projectionType }}
</Badge>
</div>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startNew"><Plus class="h-3 w-3" /></Button>
<Button v-if="viewMode === 'document'" variant="ghost" size="icon" class="h-5 w-5" @click="refreshDocuments"><RefreshCw class="h-3 w-3" :class="{ 'animate-spin': loading }" /></Button>
<div v-if="viewMode === 'document'" class="flex items-center gap-1 ml-1">
@@ -1988,7 +2250,7 @@ defineExpose({ focusSearch });
context="results"
:database-type="props.databaseType"
:mongo-update-target="mongoUpdateTarget"
editable
:editable="documentStoreEditable"
:custom-save-handler="customSaveHandler"
:loading="loading"
:sql="documentStoreLabels.queryPreview"
@@ -1999,7 +2261,7 @@ defineExpose({ focusSearch });
:inexact-total-row-count-mode="documentStoreProvider.kind === 'mongodb' ? 'estimated' : 'at-least'"
:pagination-total-row-count="pageTotal"
:count-total-rows="countExactDocumentTotal"
:full-export-result="documentStoreProvider.kind === 'mongodb' ? exportAllMongoDocuments : undefined"
:full-export-result="documentStoreProvider.kind === 'mongodb' || documentStoreProvider.kind === 'dynamodb' ? exportAllDocumentStoreDocuments : undefined"
@sort="onSort"
@reload="refreshDocuments"
@paginate="(offset: number, limit: number) => paginate(offset, limit)"
@@ -2210,7 +2472,7 @@ defineExpose({ focusSearch });
</div>
</PopoverContent>
</Popover>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.filterInputLabel }}</span>
<textarea
ref="filterInputRef"
v-model="filterInput"
@@ -2249,7 +2511,7 @@ defineExpose({ focusSearch });
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreLabels.sortInputLabel }}</span>
<textarea
ref="sortInputRef"
v-model="sortInput"
@@ -2290,7 +2552,7 @@ defineExpose({ focusSearch });
<div class="flex-1 overflow-y-auto">
<div v-for="(doc, idx) in documents" :key="idx" class="px-3 py-1.5 border-b text-xs font-mono cursor-pointer hover:bg-accent/50 flex items-center gap-2 group" :class="{ 'bg-accent': selectedIdx === idx }" @click="selectDoc(idx)">
<span class="truncate flex-1">{{ docPreview(doc) }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" @click.stop="requestDeleteDoc(idx)">
<Button variant="ghost" size="icon" class="h-5 w-5 opacity-0 group-hover:opacity-100 text-destructive shrink-0" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click.stop="requestDeleteDoc(idx)">
<Trash2 class="w-3 h-3" />
</Button>
</div>
@@ -2310,7 +2572,7 @@ defineExpose({ focusSearch });
<input class="min-w-0 w-full cursor-text select-text appearance-none border-0 bg-transparent p-0 text-inherit outline-none focus:ring-0" :value="selectedDocumentIdLabel" :aria-label="`_id: ${selectedDocumentIdLabel}`" readonly spellcheck="false" />
</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" :disabled="!documentStoreEditable" :title="documentStoreEditDisabledReason" @click="startEdit">{{ t("mongo.edit") }}</Button>
<template v-if="isEditing">
<div class="flex items-center border rounded-md overflow-hidden mr-1">
<Button variant="ghost" size="sm" class="h-6 rounded-none px-2 text-xs" :class="{ 'bg-accent': documentEditMode === 'json' }" :disabled="isSavingDocument" @click="setDocumentEditMode('json')">{{ t("mongo.editModeJson") }}</Button>
@@ -6,8 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const backend = vi.hoisted(() => ({
getColumns: vi.fn(),
documentFindDocuments: vi.fn(),
documentCountDocuments: vi.fn(),
dynamodbDescribeTable: vi.fn(),
cancelQuery: vi.fn(),
ensureConnected: vi.fn(),
documentInsertDocument: vi.fn(),
documentUpdateDocument: vi.fn(),
documentDeleteDocument: vi.fn(),
documentSaveMeilisearchBatch: vi.fn(),
}));
@@ -24,6 +28,8 @@ const dataGrid = vi.hoisted(() => ({
column_types?: string[];
rows: Array<Array<string | number | boolean | null>>;
mongo_copy_documents?: unknown[];
truncated?: boolean;
has_more?: boolean;
}
| undefined
>)
@@ -31,8 +37,12 @@ const dataGrid = vi.hoisted(() => ({
customSaveHandler: undefined as
| {
save: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<void>;
preview?: (changes: { dirtyRows: Map<number, Map<number, unknown>>; deletedRows: Set<number>; newRows: unknown[][]; newRowMeta: unknown[]; columns: string[]; rows: unknown[][] }) => Promise<string[]>;
}
| undefined,
countTotalRows: undefined as (() => Promise<number | undefined>) | undefined,
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
editable: true,
}));
const settings = vi.hoisted(() => ({
@@ -54,13 +64,19 @@ const settings = vi.hoisted(() => ({
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => (key === "dynamodb.items" ? `${params?.count} Items` : key),
}),
}));
vi.mock("@/lib/backend/api", () => ({
getColumns: backend.getColumns,
documentFindDocuments: backend.documentFindDocuments,
documentCountDocuments: backend.documentCountDocuments,
dynamodbDescribeTable: backend.dynamodbDescribeTable,
cancelQuery: backend.cancelQuery,
documentInsertDocument: backend.documentInsertDocument,
documentUpdateDocument: backend.documentUpdateDocument,
documentDeleteDocument: backend.documentDeleteDocument,
documentSaveMeilisearchBatch: backend.documentSaveMeilisearchBatch,
}));
@@ -90,10 +106,14 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
columnLayoutScopeKey: { type: String, default: "" },
fullExportResult: { type: Function, default: undefined },
customSaveHandler: { type: Object, default: undefined },
countTotalRows: { type: Function, default: undefined },
editable: { type: Boolean, default: false },
},
setup(props, { expose, slots }) {
setup(props, { attrs, expose, slots }) {
dataGrid.fullExportResult = props.fullExportResult as typeof dataGrid.fullExportResult;
dataGrid.customSaveHandler = props.customSaveHandler as typeof dataGrid.customSaveHandler;
dataGrid.countTotalRows = props.countTotalRows as typeof dataGrid.countTotalRows;
dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate;
expose({
visibleColumnCount: 2,
displayableColumnCount: 2,
@@ -113,8 +133,9 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
multiRowTranspose: false,
setMultiRowTranspose: vi.fn(),
});
return () =>
h(
return () => {
dataGrid.editable = props.editable;
return h(
"div",
{
"data-testid": "data-grid",
@@ -124,6 +145,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
"data-result-column-types": JSON.stringify((props.result as { column_types?: string[] }).column_types ?? []),
"data-result-rows": JSON.stringify((props.result as { rows?: unknown[] }).rows ?? []),
"data-editable": String(props.editable),
},
[
slots["search-bar"]?.({
@@ -134,6 +156,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
}),
],
);
};
},
}),
};
@@ -215,8 +238,9 @@ vi.mock("@/components/ui/select", async () => {
props: {
modelValue: { type: String, default: "" },
},
setup(props, { slots }) {
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue }, slots.default?.());
emits: ["update:modelValue"],
setup(props, { emit, slots }) {
return () => h("div", { "data-testid": "select", "data-model-value": props.modelValue, onClick: () => props.modelValue === "__table__" && emit("update:modelValue", "by_status") }, slots.default?.());
},
});
const passthrough = (name: string) =>
@@ -284,14 +308,23 @@ beforeEach(async () => {
});
backend.getColumns.mockReset();
backend.documentFindDocuments.mockReset();
backend.documentCountDocuments.mockReset();
backend.dynamodbDescribeTable.mockReset();
backend.cancelQuery.mockReset();
backend.ensureConnected.mockReset();
backend.documentInsertDocument.mockReset();
backend.documentUpdateDocument.mockReset();
backend.documentDeleteDocument.mockReset();
backend.documentSaveMeilisearchBatch.mockReset();
dataGrid.fullExportResult = undefined;
dataGrid.customSaveHandler = undefined;
dataGrid.countTotalRows = undefined;
dataGrid.paginate = undefined;
dataGrid.editable = true;
documentJsonEditor.openSearch.mockClear();
backend.documentDeleteDocument.mockResolvedValue(undefined);
backend.documentInsertDocument.mockResolvedValue("created");
backend.documentUpdateDocument.mockResolvedValue(1);
backend.documentSaveMeilisearchBatch.mockResolvedValue(0);
settings.editorSettings.mongoViewMode = "table";
settings.editorSettings.columnWidthDensity = "standard";
@@ -306,6 +339,16 @@ beforeEach(async () => {
settings.updateEditorSettings.mockReset();
settings.updateEditorSettings.mockImplementation((partial: Partial<typeof settings.editorSettings>) => Object.assign(settings.editorSettings, partial));
backend.ensureConnected.mockResolvedValue(undefined);
backend.documentCountDocuments.mockResolvedValue(0);
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 0,
sizeBytes: 0,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [],
});
backend.getColumns.mockResolvedValue([
{ name: "buyers", data_type: "nested" },
{ name: "buyers.email", data_type: "text" },
@@ -443,6 +486,315 @@ describe("DocumentBrowser Elasticsearch field search", () => {
});
describe("DocumentBrowser MongoDB filter value types", () => {
it("loads DynamoDB table metadata before requesting the first page", async () => {
app?.unmount();
let resolveDescription!: (value: { name: string; status: string; itemCount: number; sizeBytes: number; partitionKey: { name: string; attributeType: string }; sortKey: { name: string; attributeType: string }; indexes: never[] }) => void;
backend.dynamodbDescribeTable.mockReturnValue(
new Promise((resolve) => {
resolveDescription = resolve;
}),
);
backend.documentFindDocuments.mockClear();
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(backend.dynamodbDescribeTable).toHaveBeenCalledOnce();
expect(backend.documentFindDocuments).not.toHaveBeenCalled();
resolveDescription({
name: "orders",
status: "ACTIVE",
itemCount: 0,
sizeBytes: 0,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [],
});
await flushUi();
expect(backend.documentFindDocuments).toHaveBeenCalledOnce();
});
it("labels new DynamoDB rows as conditional inserts in the executable preview", async () => {
app?.unmount();
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-000994" }, tenant_id: "tenant-04", order_id: "ORD-000994", note: "existing" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const preview = await dataGrid.customSaveHandler!.preview!({
dirtyRows: new Map(),
deletedRows: new Set(),
newRows: [['{"tenant_id":"tenant-04","order_id":"ORD-new"}', "tenant-04", "ORD-new", "new item"]],
newRowMeta: [{}],
columns: ["_id", "tenant_id", "order_id", "note"],
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-000994"}', "tenant-04", "ORD-000994", "existing"]],
});
expect(preview).toHaveLength(1);
expect(preview[0]).toContain('DBX DYNAMODB INSERT ITEM\ntable: "orders"');
expect(preview[0]).toContain('"order_id": "ORD-new"');
});
it("uses one backend update for a DynamoDB key migration and previews the old source key", async () => {
app?.unmount();
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: { tenant_id: "tenant-04", order_id: "ORD-old" }, tenant_id: "tenant-04", order_id: "ORD-old", note: "existing" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const changes = {
dirtyRows: new Map([[0, new Map([[2, "ORD-new"]])]]),
deletedRows: new Set<number>(),
newRows: [],
newRowMeta: [],
columns: ["_id", "tenant_id", "order_id", "note"],
rows: [['{"tenant_id":"tenant-04","order_id":"ORD-old"}', "tenant-04", "ORD-old", "existing"]],
};
const preview = await dataGrid.customSaveHandler!.preview!(changes);
expect(preview).toHaveLength(1);
expect(preview[0]).toContain('key:\n{\n "tenant_id": "tenant-04",\n "order_id": "ORD-old"\n}');
expect(preview[0]).toContain('"order_id": "ORD-new"');
await dataGrid.customSaveHandler!.save(changes);
expect(backend.documentUpdateDocument).toHaveBeenCalledTimes(1);
expect(backend.documentUpdateDocument.mock.calls[0]?.slice(0, 4)).toEqual(["dynamodb-1", "us-east-1", "orders", '{"tenant_id":"tenant-04","order_id":"ORD-old"}']);
expect(JSON.parse(backend.documentUpdateDocument.mock.calls[0]?.[4])).toMatchObject({ tenant_id: "tenant-04", order_id: "ORD-new", note: "existing" });
expect(backend.documentInsertDocument).not.toHaveBeenCalled();
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
});
it("makes partial-projection DynamoDB index results read-only", async () => {
app?.unmount();
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 1,
sizeBytes: 100,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [
{
name: "by_status",
kind: "global",
partitionKey: { name: "status", attributeType: "S" },
sortKey: { name: "created_at", attributeType: "N" },
projectionType: "KEYS_ONLY",
nonKeyAttributes: [],
},
],
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(dataGrid.editable).toBe(true);
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
await flushUi();
expect(dataGrid.editable).toBe(false);
expect(root!.textContent).toContain("KEYS_ONLY");
await expect(
dataGrid.customSaveHandler!.save({
dirtyRows: new Map(),
deletedRows: new Set(),
newRows: [],
newRowMeta: [],
columns: ["_id"],
rows: [],
}),
).rejects.toThrow("dynamodb.partialProjectionReadOnly");
});
it("keeps ALL-projection DynamoDB index results editable", async () => {
app?.unmount();
backend.dynamodbDescribeTable.mockResolvedValue({
name: "orders",
status: "ACTIVE",
itemCount: 1,
sizeBytes: 100,
partitionKey: { name: "tenant_id", attributeType: "S" },
sortKey: { name: "order_id", attributeType: "S" },
indexes: [
{
name: "by_status",
kind: "global",
partitionKey: { name: "status", attributeType: "S" },
sortKey: { name: "created_at", attributeType: "N" },
projectionType: "ALL",
nonKeyAttributes: [],
},
],
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLElement>('[data-testid="select"][data-model-value="__table__"]')!.click();
await flushUi();
expect(dataGrid.editable).toBe(true);
});
it("keeps an exact DynamoDB count while moving between cursor pages", async () => {
app?.unmount();
backend.documentCountDocuments.mockResolvedValue(250);
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index) } })),
total: 101,
total_is_exact: false,
next_cursor: "cursor-1",
})
.mockResolvedValueOnce({
documents: Array.from({ length: 100 }, (_, index) => ({ _id: { tenant_id: "tenant-a", order_id: String(index + 100) } })),
total: 101,
total_is_exact: false,
next_cursor: "cursor-2",
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(await dataGrid.countTotalRows!()).toBe(250);
await flushUi();
expect(root!.textContent).toContain("250 Items");
await dataGrid.paginate!(100, 100);
await flushUi();
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[10]).toBe("cursor-1");
expect(root!.textContent).toContain("250 Items");
});
it("exports all DynamoDB pages with opaque cursors instead of offsets", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 2;
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments
.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "tenant-a", order_id: "visible" }, tenant_id: "tenant-a", order_id: "visible" }],
total: 2,
total_is_exact: false,
next_cursor: "visible-cursor",
})
.mockResolvedValueOnce({
documents: [
{ _id: { tenant_id: "tenant-a", order_id: "001" }, tenant_id: "tenant-a", order_id: "001", amount: { $dbxDynamoDb: { version: 1, type: "number", value: "9007199254740993" } } },
{ _id: { tenant_id: "tenant-a", order_id: "002" }, tenant_id: "tenant-a", order_id: "002", tags: { $dbxDynamoDb: { version: 1, type: "stringSet", value: ["new", "paid"] } } },
],
total: 3,
total_is_exact: false,
next_cursor: "cursor-1",
})
.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "tenant-b", order_id: "003" }, tenant_id: "tenant-b", order_id: "003" }],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
expect(dataGrid.fullExportResult).toBeTypeOf("function");
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(backend.documentFindDocuments.mock.calls.slice(1).map((call) => [call[3], call[4], call[10]])).toEqual([
[0, 2, undefined],
[0, 2, "cursor-1"],
]);
expect(result?.rows).toHaveLength(3);
expect(result?.rows[0]).toContain('{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}');
expect(result?.rows[1]).toContain('{"$dbxDynamoDb":{"version":1,"type":"stringSet","value":["new","paid"]}}');
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 3, totalRows: 3 });
});
it("caps an otherwise unlimited DynamoDB export at 10,000 items", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 1000;
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValueOnce({
documents: [{ _id: { tenant_id: "visible", order_id: "visible" } }],
total: 2,
total_is_exact: false,
next_cursor: "visible-cursor",
});
for (let pageIndex = 0; pageIndex < 10; pageIndex += 1) {
backend.documentFindDocuments.mockResolvedValueOnce({
documents: Array.from({ length: 1000 }, (_, rowIndex) => ({
_id: { tenant_id: `tenant-${pageIndex}`, order_id: String(rowIndex) },
tenant_id: `tenant-${pageIndex}`,
order_id: String(rowIndex),
})),
total: 1001,
total_is_exact: false,
next_cursor: `cursor-${pageIndex + 1}`,
});
}
app = createApp(DocumentBrowser, {
connectionId: "dynamodb-1",
database: "us-east-1",
collection: "orders",
databaseType: "dynamodb",
});
app.mount(root!);
await flushUi();
const progress = vi.fn();
const result = await dataGrid.fullExportResult!(progress);
expect(result?.rows).toHaveLength(10_000);
expect(result?.truncated).toBe(true);
expect(result?.has_more).toBe(true);
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(11);
expect(progress).toHaveBeenLastCalledWith({ rowsExported: 10_000, totalRows: null });
});
it("exports all matching MongoDB documents without changing the visible page", async () => {
app?.unmount();
settings.editorSettings.exportBatchSize = 2;
@@ -22,6 +22,7 @@ const assetIcons: Record<string, string> = {
redis: "redis",
mongodb: "mongodb",
mongodb_legacy: "mongodb",
dynamodb: "dynamodb",
clickhouse: "clickhouse",
duckdb: "duckdb",
mariadb: "mariadb",
@@ -397,7 +397,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
connection: ["connection"],
database: ["database", "redis-db", "mq-tenant", "nacos-namespace", "consul-root", "mongo-db"],
schema: ["schema"],
table: ["table", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"],
table: ["table", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"],
view: ["view"],
};
@@ -679,7 +679,7 @@ const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
let sidebarCommentMeasureFrame = 0;
const sidebarTreeContentWidth = ref(0);
let sidebarTreeContentMeasureFrame = 0;
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
const sidebarStorageDisplayTypes = new Set<TreeNodeType>(["database", "table", "materialized_view"]);
function sidebarCommentLabel(node: TreeNode): string {
@@ -1365,6 +1365,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
await store.loadRedisDatabases(connId);
} else if (config.db_type === "mongodb") {
await store.loadMongoDatabases(connId);
} else if (config.db_type === "dynamodb") {
await store.loadDynamoDbTables(connId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
await store.loadElasticsearchIndices(connId);
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
@@ -752,6 +752,8 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadConsulRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await connectionStore.loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
// Expand: list indices (like other db types list databases).
await connectionStore.loadElasticsearchIndices(node.connectionId);
@@ -1317,6 +1319,18 @@ function openMongoTreeData(node: TreeNode) {
queryStore.openMongoBucket(node.connectionId, node.database, node.label);
return;
}
if (node.type === "dynamodb-table") {
const tab = queryStore.createTab(node.connectionId, node.database, `${node.database}.${node.label}`, "mongo");
queryStore.updateSql(tab, node.label);
queryStore.setTableMeta(tab, {
database: node.database,
tableName: node.label,
tableType: "DYNAMODB TABLE",
columns: [],
primaryKeys: [],
});
return;
}
if (node.type !== "mongo-collection") return;
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
queryStore.updateSql(tab, node.label);
@@ -3765,7 +3779,7 @@ const canOpenSqlFileExecution = computed(() => {
const canExportAllDatabases = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
return !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(dbType || "");
});
const canOpenDiagram = computed(() => {
@@ -4889,6 +4903,13 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
return true;
}
if (node.type === "dynamodb-table") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties });
return true;
}
if (node.type === "mongo-collection") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
@@ -304,6 +304,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
return { icon: Archive, colorClass: "text-cyan-400" };
case "mongo-collection":
return { icon: Table, colorClass: "text-green-400" };
case "dynamodb-table":
return { icon: Table, colorClass: "text-amber-500" };
case "vector-collection":
return { icon: TableProperties, colorClass: "text-cyan-400" };
case "elasticsearch-index":
@@ -385,7 +387,7 @@ function treeNodeSecondaryValue(node: TreeNode): string | undefined {
function visibleLabel(node: TreeNode): string {
const withValidity = (label: string) => (node.valid === false ? `${label} · INVALID` : label);
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
return withValidity(sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes));
}
return withValidity(displayLabel(node));
@@ -44,7 +44,7 @@ async function copy() {
<!-- card: 卡片类报错信息面板 -->
<div v-if="variant === 'card'" class="mx-3 my-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 shadow-sm shrink-0 select-text flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 border-b border-destructive/20 pb-2">
<div class="flex items-center gap-2 font-semibold text-xs text-destructive">
<div data-native-clipboard class="flex items-center gap-2 font-semibold text-xs text-destructive">
<TriangleAlert class="h-4 w-4 text-destructive shrink-0" aria-hidden="true" />
<span>{{ displayTitle }}</span>
</div>
@@ -58,14 +58,14 @@ async function copy() {
</Button>
</div>
</div>
<div class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
<div data-native-clipboard class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
{{ message }}
</div>
</div>
<!-- banner: 紧凑内联横幅 -->
<div v-else-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
<span data-native-clipboard class="flex-1 min-w-0 break-all">{{ message }}</span>
<button v-if="copyMode === 'label'" type="button" class="shrink-0 hover:underline" :aria-label="t('grid.copy')" @click.stop="copy">
{{ t("grid.copy") }}
</button>
@@ -78,7 +78,7 @@ async function copy() {
<!-- centered: 居中占满 -->
<div v-else class="flex-1 min-h-0 flex flex-col items-center justify-center gap-2 px-6 py-4 text-center">
<TriangleAlert class="h-8 w-8 text-destructive/50" aria-hidden="true" />
<div class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<div data-native-clipboard class="min-h-0 max-h-48 max-w-lg overflow-auto space-y-1 select-text text-destructive" @mousedown.stop @click.stop>
<div class="text-sm font-medium">{{ displayTitle }}</div>
<div class="text-xs break-all cursor-text text-destructive/80 select-text">{{ message }}</div>
</div>
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import { eventTargetAllowsNativeClipboard } from "@/lib/common/clipboard";
const mountedContainers: HTMLDivElement[] = [];
function findNativeClipboardRegion(container: HTMLElement, text: string): HTMLElement {
const region = Array.from(container.querySelectorAll<HTMLElement>("[data-native-clipboard]")).find((element) => element.textContent?.trim() === text);
if (!region) throw new Error(`Native clipboard region not found: ${text}`);
return region;
}
function selectRegion(region: HTMLElement) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(region);
selection?.removeAllRanges();
selection?.addRange(range);
}
async function mountCardBanner() {
const container = document.createElement("div");
mountedContainers.push(container);
document.body.append(container);
const app = createApp(ErrorBanner, {
variant: "card",
title: "Save failed",
message: "Conditional request failed",
});
app.use(
createI18n({
legacy: false,
locale: "en",
messages: { en: { grid: { copy: "Copy", dismiss: "Dismiss" } } },
missingWarn: false,
fallbackWarn: false,
}),
);
app.mount(container);
await nextTick();
return container;
}
afterEach(() => {
window.getSelection()?.removeAllRanges();
for (const container of mountedContainers.splice(0)) container.remove();
});
describe("ErrorBanner native clipboard selection", () => {
it.each(["Save failed", "Conditional request failed"])("preserves native copy for selected %s text", async (text) => {
const container = await mountCardBanner();
selectRegion(findNativeClipboardRegion(container, text));
expect(
eventTargetAllowsNativeClipboard({
key: "c",
metaKey: true,
target: container,
}),
).toBe(true);
});
it("leaves grid copy shortcuts active without a text selection", async () => {
const container = await mountCardBanner();
expect(
eventTargetAllowsNativeClipboard({
key: "c",
metaKey: true,
target: container,
}),
).toBe(false);
});
});
+24
View File
@@ -443,6 +443,13 @@ export default {
sqliteExtensionBrowse: "Select SQLite extension",
user: "User",
password: "Password",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Region",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "Optional",
dynamodbCredentialsRequired: "DynamoDB requires an Access Key ID and Secret Access Key",
savePassword: "Save password",
savePasswordHint: "The password is saved, so you won't need to enter it again.",
savePasswordSessionHint: "The password is kept only for this session and cleared when you quit the app.",
@@ -4510,6 +4517,23 @@ export default {
filterPlaceholder: "Filter...",
sortPlaceholder: "Sort...",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB table metadata is not loaded. Refresh and try again.",
keyRequired: "DynamoDB items require the key attribute {key}",
baseTable: "Base table",
filter: "filter",
sortKey: "sort key",
items: "{count} Items",
index: "DynamoDB index",
keySummary: "Partition key: {partitionKey}; sort key: {sortKey}",
none: "none",
sortKeyOnly: "DynamoDB Query can sort only by the current index sort key: {key}",
pageCursorUnavailable: "The DynamoDB page cursor is unavailable. Reload from the first page.",
repeatedCursor: "DynamoDB returned a repeated page cursor. Export was stopped.",
partialProjectionReadOnly: "This {projection} index does not contain the complete item. Editing is disabled to prevent unprojected attributes from being overwritten.",
exportLimitReached: "DynamoDB export stopped at {count} items to limit memory and read usage. Set an export row limit to choose a different boundary.",
documentDetails: "DynamoDB table: {table}\nItem key: {id}",
},
vector: {
collectionFallback: "collection",
productCollection: "{product} collection",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleccionar extensión de SQLite",
user: "Usuario",
password: "Contraseña",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Región",
dynamodbAccessKeyId: "ID de clave de acceso",
dynamodbSecretAccessKey: "Clave de acceso secreta",
dynamodbSessionToken: "Token de sesión",
dynamodbSessionTokenPlaceholder: "Opcional",
dynamodbCredentialsRequired: "DynamoDB requiere un ID de clave de acceso y una clave de acceso secreta",
savePassword: "Guardar contraseña",
savePasswordHint: "La contraseña se guardará para que no tengas que volver a introducirla.",
savePasswordSessionHint: "La contraseña se conserva solo durante esta sesión y se borra al salir de la aplicación.",
@@ -4352,6 +4359,23 @@ export default withEnglishFallback({
indexOtherOptions: "Otras opciones",
indexPropertiesUnavailable: "El controlador Legacy de MongoDB no puede informar disperso, expiración, background o tamaño de bucket. Conéctese con el controlador nativo para verlos.",
},
dynamodb: {
tableMetadataUnavailable: "Los metadatos de la tabla DynamoDB no se han cargado. Actualice e inténtelo de nuevo.",
keyRequired: "Los elementos de DynamoDB requieren el atributo de clave {key}",
baseTable: "Tabla base",
filter: "filtro",
sortKey: "clave de ordenación",
items: "{count} elementos",
index: "Índice de DynamoDB",
keySummary: "Clave de partición: {partitionKey}; clave de ordenación: {sortKey}",
none: "ninguna",
sortKeyOnly: "DynamoDB Query solo puede ordenar por la clave de ordenación del índice actual: {key}",
pageCursorUnavailable: "El cursor de página de DynamoDB no está disponible. Recargue desde la primera página.",
repeatedCursor: "DynamoDB devolvió un cursor de página repetido. Se detuvo la exportación.",
partialProjectionReadOnly: "Este índice {projection} no contiene el elemento completo. La edición está deshabilitada para evitar sobrescribir atributos no proyectados.",
exportLimitReached: "La exportación de DynamoDB se detuvo en {count} elementos para limitar el uso de memoria y lecturas. Configure un límite de filas de exportación para elegir otro alcance.",
documentDetails: "Tabla de DynamoDB: {table}\nClave del elemento: {id}",
},
vector: {
collectionFallback: "colección",
productCollection: "Colección de {product}",
+24
View File
@@ -444,6 +444,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Seleziona estensione SQLite",
user: "Utente",
password: "Password",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Regione",
dynamodbAccessKeyId: "ID chiave di accesso",
dynamodbSecretAccessKey: "Chiave di accesso segreta",
dynamodbSessionToken: "Token di sessione",
dynamodbSessionTokenPlaceholder: "Opzionale",
dynamodbCredentialsRequired: "DynamoDB richiede un ID chiave di accesso e una chiave di accesso segreta",
savePassword: "Salva password",
savePasswordHint: "La password verrà salvata, quindi non dovrai inserirla di nuovo.",
savePasswordSessionHint: "La password viene conservata solo per questa sessione e cancellata alla chiusura dell'app.",
@@ -4350,6 +4357,23 @@ export default withEnglishFallback({
indexOtherOptions: "Altre opzioni",
indexPropertiesUnavailable: "Il driver Legacy di MongoDB non può riportare sparsità, scadenza, background o dimensione bucket. Connettersi con il driver nativo per vederli.",
},
dynamodb: {
tableMetadataUnavailable: "I metadati della tabella DynamoDB non sono caricati. Aggiorna e riprova.",
keyRequired: "Gli elementi DynamoDB richiedono l'attributo chiave {key}",
baseTable: "Tabella base",
filter: "filtro",
sortKey: "chiave di ordinamento",
items: "{count} elementi",
index: "Indice DynamoDB",
keySummary: "Chiave di partizione: {partitionKey}; chiave di ordinamento: {sortKey}",
none: "nessuna",
sortKeyOnly: "DynamoDB Query può ordinare solo per la chiave di ordinamento dell'indice corrente: {key}",
pageCursorUnavailable: "Il cursore di pagina DynamoDB non è disponibile. Ricarica dalla prima pagina.",
repeatedCursor: "DynamoDB ha restituito un cursore di pagina ripetuto. L'esportazione è stata interrotta.",
partialProjectionReadOnly: "Questo indice {projection} non contiene l'elemento completo. La modifica è disabilitata per evitare di sovrascrivere attributi non proiettati.",
exportLimitReached: "L'esportazione DynamoDB si è fermata a {count} elementi per limitare memoria e letture. Imposta un limite di righe per scegliere un intervallo diverso.",
documentDetails: "Tabella DynamoDB: {table}\nChiave elemento: {id}",
},
vector: {
collectionFallback: "collezione",
productCollection: "Collezione {product}",
+24
View File
@@ -444,6 +444,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite拡張を選択",
user: "ユーザー",
password: "パスワード",
dynamodbEndpoint: "エンドポイント",
dynamodbRegion: "リージョン",
dynamodbAccessKeyId: "アクセスキー ID",
dynamodbSecretAccessKey: "シークレットアクセスキー",
dynamodbSessionToken: "セッショントークン",
dynamodbSessionTokenPlaceholder: "任意",
dynamodbCredentialsRequired: "DynamoDB にはアクセスキー ID とシークレットアクセスキーが必要です",
savePassword: "パスワードを保存",
savePasswordHint: "パスワードを保存し、次回以降の接続では再入力を不要にします。",
savePasswordSessionHint: "パスワードは今回の実行中のみ保持され、アプリ終了時に消去されます。",
@@ -4380,6 +4387,23 @@ export default withEnglishFallback({
indexOtherOptions: "その他のオプション",
indexPropertiesUnavailable: "MongoDB Legacy ドライバーはスパース、有効期限、バックグラウンド、バケットサイズを報告できません。ネイティブドライバーで接続して確認してください。",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB テーブルのメタデータが読み込まれていません。更新して再試行してください。",
keyRequired: "DynamoDB 項目にはキー属性 {key} が必要です",
baseTable: "ベーステーブル",
filter: "フィルター",
sortKey: "ソートキー",
items: "{count} 件",
index: "DynamoDB インデックス",
keySummary: "パーティションキー: {partitionKey}; ソートキー: {sortKey}",
none: "なし",
sortKeyOnly: "DynamoDB Query は現在のインデックスのソートキー {key} でのみ並べ替えできます",
pageCursorUnavailable: "DynamoDB のページカーソルを利用できません。最初のページから再読み込みしてください。",
repeatedCursor: "DynamoDB が同じページカーソルを返したため、エクスポートを停止しました。",
partialProjectionReadOnly: "この {projection} インデックスには項目全体が含まれないため、未投影属性の上書きを防ぐ目的で編集を無効にしました。",
exportLimitReached: "メモリと読み取り使用量を制限するため、DynamoDB のエクスポートは {count} 件で停止しました。別の範囲にするにはエクスポート行数上限を設定してください。",
documentDetails: "DynamoDB テーブル: {table}\n項目キー: {id}",
},
vector: {
collectionFallback: "コレクション",
productCollection: "{product} コレクション",
+24
View File
@@ -437,6 +437,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "SQLite 확장 선택",
user: "사용자",
password: "비밀번호",
dynamodbEndpoint: "엔드포인트",
dynamodbRegion: "리전",
dynamodbAccessKeyId: "액세스 키 ID",
dynamodbSecretAccessKey: "비밀 액세스 키",
dynamodbSessionToken: "세션 토큰",
dynamodbSessionTokenPlaceholder: "선택",
dynamodbCredentialsRequired: "DynamoDB에는 액세스 키 ID와 비밀 액세스 키가 필요합니다",
savePassword: "비밀번호 저장",
savePasswordHint: "비밀번호를 저장하여 다음 연결부터 다시 입력하지 않아도 됩니다.",
savePasswordSessionHint: "비밀번호는 이번 실행 동안만 유지되며 앱을 종료하면 삭제됩니다.",
@@ -3995,6 +4002,23 @@ export default withEnglishFallback({
indexOtherOptions: "기타 옵션",
indexPropertiesUnavailable: "MongoDB Legacy 드라이버는 희소, 만료, 백그라운드 또는 버킷 크기를 보고할 수 없습니다. 네이티브 드라이버로 연결하여 확인하세요.",
},
dynamodb: {
tableMetadataUnavailable: "DynamoDB 테이블 메타데이터가 로드되지 않았습니다. 새로 고친 후 다시 시도하세요.",
keyRequired: "DynamoDB 항목에는 키 속성 {key}이(가) 필요합니다",
baseTable: "기본 테이블",
filter: "필터",
sortKey: "정렬 키",
items: "항목 {count}개",
index: "DynamoDB 인덱스",
keySummary: "파티션 키: {partitionKey}; 정렬 키: {sortKey}",
none: "없음",
sortKeyOnly: "DynamoDB Query는 현재 인덱스의 정렬 키 {key}로만 정렬할 수 있습니다",
pageCursorUnavailable: "DynamoDB 페이지 커서를 사용할 수 없습니다. 첫 페이지부터 다시 불러오세요.",
repeatedCursor: "DynamoDB가 중복 페이지 커서를 반환하여 내보내기를 중지했습니다.",
partialProjectionReadOnly: "이 {projection} 인덱스에는 전체 항목이 포함되지 않으므로 투영되지 않은 속성이 덮어쓰이지 않도록 편집을 비활성화했습니다.",
exportLimitReached: "메모리와 읽기 사용량을 제한하기 위해 DynamoDB 내보내기가 {count}개 항목에서 중지되었습니다. 다른 범위를 사용하려면 내보내기 행 제한을 설정하세요.",
documentDetails: "DynamoDB 테이블: {table}\n항목 키: {id}",
},
vector: {
collectionFallback: "컬렉션",
productCollection: "{product} 컬렉션",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "Selecionar extensão SQLite",
user: "Usuário",
password: "Senha",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "Região",
dynamodbAccessKeyId: "ID da chave de acesso",
dynamodbSecretAccessKey: "Chave de acesso secreta",
dynamodbSessionToken: "Token de sessão",
dynamodbSessionTokenPlaceholder: "Opcional",
dynamodbCredentialsRequired: "O DynamoDB requer um ID de chave de acesso e uma chave de acesso secreta",
savePassword: "Salvar senha",
savePasswordHint: "A senha será salva para que você não precise digitá-la novamente.",
savePasswordSessionHint: "A senha é mantida apenas nesta sessão e apagada ao sair do aplicativo.",
@@ -4352,6 +4359,23 @@ export default withEnglishFallback({
indexOtherOptions: "Outras opções",
indexPropertiesUnavailable: "O driver Legacy do MongoDB não pode informar esparso, expiração, background ou tamanho do bucket. Conecte-se com o driver nativo para vê-los.",
},
dynamodb: {
tableMetadataUnavailable: "Os metadados da tabela DynamoDB não foram carregados. Atualize e tente novamente.",
keyRequired: "Os itens do DynamoDB exigem o atributo de chave {key}",
baseTable: "Tabela base",
filter: "filtro",
sortKey: "chave de ordenação",
items: "{count} itens",
index: "Índice do DynamoDB",
keySummary: "Chave de partição: {partitionKey}; chave de ordenação: {sortKey}",
none: "nenhuma",
sortKeyOnly: "DynamoDB Query só pode ordenar pela chave de ordenação do índice atual: {key}",
pageCursorUnavailable: "O cursor de página do DynamoDB não está disponível. Recarregue a partir da primeira página.",
repeatedCursor: "O DynamoDB retornou um cursor de página repetido. A exportação foi interrompida.",
partialProjectionReadOnly: "Este índice {projection} não contém o item completo. A edição foi desativada para evitar a substituição de atributos não projetados.",
exportLimitReached: "A exportação do DynamoDB parou em {count} itens para limitar o uso de memória e leituras. Defina um limite de linhas para escolher outro intervalo.",
documentDetails: "Tabela do DynamoDB: {table}\nChave do item: {id}",
},
vector: {
collectionFallback: "coleção",
productCollection: "coleção {product}",
+24
View File
@@ -368,6 +368,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "选择 SQLite 扩展库",
user: "用户名",
password: "密码",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "区域",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "可选",
dynamodbCredentialsRequired: "DynamoDB 需要填写 Access Key ID 和 Secret Access Key",
savePassword: "保存密码",
savePasswordHint: "密码将被保存,后续连接无需再次输入。",
savePasswordSessionHint: "密码仅在本次运行期间保留,退出应用后清除。",
@@ -4498,6 +4505,23 @@ export default withEnglishFallback({
filterPlaceholder: "过滤条件...",
sortPlaceholder: "排序条件...",
},
dynamodb: {
tableMetadataUnavailable: "尚未加载 DynamoDB 表结构,请刷新后重试",
keyRequired: "DynamoDB 项目必须填写键字段 {key}",
baseTable: "主表",
filter: "筛选",
sortKey: "排序键",
items: "{count} 个项目",
index: "DynamoDB 索引",
keySummary: "分区键:{partitionKey};排序键:{sortKey}",
none: "无",
sortKeyOnly: "DynamoDB Query 只能按当前索引的排序键 {key} 排序",
pageCursorUnavailable: "当前 DynamoDB 页游标不可用,请从第一页重新加载",
repeatedCursor: "DynamoDB 返回了重复的分页游标,已停止导出",
partialProjectionReadOnly: "当前 {projection} 索引不包含完整项目,为避免覆盖未投影属性,已禁用编辑",
exportLimitReached: "为限制内存和读取用量,DynamoDB 导出已在 {count} 条停止;可设置导出行数上限来调整范围",
documentDetails: "DynamoDB 表:{table}\n项目键:{id}",
},
vector: {
collectionFallback: "集合",
productCollection: "{product} 集合",
+24
View File
@@ -445,6 +445,13 @@ export default withEnglishFallback({
sqliteExtensionBrowse: "選擇 SQLite 擴充庫",
user: "使用者名稱",
password: "密碼",
dynamodbEndpoint: "Endpoint",
dynamodbRegion: "區域",
dynamodbAccessKeyId: "Access Key ID",
dynamodbSecretAccessKey: "Secret Access Key",
dynamodbSessionToken: "Session Token",
dynamodbSessionTokenPlaceholder: "選填",
dynamodbCredentialsRequired: "DynamoDB 需要填寫 Access Key ID 和 Secret Access Key",
savePassword: "儲存密碼",
savePasswordHint: "密碼將被儲存,後續連線無需再次輸入。",
savePasswordSessionHint: "密碼僅在本次執行期間保留,退出應用程式後清除。",
@@ -3694,6 +3701,23 @@ export default withEnglishFallback({
invalidNativeJson: "原生 JSON 格式無效:{error}",
nativeDocumentRequired: "原生 JSON 的最上層必須是文件物件。",
},
dynamodb: {
tableMetadataUnavailable: "尚未載入 DynamoDB 表結構,請重新整理後再試",
keyRequired: "DynamoDB 項目必須填寫鍵欄位 {key}",
baseTable: "主表",
filter: "篩選",
sortKey: "排序鍵",
items: "{count} 個項目",
index: "DynamoDB 索引",
keySummary: "分區鍵:{partitionKey};排序鍵:{sortKey}",
none: "無",
sortKeyOnly: "DynamoDB Query 只能依目前索引的排序鍵 {key} 排序",
pageCursorUnavailable: "目前 DynamoDB 頁面游標不可用,請從第一頁重新載入",
repeatedCursor: "DynamoDB 傳回重複的分頁游標,已停止匯出",
partialProjectionReadOnly: "目前 {projection} 索引不包含完整項目,為避免覆寫未投影屬性,已停用編輯",
exportLimitReached: "為限制記憶體和讀取用量,DynamoDB 匯出已在 {count} 筆停止;可設定匯出列數上限來調整範圍",
documentDetails: "DynamoDB 表:{table}\n項目鍵:{id}",
},
vector: {
collectionFallback: "集合",
productCollection: "{product} 集合",
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { formatDocumentStoreIdLabel, serializeDocumentStoreId } from "@/lib/app/documentJsonValues";
import { buildDocumentFilterCondition, currentDocumentFilterJson, documentStoreProviderFor } from "@/lib/app/documentStoreProvider";
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument } from "@/lib/app/documentStoreSave";
describe("DynamoDB document store provider", () => {
it("uses a dedicated provider and describes a cursor-backed read", () => {
const provider = documentStoreProviderFor("dynamodb");
expect(provider.kind).toBe("dynamodb");
expect(
provider.queryPreview({
collection: "orders",
filterJson: '{"tenant_id":"tenant-a","$index":"by_status"}',
sortJson: '{"created_at":-1}',
skip: 100,
limit: 50,
}),
).toContain('DBX DYNAMODB QUERY / SCAN\ntable: "orders"\nlimit: 50');
});
it("keeps composite key identity and large-number wrappers intact", () => {
const id = {
tenant_id: "tenant-a",
sequence: { $dbxDynamoDb: { version: 1, type: "number", value: "9007199254740993" } },
};
expect(serializeDocumentStoreId(id, "dynamodb")).toBe('{"tenant_id":"tenant-a","sequence":{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}}');
expect(formatDocumentStoreIdLabel(id, "dynamodb")).toBe('{"tenant_id":"tenant-a","sequence":{"$dbxDynamoDb":{"version":1,"type":"number","value":"9007199254740993"}}}');
});
it("builds DynamoDB-compatible structured filters", () => {
expect(currentDocumentFilterJson("", { amount: { $gte: 50 } }, "dynamodb")).toBe('{"amount":{"$gte":50}}');
expect(buildDocumentFilterCondition({ id: "contains", fieldName: "customer", mode: "like", rawValue: "Acme", conjunction: "AND" }, { kind: "dynamodb" })).toEqual({ customer: { $contains: "Acme" } });
expect(buildDocumentFilterCondition({ id: "not-contains", fieldName: "customer", mode: "not-like", rawValue: "Test", conjunction: "AND" }, { kind: "dynamodb" })).toEqual({ customer: { $notContains: "Test" } });
});
it("uses conditional insert semantics even when a new item has an explicit identity", async () => {
const insert = vi.fn(async () => "created");
const update = vi.fn(async () => 1);
await insertDocumentStoreDocument({
kind: "dynamodb",
explicitId: '{"tenant_id":"tenant-a","order_id":"order-1001"}',
document: { tenant_id: "tenant-a", order_id: "order-1001", amount: 42 },
apis: { insert, update },
});
expect(insert).toHaveBeenCalledWith('{"tenant_id":"tenant-a","order_id":"order-1001","amount":42}', undefined);
expect(update).not.toHaveBeenCalled();
});
it("delegates a DynamoDB rekey to one atomic backend update", async () => {
const calls: string[] = [];
const insert = vi.fn(async () => {
calls.push("insert");
return "created";
});
const update = vi.fn(async () => {
calls.push("update");
return 1;
});
const remove = vi.fn(async () => {
calls.push("delete");
return 1;
});
await applyDocumentStoreIdentityPlan({
kind: "dynamodb",
plan: {
action: "rekey",
writeId: '{"tenant_id":"tenant-a","order_id":"order-1002"}',
deleteId: '{"tenant_id":"tenant-a","order_id":"order-1001"}',
},
document: { tenant_id: "tenant-a", order_id: "order-1002", amount: 42 },
apis: { insert, update, delete: remove },
});
expect(calls).toEqual(["update"]);
expect(update).toHaveBeenCalledWith('{"tenant_id":"tenant-a","order_id":"order-1001"}', '{"tenant_id":"tenant-a","order_id":"order-1002","amount":42}', undefined);
expect(insert).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { connectionDisplayUrlScheme, connectionUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { connectionCanChooseVisibleDatabases } from "@/lib/connection/connectionVisibleDatabases";
import type { ConnectionConfig } from "@/types/database";
describe("DynamoDB connection presentation", () => {
it("uses a DynamoDB endpoint URL as the connection placeholder", () => {
expect(connectionUrlPlaceholder("dynamodb")).toBe("https://dynamodb.us-east-1.amazonaws.com");
expect(connectionDisplayUrlScheme({ db_type: "dynamodb", ssl: true })).toBe("https");
expect(connectionDisplayUrlScheme({ db_type: "dynamodb", ssl: false })).toBe("http");
});
it("parses an HTTPS endpoint while DynamoDB is selected", () => {
expect(parseConnectionUrl("https://dynamodb.eu-west-1.amazonaws.com", "dynamodb")).toMatchObject({
dbType: "dynamodb",
driverProfile: "dynamodb",
host: "dynamodb.eu-west-1.amazonaws.com",
port: 443,
database: "eu-west-1",
ssl: true,
});
});
it("preserves the selected region for a custom endpoint URL", () => {
const parsed = parseConnectionUrl("http://127.0.0.1:8000", "dynamodb");
const config = applyParsedConnectionUrl(
{
db_type: "dynamodb",
driver_profile: "dynamodb",
name: "DynamoDB Local",
host: "127.0.0.1",
port: 8000,
username: "local",
password: "local",
database: "ap-southeast-1",
} as Omit<ConnectionConfig, "id">,
parsed,
);
expect(config.database).toBe("ap-southeast-1");
expect(config.username).toBe("local");
expect(config.password).toBe("local");
});
it("does not offer database visibility selection", () => {
expect(connectionCanChooseVisibleDatabases({ db_type: "dynamodb" })).toBe(false);
});
});
@@ -5,8 +5,10 @@ describe("databaseDriverManifest", () => {
it("uses agent cursor only for agent or external runtimes", () => {
expect(databaseRuntimeMode("gaussdb")).toBe("native");
expect(databaseRuntimeMode("meilisearch")).toBe("native");
expect(databaseRuntimeMode("dynamodb")).toBe("native");
expect(usesAgentCursorForQuery("gaussdb")).toBe(false);
expect(usesAgentCursorForQuery("meilisearch")).toBe(false);
expect(usesAgentCursorForQuery("dynamodb")).toBe(false);
expect(usesAgentCursorForQuery("mysql")).toBe(false);
expect(usesAgentCursorForQuery("jdbc")).toBe(true);
expect(usesAgentCursorForQuery("prestosql")).toBe(true);
@@ -28,6 +28,7 @@ describe("sidebar tree item layout", () => {
});
it("renders navigation leaf actions without expanders", () => {
expect(canTreeNodeShowExpander({ type: "dynamodb-table", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "nacos-namespace", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "nacos-access-control", childCount: 0 })).toBe(false);
expect(canTreeNodeShowExpander({ type: "etcd-root", childCount: 0 })).toBe(false);
@@ -12,6 +12,11 @@ import {
} from "@/lib/sidebar/treeNodeClick";
describe("treeNodeClick", () => {
it("opens DynamoDB tables in the document browser on single or double activation", () => {
expect(treeNodeRowAction("dynamodb-table", false, "single", "dynamodb")).toBe("toggle");
expect(treeNodeRowDoubleClickAction("dynamodb-table", false, "double", false, "dynamodb")).toBe("toggle");
});
it("opens synonym nodes as synonym source", () => {
expect(objectSourceKindForTreeNode("synonym")).toBe("SYNONYM");
expect(treeNodeRowAction("synonym", false)).toBe("open-source");
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { classifySqlRisk } from "@/lib/sql/sqlRisk";
describe("DynamoDB statement risk", () => {
it("treats generated scan statements as reads", () => {
expect(classifySqlRisk('DBX DYNAMODB SCAN\ntable: "orders"\nlimit: 1000', { dialect: "dynamodb" }).risk).toBe("read");
expect(classifySqlRisk('DBX DYNAMODB QUERY / SCAN\ntable: "orders"\nfilter:\n{"status":"SHIPPED"}', { dialect: "dynamodb" }).risk).toBe("read");
});
it("treats generated item changes as writes", () => {
for (const operation of ["INSERT ITEM", "PUT ITEM", "DELETE ITEM"]) {
expect(classifySqlRisk(`DBX DYNAMODB ${operation}\ntable: "orders"`, { dialect: "dynamodb" }).risk).toBe("write");
}
});
it("keeps unknown DynamoDB operations unsafe", () => {
expect(classifySqlRisk('DBX DYNAMODB UNKNOWN\ntable: "orders"', { dialect: "dynamodb" }).risk).toBe("unknown");
});
});
@@ -42,9 +42,16 @@ export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
if (kind === "elasticsearch") return String(value);
if (kind === "meilisearch") return typeof value === "string" ? `__dbx_meilisearch_string_id__${JSON.stringify(value)}` : String(value);
if (kind === "dynamodb") return stringifyJsonPreservingLargeNumbers(value);
return serializeMongoDocumentId(value);
}
export function formatDocumentStoreIdLabel(value: unknown, kind: DocumentStoreKind): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") return stringifyDocumentStoreValue(value, kind);
return String(value);
}
/**
* Parse a whole document JSON payload for MongoDB / Elasticsearch editors.
* Accepts standard JSON and Extended JSON objects; rejects non-object roots.
@@ -4,7 +4,7 @@ import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch" | "meilisearch";
export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
@@ -150,6 +150,23 @@ const meilisearchDocumentProvider: DocumentStoreProvider = {
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
const dynamodbDocumentProvider: DocumentStoreProvider = {
kind: "dynamodb",
filterInputLabel: "filter",
sortInputLabel: "sort key",
documentsLabel: ({ total, totalIsExact, t }) => `${totalIsExact ? "" : "≥"}${t("dynamodb.items", { count: total })}`,
queryPreview: ({ collection, filterJson, sortJson, limit }) => {
const filter = documentStorePreviewJson(filterJson);
const sort = documentStorePreviewJson(sortJson);
const operation = filterJson?.includes('"$index"') || (filterJson && filterJson !== "{}") ? "QUERY / SCAN" : "SCAN";
const lines = [`DBX DYNAMODB ${operation}`, `table: ${JSON.stringify(collection)}`, `limit: ${limit}`];
if (filter) lines.push("filter:", filter);
if (sort) lines.push("sort:", sort);
return lines.join("\n");
},
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
function documentStorePreviewJson(value?: string): string | null {
const trimmed = value?.trim();
if (!trimmed || trimmed === "{}") return null;
@@ -161,6 +178,7 @@ function documentStorePreviewJson(value?: string): string | null {
}
export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentStoreProvider {
if (databaseType === "dynamodb") return dynamodbDocumentProvider;
if (isElasticsearchCompatibleDatabaseType(databaseType)) return elasticsearchDocumentProvider;
if (isMeilisearchDatabaseType(databaseType)) return meilisearchDocumentProvider;
return mongoDocumentProvider;
@@ -482,8 +500,10 @@ export function buildDocumentFilterCondition(rule: DocumentFilterRule, options:
case "not-equals":
return { [rule.fieldName]: { $ne: value } };
case "like":
if (options.kind === "dynamodb") return { [rule.fieldName]: { $contains: value } };
return { [rule.fieldName]: { $regex: escapeRegexLiteral(textValue), $options: "i" } };
case "not-like":
if (options.kind === "dynamodb") return { [rule.fieldName]: { $notContains: value } };
return { [rule.fieldName]: { $not: { $regex: escapeRegexLiteral(textValue), $options: "i" } } };
case "greater-than":
return { [rule.fieldName]: { $gt: value } };
@@ -670,6 +690,7 @@ type DocumentQueryInputNormalizer = (input: string) => string | null;
const documentQueryInputNormalizers: Record<DocumentStoreKind, DocumentQueryInputNormalizer> = {
mongodb: normalizeJsonArgument,
dynamodb: quoteUnquotedObjectKeys,
elasticsearch: quoteUnquotedObjectKeys,
meilisearch: quoteUnquotedObjectKeys,
};
+16 -3
View File
@@ -37,12 +37,26 @@ export async function writeDocumentStoreDocument(options: { kind: DocumentStoreK
/**
* Apply an identity plan for an existing document save.
* Rekey always writes first, then deletes the old identity — a failed write never deletes.
* DynamoDB rekeys atomically in the backend. Other stores write first, then
* delete the old identity so a failed write never deletes the source.
* Plan coordinates are assumed distinct for rekey (same identity is always `replace`).
*/
export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentStoreKind; plan: DocumentStoreIdentityPlan; document: Record<string, unknown>; apis: DocumentStoreWriteApis }): Promise<void> {
const { kind, plan, document, apis } = options;
// DynamoDB handles both same-key replacement and key migration in the
// backend. Rekey uses one TransactWriteItems request, keyed by the old id.
if (kind === "dynamodb") {
await writeDocumentStoreDocument({
kind,
op: "put",
id: plan.action === "rekey" ? plan.deleteId : plan.writeId,
document,
apis,
});
return;
}
if (plan.action === "replace") {
await writeDocumentStoreDocument({
kind,
@@ -55,7 +69,6 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
return;
}
// Path-identity stores write under the new id first; Mongo inserts a new document.
if (kind !== "mongodb") {
await writeDocumentStoreDocument({
kind,
@@ -81,7 +94,7 @@ export async function applyDocumentStoreIdentityPlan(options: { kind: DocumentSt
/** Insert a new document (optional explicit path identity uses put). */
export async function insertDocumentStoreDocument(options: { kind: DocumentStoreKind; document: Record<string, unknown>; explicitId?: string | null; routing?: string; apis: Pick<DocumentStoreWriteApis, "insert" | "update"> }): Promise<void> {
const { kind, document, explicitId, routing, apis } = options;
if (kind !== "mongodb" && explicitId) {
if (kind !== "mongodb" && kind !== "dynamodb" && explicitId) {
await writeDocumentStoreDocument({
kind,
op: "put",
+5
View File
@@ -698,6 +698,8 @@ export const mongoDropCollection = forward("mongoDropCollection");
export const mongoRenameCollection = forward("mongoRenameCollection");
export const mongoCloneCollection = forward("mongoCloneCollection");
export const documentFindDocuments = forward("documentFindDocuments");
export const documentCountDocuments = forward("documentCountDocuments");
export const dynamodbDescribeTable = forward("dynamodbDescribeTable");
export const elasticsearchCountDocuments = forward("elasticsearchCountDocuments");
export const mongoFindDocuments = forward("mongoFindDocuments");
export const mongoParseShellCommand = forward("mongoParseShellCommand");
@@ -856,6 +858,9 @@ export type {
EtcdPreflightResponse,
EtcdDangerousApproval,
DocumentQueryResult,
DynamoDbKeyInfo,
DynamoDbIndexInfo,
DynamoDbTableDescription,
MongoDocumentResult,
HistoryEntry,
HistoryConnectionFilter,
+16 -1
View File
@@ -98,6 +98,7 @@ import type {
EtcdWatchPollResponse,
EtcdLeaseListResponse,
DocumentQueryResult,
DynamoDbTableDescription,
MongoDocumentResult,
MongoCollectionStatsResult,
MongoCloneCollectionResult,
@@ -3561,7 +3562,7 @@ export async function mongoFindOne(connectionId: string, database: string, colle
});
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string): Promise<DocumentQueryResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
return post("/api/document-store/find-documents", {
connectionId,
database,
@@ -3572,10 +3573,24 @@ export async function documentFindDocuments(connectionId: string, database: stri
projection,
sort,
collation,
cursor,
executionId,
});
}
export async function documentCountDocuments(connectionId: string, collection: string, filter?: string, executionId?: string): Promise<number> {
return post("/api/document-store/count-documents", {
connectionId,
collection,
filter,
executionId,
});
}
export async function dynamodbDescribeTable(connectionId: string, table: string): Promise<DynamoDbTableDescription> {
return post("/api/document-store/dynamodb-describe-table", { connectionId, table });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return post("/api/document-store/elasticsearch-count-documents", {
connectionId,
+40 -1
View File
@@ -3286,6 +3286,31 @@ export interface DocumentQueryResult {
extended_documents?: any[];
total: number;
total_is_exact?: boolean;
next_cursor?: string;
}
export interface DynamoDbKeyInfo {
name: string;
attributeType: "S" | "N" | "B" | string;
}
export interface DynamoDbIndexInfo {
name: string;
kind: "global" | "local" | string;
partitionKey: DynamoDbKeyInfo;
sortKey?: DynamoDbKeyInfo;
projectionType: "ALL" | "KEYS_ONLY" | "INCLUDE" | string;
nonKeyAttributes: string[];
}
export interface DynamoDbTableDescription {
name: string;
status: string;
itemCount: number;
sizeBytes: number;
partitionKey: DynamoDbKeyInfo;
sortKey?: DynamoDbKeyInfo;
indexes: DynamoDbIndexInfo[];
}
// Kept for callers that are specifically using MongoDB APIs.
@@ -3448,7 +3473,7 @@ export async function mongoParseShellCommand(source: string): Promise<MongoComma
return normalizeRustMongoCommand(raw);
}
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string): Promise<DocumentQueryResult> {
export async function documentFindDocuments(connectionId: string, database: string, collection: string, skip: number, limit: number, filter?: string, projection?: string, sort?: string, collation?: string, executionId?: string, cursor?: string): Promise<DocumentQueryResult> {
return invoke("document_find_documents", {
connectionId,
database,
@@ -3459,10 +3484,24 @@ export async function documentFindDocuments(connectionId: string, database: stri
projection,
sort,
collation,
cursor,
executionId,
});
}
export async function documentCountDocuments(connectionId: string, collection: string, filter?: string, executionId?: string): Promise<number> {
return invoke("document_count_documents", {
connectionId,
collection,
filter,
executionId,
});
}
export async function dynamodbDescribeTable(connectionId: string, table: string): Promise<DynamoDbTableDescription> {
return invoke("dynamodb_describe_table", { connectionId, table });
}
export async function elasticsearchCountDocuments(connectionId: string, index: string, filter?: string, executionId?: string): Promise<number> {
return invoke("elasticsearch_count_documents", {
connectionId,
@@ -136,6 +136,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
case "chromadb":
case "rqlite":
case "turso":
case "dynamodb":
case "mq":
case "consul":
return connection.ssl ? "https" : "http";
@@ -197,6 +198,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "mongodb":
return "mongodb://user:password@host:port/database";
case "dynamodb":
return "https://dynamodb.us-east-1.amazonaws.com";
case "clickhouse":
return "clickhouse://user:password@host:port/database";
@@ -49,6 +49,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
zookeeper: { type: "zookeeper", profile: "zookeeper", label: "Apache ZooKeeper", defaultPort: 2181 },
mongodb: { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
"mongodb+srv": { type: "mongodb", profile: "mongodb", label: "MongoDB", defaultPort: 27017 },
dynamodb: { type: "dynamodb", profile: "dynamodb", label: "Amazon DynamoDB", defaultPort: 443 },
clickhouse: { type: "clickhouse", profile: "clickhouse", label: "ClickHouse", defaultPort: 8123 },
sqlserver: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
mssql: { type: "sqlserver", profile: "sqlserver", label: "SQL Server", defaultPort: 1433 },
@@ -83,6 +84,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
clickhouse: SCHEME_PROFILES.clickhouse,
dynamodb: SCHEME_PROFILES.dynamodb,
elasticsearch: SCHEME_PROFILES.elasticsearch,
easysearch: SCHEME_PROFILES.easysearch,
meilisearch: SCHEME_PROFILES.meilisearch,
@@ -190,6 +192,10 @@ function databaseFromPath(pathname: string): string | undefined {
return decodeUrlPart(value.split("/")[0]);
}
function dynamodbRegionFromHost(hostname: string): string | undefined {
return hostname.toLowerCase().match(/^dynamodb(?:-fips)?\.([a-z0-9-]+)\.(?:amazonaws\.com(?:\.cn)?|api\.aws)$/)?.[1];
}
function parseZooKeeperUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^zookeeper:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?$/i);
if (!match) return null;
@@ -665,7 +671,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: mysqlCredentials?.username ?? decodeUrlPart(parsed.username),
password: mysqlCredentials?.password ?? decodeUrlPart(parsed.password),
database: profile.type === "victoriametrics" ? "metrics" : isMeilisearch ? undefined : databaseFromPath(parsed.pathname),
database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : databaseFromPath(parsed.pathname),
urlParams: effectiveUrlParams,
ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)),
...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}),
@@ -759,7 +765,7 @@ export function applyParsedConnectionUrl(config: Omit<ConnectionConfig, "id">, p
name: parsed.name?.trim() || config.name,
username: applyParsedUsername(config, parsed),
password: applyParsedPassword(config, parsed),
database: parsed.database,
database: parsed.dbType === "dynamodb" ? parsed.database || config.database || "us-east-1" : parsed.database,
url_params: parsed.urlParams,
ssl: parsed.ssl,
connection_string: parsed.connectionString,
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
// non-database services expose their own root objects rather than database namespaces.
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
@@ -27,6 +27,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
clickhouse: { connection: "database" },
sqlserver: { connection: "database", database: "schema" },
mongodb: { connection: "special" },
dynamodb: { deferred: "table creation requires dedicated DynamoDB key and capacity options" },
oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" },
elasticsearch: { deferred: "index creation is not modeled as database creation" },
easysearch: { deferred: "index creation is not modeled as database creation" },
@@ -27,6 +27,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
clickhouse: { deferred: "database property editing not verified for first pass" },
sqlserver: { deferred: "database options are broad administrative settings and need a dedicated editor" },
mongodb: { deferred: "database options are not modeled as SQL database properties" },
dynamodb: { deferred: "table settings require a dedicated DynamoDB workflow" },
oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" },
elasticsearch: { deferred: "index settings are not database properties" },
easysearch: { deferred: "index settings are not database properties" },
@@ -27,6 +27,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"consul-overview",
"mongo-gridfs",
"mongo-bucket",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
@@ -38,7 +39,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
"extension",
]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "vector-collection", "elasticsearch-index"]);
const fullWidthLabelTypes: Set<TreeNodeType> = new Set(["table", "view", "materialized_view", "mongo-collection", "mongo-bucket", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
const emptyContainerTypes: Set<TreeNodeType> = new Set(["saved-sql-root", "saved-sql-folder", "type"]);
@@ -58,12 +59,13 @@ const pinnableTypes: Set<TreeNodeType> = new Set([
"mongo-gridfs",
"mongo-bucket",
"mongo-collection",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"nacos-namespace",
]);
const commentTypes: Set<TreeNodeType> = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "vector-collection", "elasticsearch-index"]);
const commentTypes: Set<TreeNodeType> = new Set(["connection", "schema", "table", "view", "materialized_view", "column", "mongo-collection", "dynamodb-table", "vector-collection", "elasticsearch-index"]);
export const SIDEBAR_INDENT_DEFAULT_PX = 16;
@@ -8,7 +8,7 @@ export type SidebarSelectionCopyAction = "copy-name" | "none";
export type SidebarActivation = "single" | "double";
const dataNodeTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket"]);
const documentBrowserNodeTypes = new Set<TreeNodeType>(["mongo-collection", "mongo-bucket", "dynamodb-table"]);
const toggleLeafNodeTypes = new Set<TreeNodeType>([
"redis-db",
"mq-tenant",
@@ -24,6 +24,7 @@ const toggleLeafNodeTypes = new Set<TreeNodeType>([
"mongo-gridfs",
"mongo-collection",
"mongo-bucket",
"dynamodb-table",
"vector-collection",
"elasticsearch-index",
"user-admin",
+20 -1
View File
@@ -50,10 +50,29 @@ export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlR
return { ...highest, statements };
}
export function classifySqlStatementRisk(sql: string, _options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
const dynamodbRisk = classifyDynamoDbStatementRisk(sql, options.dialect);
if (dynamodbRisk) return dynamodbRisk;
return classifyTokens(tokenizeSqlForRisk(sql));
}
function classifyDynamoDbStatementRisk(sql: string, dialect?: DatabaseType | string): SqlRiskStatementAssessment | null {
if (dialect !== "dynamodb") return null;
const header = sql
.split(/\r?\n/)
.find((line) => line.trim())
?.trim()
.toUpperCase();
if (header === "DBX DYNAMODB SCAN" || header === "DBX DYNAMODB QUERY / SCAN") {
return { risk: "read", firstKeyword: "dbx" };
}
if (header === "DBX DYNAMODB INSERT ITEM" || header === "DBX DYNAMODB PUT ITEM" || header === "DBX DYNAMODB DELETE ITEM") {
return { risk: "write", firstKeyword: "dbx" };
}
if (header?.startsWith("DBX DYNAMODB")) return { risk: "unknown", firstKeyword: "dbx" };
return null;
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
+43 -2
View File
@@ -1732,7 +1732,7 @@ export const useConnectionStore = defineStore("connection", () => {
function invalidateMetadataCachesForNode(node: TreeNode) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined);
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
connectionId: node.connectionId,
database: node.database || undefined,
@@ -3135,6 +3135,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadConsulRoot(connectionId);
} else if (config.db_type === "mongodb") {
await loadMongoDatabases(connectionId);
} else if (config.db_type === "dynamodb") {
await loadDynamoDbTables(connectionId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
// Reload: list indices.
await loadElasticsearchIndices(connectionId);
@@ -3795,7 +3797,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
@@ -4215,6 +4217,42 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function loadDynamoDbTables(connectionId: string) {
const node = findConnectionNode(connectionId);
if (!node) return;
let load = beginTreeNodeLoad(node);
try {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const config = getConfig(connectionId);
const region = config?.database?.trim() || "us-east-1";
const tables = await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, region), "DynamoDB tables");
const targetNode = treeNodeLoadTarget(load);
if (!targetNode) return;
setChildren(
targetNode,
[...tables]
.sort((left, right) => compareSidebarNames(left.name, right.name))
.map((table) => ({
id: `${connectionId}:__dynamodb_table:${table.id}`,
label: table.name,
type: "dynamodb-table" as const,
connectionId,
database: region,
tableName: table.name,
isExpanded: false,
})),
);
targetNode.isExpanded = true;
} catch (e) {
recordMetadataLoadError(connectionId, e, load);
throw e;
} finally {
finishTreeNodeLoad(load);
}
}
/**
* Connect an Elasticsearch root without expanding or listing indices.
* Used when first opening a connection (test/connect) — connectivity uses
@@ -5888,6 +5926,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadConsulRoot(node.connectionId);
} else if (config?.db_type === "mongodb") {
await loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
await loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
@@ -8006,6 +8046,7 @@ export const useConnectionStore = defineStore("connection", () => {
loadNacosNamespaces,
updateRedisDbKeyStats,
loadMongoDatabases,
loadDynamoDbTables,
loadMilvusDatabases,
openElasticsearchConnectionTree,
loadElasticsearchIndices,
+2
View File
@@ -14,6 +14,7 @@ export type DatabaseType =
| "clickhouse"
| "sqlserver"
| "mongodb"
| "dynamodb"
| "oracle"
| "elasticsearch"
| "easysearch"
@@ -960,6 +961,7 @@ export type TreeNodeType =
| "mongo-buckets"
| "mongo-bucket"
| "mongo-collection"
| "dynamodb-table"
| "vector-database"
| "vector-collection"
| "elasticsearch-index"
+3 -1
View File
@@ -5,8 +5,9 @@ edition = "2021"
license = "Apache-2.0"
[features]
default = ["duckdb-sidecar", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
duckdb-sidecar = []
dynamodb = ["dep:aws-sdk-dynamodb"]
mq-admin = ["dep:rumqttc"]
sqlite-sqlcipher = ["rusqlite/bundled-sqlcipher-vendored-openssl"]
openapi = ["dep:utoipa"]
@@ -50,6 +51,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
prometheus-parse = "=0.2.5"
futures = "0.3"
mongodb = { version = "3.2.5", features = ["socks5-proxy"] }
aws-sdk-dynamodb = { version = "=1.104.0", optional = true }
rumqttc = { version = "0.24", features = ["websocket"], optional = true }
russh = "0.60"
pageant = "0.2"
@@ -296,6 +296,35 @@
"driverManagement": false
}
},
{
"dbType": "dynamodb",
"label": "Amazon DynamoDB",
"runtimeMode": "native",
"mcpMode": "bridge",
"singleConnectionPool": true,
"metadataConnectionScoped": false,
"skipTcpProbe": false,
"defaultPort": 8000,
"supportLevel": "operate",
"capabilities": {
"queryExecution": false,
"metadataBrowse": true,
"objectBrowser": false,
"objectSource": false,
"schemaSearch": false,
"diagram": false,
"tableDataEdit": true,
"tableStructureEdit": false,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": false,
"databaseCreate": false,
"fieldLineage": false,
"sqlExplain": false,
"userAdmin": false,
"driverManagement": false
}
},
{
"dbType": "oracle",
"label": "Oracle",
+29
View File
@@ -87,6 +87,7 @@ pub enum PoolKind {
Redis(db::redis_driver::RedisConnection),
DuckDbWorker(DuckDbWorkerHandle),
MongoDb(mongodb::Client),
DynamoDb(db::dynamodb_driver::DynamoDbClient),
ClickHouse(db::clickhouse_driver::ChClient),
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
Elasticsearch(db::elasticsearch_driver::EsClient),
@@ -2022,6 +2023,11 @@ impl AppState {
}
}
}
DatabaseType::DynamoDb => {
let client = db::dynamodb_driver::connect(&db_config, &host, port)?;
db::dynamodb_driver::test_connection(&client, connect_timeout).await?;
PoolKind::DynamoDb(client)
}
DatabaseType::ClickHouse => {
let username = if db_config.username.is_empty() { None } else { Some(db_config.username.clone()) };
let password = if db_config.password.is_empty() { None } else { Some(db_config.password.clone()) };
@@ -3100,6 +3106,18 @@ impl AppState {
}
}
}
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
let timeout = crate::db::connection_timeout();
match db::dynamodb_driver::test_connection(&client, timeout).await {
Ok(()) => false,
Err(err) => {
log::warn!("DynamoDB connection pool '{pool_key}' is stale: {err}");
true
}
}
}
PoolKind::ClickHouse(client) => {
let client = client.clone();
drop(connections);
@@ -4051,6 +4069,13 @@ impl AppState {
false
}
},
PoolKind::DynamoDb(client) => match db::dynamodb_driver::test_connection(client, timeout).await {
Ok(()) => true,
Err(e) => {
log::warn!("DynamoDB connection pool '{key}' is unhealthy: {e}");
false
}
},
PoolKind::ClickHouse(client) => match db::clickhouse_driver::test_connection(client, timeout).await {
Ok(()) => true,
Err(e) => {
@@ -4844,6 +4869,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
#[cfg(not(feature = "duckdb-sidecar"))]
PoolKind::DuckDbWorker(_) => PoolKind::DuckDbWorker(()),
PoolKind::MongoDb(client) => PoolKind::MongoDb(client.clone()),
PoolKind::DynamoDb(client) => PoolKind::DynamoDb(client.clone()),
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
@@ -4888,6 +4914,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
PoolKind::MongoDb(client) => {
drop(client);
}
PoolKind::DynamoDb(client) => {
drop(client);
}
PoolKind::ClickHouse(client) => {
drop(client);
}
@@ -18,6 +18,7 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
| DatabaseType::Turso
| DatabaseType::CloudflareD1
| DatabaseType::MongoDb
| DatabaseType::DynamoDb
| DatabaseType::Hbase
| DatabaseType::Oracle
| DatabaseType::Dameng
@@ -13,6 +13,10 @@ pub struct DocumentQueryResult {
// unchanged and only send the flag when Elasticsearch reports a lower bound.
#[serde(default = "default_total_is_exact", skip_serializing_if = "is_true")]
pub total_is_exact: bool,
/// Opaque document-store cursor for APIs such as DynamoDB that do not
/// support offset pagination.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
fn default_total_is_exact() -> bool {
@@ -35,6 +39,7 @@ mod tests {
extended_documents: None,
total: 1,
total_is_exact: true,
next_cursor: None,
};
let serialized = serde_json::to_value(result).unwrap();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
use std::time::Duration;
use crate::db::document_result::DocumentQueryResult;
use crate::models::connection::ConnectionConfig;
use crate::types::QueryResult;
const FEATURE_DISABLED: &str = "DynamoDB support is not included in this DBX build";
#[derive(Clone)]
pub struct DynamoDbClient {
pub region: String,
pub endpoint_url: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DynamoDbKeyInfo {
pub name: String,
pub attribute_type: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DynamoDbIndexInfo {
pub name: String,
pub kind: String,
pub partition_key: DynamoDbKeyInfo,
pub sort_key: Option<DynamoDbKeyInfo>,
pub projection_type: String,
pub non_key_attributes: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DynamoDbTableDescription {
pub name: String,
pub status: String,
pub item_count: u64,
pub size_bytes: u64,
pub partition_key: DynamoDbKeyInfo,
pub sort_key: Option<DynamoDbKeyInfo>,
pub indexes: Vec<DynamoDbIndexInfo>,
}
pub fn connect(_config: &ConnectionConfig, _host: &str, _port: u16) -> Result<DynamoDbClient, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn test_connection(_client: &DynamoDbClient, _timeout: Duration) -> Result<(), String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn execute_statement(
_client: &DynamoDbClient,
_source: &str,
_max_rows: usize,
) -> Result<QueryResult, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn list_tables(_client: &DynamoDbClient) -> Result<Vec<String>, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn describe_table(_client: &DynamoDbClient, _table_name: &str) -> Result<DynamoDbTableDescription, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn find_items(
_client: &DynamoDbClient,
_table_name: &str,
_limit: i64,
_filter_json: Option<&str>,
_sort_json: Option<&str>,
_cursor: Option<&str>,
) -> Result<DocumentQueryResult, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn count_items(
_client: &DynamoDbClient,
_table_name: &str,
_filter_json: Option<&str>,
) -> Result<u64, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn insert_item(_client: &DynamoDbClient, _table_name: &str, _doc_json: &str) -> Result<String, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn update_item(
_client: &DynamoDbClient,
_table_name: &str,
_id: &str,
_doc_json: &str,
) -> Result<u64, String> {
Err(FEATURE_DISABLED.to_string())
}
pub async fn delete_item(_client: &DynamoDbClient, _table_name: &str, _id: &str) -> Result<u64, String> {
Err(FEATURE_DISABLED.to_string())
}
@@ -829,6 +829,7 @@ fn search_response_to_document_result(result: SearchResponse) -> Result<Document
extended_documents: None,
total,
total_is_exact,
next_cursor: None,
})
}
@@ -274,6 +274,7 @@ pub async fn find_documents(
extended_documents: None,
total: result.total,
total_is_exact: true,
next_cursor: None,
})
}
+6
View File
@@ -11,6 +11,12 @@ pub mod duckdb_sql;
pub mod duckdb_worker_process;
#[cfg(feature = "duckdb-sidecar")]
pub mod duckdb_worker_protocol;
#[cfg(feature = "dynamodb")]
#[path = "dynamodb_driver.rs"]
pub mod dynamodb_driver;
#[cfg(not(feature = "dynamodb"))]
#[path = "dynamodb_driver_disabled.rs"]
pub mod dynamodb_driver;
pub mod easysearch_driver;
pub mod elasticsearch_driver;
pub mod elasticsearch_sql;
+8
View File
@@ -189,6 +189,7 @@ pub async fn run_command(client: &Client, database: &str, command_json: &str) ->
extended_documents: Some(vec![extended_document]),
total: 1,
total_is_exact: true,
next_cursor: None,
})
}
@@ -1198,6 +1199,7 @@ async fn find_documents_with_total(
extended_documents: Some(extended_documents),
total,
total_is_exact,
next_cursor: None,
})
}
@@ -1354,6 +1356,7 @@ pub async fn find_documents_extended_json(
raw_documents: None,
total,
total_is_exact,
next_cursor: None,
})
}
@@ -1422,6 +1425,7 @@ pub async fn aggregate_documents(
extended_documents: Some(vec![extended]),
total: 1,
total_is_exact: true,
next_cursor: None,
});
}
@@ -1479,6 +1483,7 @@ async fn drain_document_cursor(
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
next_cursor: None,
})
}
@@ -1539,6 +1544,7 @@ pub async fn distinct(
extended_documents: Some(extended_documents),
total,
total_is_exact: true,
next_cursor: None,
})
}
@@ -2107,6 +2113,7 @@ fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
extended_documents: Some(vec![Bson::Document(document).into_canonical_extjson()]),
total: 1,
total_is_exact: true,
next_cursor: None,
},
None => MongoDocumentResult {
documents: Vec::new(),
@@ -2114,6 +2121,7 @@ fn single_document_result(document: Option<Document>) -> MongoDocumentResult {
extended_documents: Some(Vec::new()),
total: 0,
total_is_exact: true,
next_cursor: None,
},
}
}
+2
View File
@@ -621,6 +621,7 @@ pub async fn find_documents(
extended_documents: None,
total: result.affected_rows,
total_is_exact: true,
next_cursor: None,
});
}
@@ -669,6 +670,7 @@ pub async fn find_documents(
extended_documents: None,
total: result.affected_rows,
total_is_exact: true,
next_cursor: None,
})
}
+83 -1
View File
@@ -1,7 +1,7 @@
use crate::connection::{AppState, PoolKind};
use crate::db::agent_driver::mongo_document_id_params;
use crate::db::document_result::DocumentQueryResult;
use crate::db::{easysearch_driver, elasticsearch_driver, mongo_driver, vector_driver};
use crate::db::{dynamodb_driver, easysearch_driver, elasticsearch_driver, mongo_driver, vector_driver};
pub use crate::db::vector_driver::CollectionInfo;
@@ -54,6 +54,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
}
Err(error) => Err(error),
},
PoolKind::DynamoDb(client) => Ok(vec![client.region.clone()]),
PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]),
PoolKind::Easysearch(_) => Ok(vec!["default".to_string()]),
PoolKind::Meilisearch(_) => Ok(vec!["default".to_string()]),
@@ -246,6 +247,20 @@ pub async fn list_collections_core(
infos.extend(specs.into_iter().map(|spec| mongo_collection_info(spec.name, spec.kind)));
Ok(infos)
}
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
let names = dynamodb_driver::list_tables(&client).await?;
Ok(names
.into_iter()
.map(|name| CollectionInfo {
id: name.clone(),
name,
kind: Some("table".to_string()),
..Default::default()
})
.collect())
}
PoolKind::Elasticsearch(client) => {
let names = sort_names(elasticsearch_driver::list_indices(client).await?);
Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
@@ -408,6 +423,7 @@ pub async fn find_documents_core(
projection: Option<&str>,
sort: Option<&str>,
collation: Option<&str>,
cursor: Option<&str>,
) -> Result<DocumentQueryResult, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
@@ -420,6 +436,12 @@ pub async fn find_documents_core(
)
.await
}
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
let _ = (database, skip, projection, collation);
dynamodb_driver::find_items(&client, collection, limit, filter, sort, cursor).await
}
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
@@ -469,6 +491,51 @@ pub async fn find_documents_core(
}
}
pub async fn count_document_store_documents_core(
state: &AppState,
connection_id: &str,
collection: &str,
filter: Option<&str>,
) -> Result<u64, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
dynamodb_driver::count_items(&client, collection, filter).await
}
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
elasticsearch_driver::count_documents(&client, collection, filter).await
}
PoolKind::Easysearch(client) => {
let client = client.clone();
drop(connections);
easysearch_driver::count_documents(&client, collection, filter).await
}
_ => Err("Document count is not supported for this connection".to_string()),
}
}
pub async fn describe_dynamodb_table_core(
state: &AppState,
connection_id: &str,
table: &str,
) -> Result<dynamodb_driver::DynamoDbTableDescription, String> {
ensure_document_pool(state, connection_id).await?;
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
dynamodb_driver::describe_table(&client, table).await
}
_ => Err("Not a DynamoDB connection".to_string()),
}
}
pub async fn count_elasticsearch_documents_core(
state: &AppState,
connection_id: &str,
@@ -509,6 +576,11 @@ pub async fn insert_document_core(
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::insert_document(client, database, collection, doc_json).await,
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
dynamodb_driver::insert_item(&client, collection, doc_json).await
}
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
@@ -573,6 +645,11 @@ pub async fn update_document_core(
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::update_document(client, database, collection, id, doc_json).await,
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
dynamodb_driver::update_item(&client, collection, id, doc_json).await
}
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
@@ -630,6 +707,11 @@ pub async fn delete_document_core_with_type(
let connections = state.connections.read().await;
match connections.get(connection_id).ok_or("Not found")? {
PoolKind::MongoDb(client) => mongo_driver::delete_document(client, database, collection, id).await,
PoolKind::DynamoDb(client) => {
let client = client.clone();
drop(connections);
dynamodb_driver::delete_item(&client, collection, id).await
}
PoolKind::Elasticsearch(client) => {
let client = client.clone();
drop(connections);
+2
View File
@@ -1,3 +1,5 @@
#![recursion_limit = "256"]
pub mod agent_catalog;
pub mod agent_connection;
pub mod agent_events;
+10
View File
@@ -511,6 +511,8 @@ pub enum DatabaseType {
SqlServer,
#[serde(rename = "mongodb")]
MongoDb,
#[serde(rename = "dynamodb")]
DynamoDb,
#[serde(rename = "oracle")]
Oracle,
#[serde(rename = "elasticsearch")]
@@ -1093,6 +1095,10 @@ impl ConnectionConfig {
let db_part = mongo_uri_db_part_for_suffix(&db_part, &suffix);
format!("mongodb://{host}:{port}{db_part}{suffix}")
}
DatabaseType::DynamoDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch
| DatabaseType::Easysearch
@@ -1258,6 +1264,10 @@ impl ConnectionConfig {
format!("mongodb://{username}:{password}@{host}:{port}{db_part}{suffix}")
}
}
DatabaseType::DynamoDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Oracle => {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
+5
View File
@@ -224,6 +224,7 @@ pub async fn mongo_find_documents_core(
projection,
sort,
collation,
None,
)
.await
}
@@ -1299,6 +1300,7 @@ mod tests {
extended_documents: Some(vec![serde_json::json!(1), serde_json::json!(2), serde_json::json!(3)]),
total: 3,
total_is_exact: true,
next_cursor: None,
};
let limited = limit_mongo_documents(result, 2);
@@ -1323,6 +1325,7 @@ mod tests {
extended_documents: None,
total: 1,
total_is_exact: true,
next_cursor: None,
};
let query_result = mongo_show_databases_query_result(result.documents, 2).unwrap();
@@ -1348,6 +1351,7 @@ mod tests {
extended_documents: None,
total: 1,
total_is_exact: true,
next_cursor: None,
};
let query_result = mongo_show_databases_query_result(empty.documents, 100).unwrap();
assert_eq!(query_result.columns, ["name", "sizeOnDisk", "empty"]);
@@ -1360,6 +1364,7 @@ mod tests {
extended_documents: None,
total: 1,
total_is_exact: true,
next_cursor: None,
};
assert!(mongo_show_databases_query_result(malformed.documents, 100).unwrap_err().contains("databases array"));
}
+60
View File
@@ -2067,6 +2067,15 @@ async fn do_execute_typed(
.map(|result| truncate_result_with_max_rows(result, max_rows))
}
PoolKind::HBase(_) => Err("SQL execution is not supported for HBase connections".to_string()),
PoolKind::DynamoDb(client) => {
let client = client.clone();
let sql = sql.to_string();
let max_rows = options.max_rows.unwrap_or(MAX_ROWS);
drop(connections);
// Keep the AWS SDK cold-path future off this already-large query dispatcher stack.
let execution = Box::pin(db::dynamodb_driver::execute_statement(&client, &sql, max_rows));
wait_for_query_opt(cancel_token, query_timeout, execution).await
}
PoolKind::Consul(_) => Err("SQL execution is not supported for Consul connections".to_string()),
};
result
@@ -3498,6 +3507,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::DuckDbWorker(_)
| PoolKind::Redis(_)
| PoolKind::MongoDb(_)
| PoolKind::DynamoDb(_)
| PoolKind::Elasticsearch(_)
| PoolKind::Easysearch(_)
| PoolKind::Meilisearch(_)
@@ -3766,6 +3776,7 @@ pub async fn execute_statements_in_transaction_on_pool_typed(
PoolKind::DuckDbWorker(_)
| PoolKind::Redis(_)
| PoolKind::MongoDb(_)
| PoolKind::DynamoDb(_)
| PoolKind::Elasticsearch(_)
| PoolKind::Easysearch(_)
| PoolKind::Meilisearch(_)
@@ -5231,6 +5242,55 @@ for line in sys.stdin:
}
}
#[cfg(feature = "dynamodb")]
#[tokio::test]
#[ignore = "requires DBX_DYNAMODB_ENDPOINT and an orders table"]
async fn live_dynamodb_editor_scan_serializes_one_thousand_rows() {
let endpoint = std::env::var("DBX_DYNAMODB_ENDPOINT").expect("DBX_DYNAMODB_ENDPOINT is required");
let (ssl, address) = endpoint
.strip_prefix("https://")
.map(|address| (true, address))
.or_else(|| endpoint.strip_prefix("http://").map(|address| (false, address)))
.expect("DynamoDB endpoint must start with http:// or https://");
let (host, port) = address.rsplit_once(':').expect("DynamoDB endpoint must include a port");
let dir = std::env::temp_dir().join(format!("dbx-query-dynamodb-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let mut config = test_connection_config(DatabaseType::DynamoDb);
config.host = host.to_string();
config.port = port.parse().expect("valid DynamoDB port");
config.username = "dummy".to_string();
config.password = "dummy".to_string();
config.database = Some("us-east-1".to_string());
config.ssl = ssl;
let client = db::dynamodb_driver::connect(&config, host, config.port).unwrap();
db::dynamodb_driver::test_connection(&client, Duration::from_secs(5)).await.unwrap();
state.configs.write().await.insert(config.id.clone(), config.clone());
state.connections.write().await.insert(config.id.clone(), PoolKind::DynamoDb(client));
let results = execute_multi_core_with_options_for_client_and_progress_typed(
&state,
&config.id,
"us-east-1",
"DBX DYNAMODB SCAN\ntable: \"orders\"\nlimit: 1000",
None,
None,
QueryExecutionOptions { max_rows: Some(1000), ..Default::default() },
None,
)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].result.rows.len(), 1000);
let serialized = serde_json::to_string(&results).unwrap();
assert!(!serialized.is_empty());
drop(state);
let _ = std::fs::remove_dir_all(dir);
}
async fn agent_error_state(
disposition: &str,
) -> (AppState, std::path::PathBuf, std::sync::Arc<crate::db::agent_driver::AgentRuntimeClient>) {
@@ -247,12 +247,27 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
if database_type == DatabaseType::VictoriaMetrics {
return false;
}
if database_type == DatabaseType::DynamoDb {
if let Some(is_write) = classify_dynamodb_statement_write(sql) {
return is_write;
}
}
if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
return risk != SearchEngineQueryRisk::ReadOnly;
}
is_write_sql_with_database_type(sql, Some(database_type))
}
fn classify_dynamodb_statement_write(source: &str) -> Option<bool> {
let header = source.lines().find(|line| !line.trim().is_empty())?.trim().to_ascii_uppercase();
match header.as_str() {
"DBX DYNAMODB SCAN" | "DBX DYNAMODB QUERY / SCAN" => Some(false),
"DBX DYNAMODB INSERT ITEM" | "DBX DYNAMODB PUT ITEM" | "DBX DYNAMODB DELETE ITEM" => Some(true),
_ if header.starts_with("DBX DYNAMODB") => Some(true),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SearchEngineQueryRisk {
ReadOnly,
@@ -1598,6 +1613,24 @@ mod tests {
assert_eq!(strip_sql_comments("SELECT 'hello /* not a comment */'"), "SELECT 'hello '");
}
#[test]
fn classifies_dynamodb_generated_statements_for_read_only_protection() {
assert!(!is_write_sql_for_database(
"DBX DYNAMODB SCAN\ntable: \"orders\"\nlimit: 1000",
DatabaseType::DynamoDb
));
assert!(!is_write_sql_for_database(
"DBX DYNAMODB QUERY / SCAN\ntable: \"orders\"\nfilter:\n{\"status\":\"SHIPPED\"}",
DatabaseType::DynamoDb
));
for operation in ["INSERT ITEM", "PUT ITEM", "DELETE ITEM", "UNKNOWN"] {
assert!(is_write_sql_for_database(
&format!("DBX DYNAMODB {operation}\ntable: \"orders\""),
DatabaseType::DynamoDb
));
}
}
#[test]
fn classifies_search_engine_rest_writes() {
assert!(!is_write_sql_for_database("GET /_cluster/health", DatabaseType::Easysearch));
@@ -675,10 +675,9 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
}
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate | ChromaDb | Neo4j
| Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics | MessageQueue | Mqtt | Hbase => {
conservative_ansi(db_type)
}
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb | VictoriaMetrics
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
}
}
+2 -1
View File
@@ -9,8 +9,9 @@ name = "dbx-web"
path = "src/main.rs"
[features]
default = ["duckdb-sidecar", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
duckdb-sidecar = ["dbx-core/duckdb-sidecar"]
dynamodb = ["dbx-core/dynamodb"]
mq-admin = ["dbx-core/mq-admin"]
sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
system-fonts = ["dbx-core/system-fonts"]
+2
View File
@@ -777,6 +777,8 @@ async fn main() {
.route("/document-store/list-databases", post(routes::document_store::list_databases))
.route("/document-store/list-collections", post(routes::document_store::list_collections))
.route("/document-store/find-documents", post(routes::document_store::find_documents))
.route("/document-store/count-documents", post(routes::document_store::count_documents))
.route("/document-store/dynamodb-describe-table", post(routes::document_store::describe_dynamodb_table))
.route(
"/document-store/elasticsearch-count-documents",
post(routes::document_store::elasticsearch_count_documents),
@@ -67,9 +67,26 @@ pub struct DocumentFindRequest {
pub projection: Option<String>,
pub sort: Option<String>,
pub collation: Option<String>,
pub cursor: Option<String>,
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentCountRequest {
pub connection_id: String,
pub collection: String,
pub filter: Option<String>,
pub execution_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DynamoDbDescribeTableRequest {
pub connection_id: String,
pub table: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElasticsearchCountDocumentsRequest {
@@ -204,12 +221,41 @@ pub async fn find_documents(
req.projection.as_deref(),
req.sort.as_deref(),
req.collation.as_deref(),
req.cursor.as_deref(),
),
)
.await?;
Ok(Json(result))
}
pub async fn count_documents(
State(state): State<Arc<WebState>>,
Json(req): Json<DocumentCountRequest>,
) -> Result<Json<u64>, AppError> {
let result = run_cancellable(
&state,
req.execution_id,
dbx_core::document_ops::count_document_store_documents_core(
&state.app,
&req.connection_id,
&req.collection,
req.filter.as_deref(),
),
)
.await?;
Ok(Json(result))
}
pub async fn describe_dynamodb_table(
State(state): State<Arc<WebState>>,
Json(req): Json<DynamoDbDescribeTableRequest>,
) -> Result<Json<dbx_core::db::dynamodb_driver::DynamoDbTableDescription>, AppError> {
let result = dbx_core::document_ops::describe_dynamodb_table_core(&state.app, &req.connection_id, &req.table)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
pub async fn elasticsearch_count_documents(
State(state): State<Arc<WebState>>,
Json(req): Json<ElasticsearchCountDocumentsRequest>,
+1
View File
@@ -466,6 +466,7 @@ pub async fn find_documents(
req.projection.as_deref(),
req.sort.as_deref(),
req.collation.as_deref(),
None,
),
)
.await?;
+1 -1
View File
@@ -88,7 +88,7 @@ test("document table wires on-demand exact total counting for estimated mongo to
test("document pagination commits page index with fetched rows", () => {
const source = documentBrowserSource();
assert.match(source, /async function load\(options: \{ page\?: number \} = \{\}\)/);
assert.match(source, /void load\(\{ page: nextPage \}\)/);
assert.match(source, /(?:void|await) load\(\{ page: nextPage \}\)/);
assert.match(source, /if \(options\.page !== undefined\) page\.value = options\.page;/);
});
+2 -1
View File
@@ -13,9 +13,10 @@ name = "dbx_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["duckdb-sidecar", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
default = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "system-fonts"]
custom-protocol = ["tauri/custom-protocol"]
duckdb-sidecar = ["dbx-core/duckdb-sidecar"]
dynamodb = ["dbx-core/dynamodb"]
mq-admin = ["dbx-core/mq-admin"]
sqlite-sqlcipher = ["dbx-core/sqlite-sqlcipher"]
system-fonts = ["dbx-core/system-fonts"]
+11
View File
@@ -1105,6 +1105,12 @@ async fn test_connection_with_info_inner(
Err(native_err)
}
}
DatabaseType::DynamoDb => {
let client = db::dynamodb_driver::connect(&config, &host, port)?;
db::dynamodb_driver::test_connection(&client, connect_timeout)
.await
.map(|_| "Connection successful".to_string())
}
DatabaseType::ClickHouse => {
let username = if config.username.is_empty() { None } else { Some(config.username.clone()) };
let password = if config.password.is_empty() { None } else { Some(config.password.clone()) };
@@ -1518,6 +1524,11 @@ pub async fn connect_db(
}
}
}
DatabaseType::DynamoDb => {
let client = db::dynamodb_driver::connect(&db_config, &host, port)?;
db::dynamodb_driver::test_connection(&client, connect_timeout).await?;
PoolKind::DynamoDb(client)
}
DatabaseType::ClickHouse => {
let username = if db_config.username.is_empty() { None } else { Some(db_config.username.clone()) };
let password = if db_config.password.is_empty() { None } else { Some(db_config.password.clone()) };
+33
View File
@@ -58,6 +58,7 @@ pub async fn document_find_documents(
projection: Option<String>,
sort: Option<String>,
collation: Option<String>,
cursor: Option<String>,
execution_id: Option<String>,
) -> Result<DocumentQueryResult, String> {
let app = state.inner().clone();
@@ -75,11 +76,43 @@ pub async fn document_find_documents(
projection.as_deref(),
sort.as_deref(),
collation.as_deref(),
cursor.as_deref(),
),
)
.await
}
#[tauri::command]
pub async fn document_count_documents(
state: State<'_, Arc<AppState>>,
connection_id: String,
collection: String,
filter: Option<String>,
execution_id: Option<String>,
) -> Result<u64, String> {
let app = state.inner().clone();
run_cancellable(
&app,
execution_id,
dbx_core::document_ops::count_document_store_documents_core(
&app,
&connection_id,
&collection,
filter.as_deref(),
),
)
.await
}
#[tauri::command]
pub async fn dynamodb_describe_table(
state: State<'_, Arc<AppState>>,
connection_id: String,
table: String,
) -> Result<dbx_core::db::dynamodb_driver::DynamoDbTableDescription, String> {
dbx_core::document_ops::describe_dynamodb_table_core(&state, &connection_id, &table).await
}
#[tauri::command]
pub async fn elasticsearch_count_documents(
state: State<'_, Arc<AppState>>,
+1
View File
@@ -147,6 +147,7 @@ pub async fn mongo_find_documents(
projection,
sort,
collation,
None,
execution_id,
)
.await
+2
View File
@@ -2003,6 +2003,8 @@ pub fn run() {
commands::document_cmd::document_list_databases,
commands::document_cmd::document_list_collections,
commands::document_cmd::document_find_documents,
commands::document_cmd::document_count_documents,
commands::document_cmd::dynamodb_describe_table,
commands::document_cmd::elasticsearch_count_documents,
commands::document_cmd::document_list_gridfs_buckets,
commands::document_cmd::document_create_gridfs_bucket,