Merge branch 'main' into dev/fnos-app-package

This commit is contained in:
t8y2
2026-09-28 13:15:32 +08:00
committed by GitHub
342 changed files with 16737 additions and 2267 deletions
+16
View File
@@ -145,6 +145,22 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
assert.ok(win7.includes("--timings"));
assert.ok(win7.includes("name: DBX-win7-cargo-timings"));
assert.ok(win7.includes("path: target/cargo-timings/"));
assert.ok(win7.includes("name: Measure dbx library command registry expansion"));
assert.ok(win7.includes("if: vars.WIN7_REGISTRY_AB == 'true'"));
assert.ok(win7.includes("--package dbx"));
assert.ok(win7.includes("--lib"));
assert.ok(win7.includes('$env:RUSTC_WRAPPER = ""'));
assert.ok(win7.includes('Measure-Registry "full" $fullCommandCount'));
assert.ok(win7.includes('Measure-Registry "half" $keptCommandCount'));
assert.ok(win7.includes('"-Zdump-mono-stats=$outputDir"'));
assert.ok(win7.includes('"-Zdump-mono-stats-format=json"'));
assert.ok(win7.includes("git restore --source=HEAD -- $sourcePath"));
assert.ok(win7.includes("name: DBX-win7-dbx-lib-command-registry-ab"));
assert.ok(win7.includes("if: always() && vars.WIN7_REGISTRY_AB == 'true'"));
assert.ok(win7.includes("path: ${{ runner.temp }}/dbx-lib-command-registry-ab/"));
assert.ok(win7.includes("retention-days: 3"));
assert.ok(win7.indexOf("name: Build DBX for Windows 7") < win7.indexOf("name: Measure dbx library command registry expansion"));
assert.ok(win7.indexOf("name: Measure dbx library command registry expansion") < win7.indexOf("name: Upload dbx library command registry A/B"));
assert.doesNotMatch(win7, /^\s+(?:CC|CXX):/m);
});
+1
View File
@@ -151,6 +151,7 @@ export function inferAreaLabels(changedFiles) {
if (has((file) => file.startsWith("crates/dbx-cli/")
|| file.startsWith("packages/cli-")
|| file.startsWith("packages/cli/")
|| file.startsWith("skills/dbx/")
|| file.startsWith("examples/cli/"))) {
labels.add("area/cli");
}
@@ -53,6 +53,16 @@ test("labels documentation-only changes without a conventional title", () => {
assert.deepEqual(result.labels, ["area/docs", "documentation"]);
});
test("labels the bundled DBX skill as CLI work", () => {
const result = evaluatePullRequestLabels({
title: "feat(cli): improve the bundled agent skill",
changedFiles: ["skills/dbx/SKILL.md", "skills/dbx/references/commands.md"],
knownDatabaseTypes,
});
assert.deepEqual(result.labels, ["area/cli", "enhancement"]);
});
test("maps agent and dialect paths to existing database types", () => {
assert.deepEqual(
inferDatabaseTypes([
+117
View File
@@ -577,6 +577,122 @@ jobs:
if-no-files-found: error
retention-days: 7
- name: Measure dbx library command registry expansion
if: vars.WIN7_REGISTRY_AB == 'true'
shell: pwsh
run: |
$resultRoot = Join-Path $env:RUNNER_TEMP "dbx-lib-command-registry-ab"
$sourcePath = "src-tauri/src/lib.rs"
New-Item -ItemType Directory -Force -Path $resultRoot | Out-Null
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
$results = @{}
function Measure-Registry([string] $name, [int] $commandCount) {
$outputDir = Join-Path $resultRoot $name
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
$timer = [System.Diagnostics.Stopwatch]::StartNew()
cargo rustc `
--locked `
--package dbx `
--release `
--lib `
--features custom-protocol `
--target x86_64-win7-windows-msvc `
-Z build-std=std,panic_abort `
-- `
"-Zdump-mono-stats=$outputDir" `
"-Zdump-mono-stats-format=json"
$exitCode = $LASTEXITCODE
$timer.Stop()
if ($exitCode -ne 0) {
throw "$name command registry compile failed with exit code $exitCode."
}
$monoFile = Get-ChildItem $outputDir -File -Filter "*.mono_items.json" |
Select-Object -First 1
if (!$monoFile) {
throw "$name command registry compile did not create mono statistics."
}
$monoItems = Get-Content $monoFile.FullName -Raw | ConvertFrom-Json
$results[$name] = @{
Commands = $commandCount
Definitions = $monoItems.Count
Seconds = [math]::Round($timer.Elapsed.TotalSeconds, 2)
TotalEstimate = ($monoItems | Measure-Object -Property total_estimate -Sum).Sum
}
}
$savedWrapper = $env:RUSTC_WRAPPER
try {
$env:RUSTC_WRAPPER = ""
$sourceLines = Get-Content $sourcePath
$insideHandler = $false
$fullCommandCount = 0
foreach ($line in $sourceLines) {
if ($line -match 'tauri::generate_handler!\[') {
$insideHandler = $true
continue
}
if ($insideHandler -and $line -match '^\s*\]\)\)') {
break
}
if ($insideHandler -and $line -match '^\s+commands::') {
$fullCommandCount++
}
}
Measure-Registry "full" $fullCommandCount
$insideHandler = $false
$handlerCommandCount = 0
$keptCommandCount = 0
$reducedLines = foreach ($line in $sourceLines) {
if ($line -match 'tauri::generate_handler!\[') {
$insideHandler = $true
$line
continue
}
if ($insideHandler -and $line -match '^\s*\]\)\)') {
$insideHandler = $false
$line
continue
}
if ($insideHandler -and $line -match '^\s+commands::') {
$handlerCommandCount++
if ($handlerCommandCount % 2 -eq 0) {
continue
}
$keptCommandCount++
}
$line
}
if ($handlerCommandCount -ne $fullCommandCount -or $keptCommandCount -eq $fullCommandCount) {
throw "Could not create the reduced command registry."
}
$reducedLines | Set-Content $sourcePath -Encoding utf8NoBOM
Measure-Registry "half" $keptCommandCount
} finally {
$env:RUSTC_WRAPPER = $savedWrapper
git restore --source=HEAD -- $sourcePath
}
@(
"### dbx library command registry A/B"
""
"| Registry | Commands | Definitions | Estimated cost | Seconds |"
"| --- | ---: | ---: | ---: | ---: |"
"| Full | $($results['full'].Commands) | $($results['full'].Definitions) | $($results['full'].TotalEstimate) | $($results['full'].Seconds) |"
"| Half | $($results['half'].Commands) | $($results['half'].Definitions) | $($results['half'].TotalEstimate) | $($results['half'].Seconds) |"
) | Add-Content $env:GITHUB_STEP_SUMMARY
- name: Upload dbx library command registry A/B
if: always() && vars.WIN7_REGISTRY_AB == 'true'
uses: actions/upload-artifact@v6
with:
name: DBX-win7-dbx-lib-command-registry-ab
path: ${{ runner.temp }}/dbx-lib-command-registry-ab/
if-no-files-found: error
retention-days: 3
duckdb-windows-driver:
needs: changes
if: needs.changes.outputs.duckdb_windows == 'true'
@@ -973,6 +1089,7 @@ jobs:
- 'packages/mcp-server/**'
- 'crates/dbx-cli/**'
- 'crates/dbx-mcp/**'
- 'skills/dbx/**'
- 'scripts/verify-package-install.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
Generated
+17 -4
View File
@@ -2047,7 +2047,7 @@ dependencies = [
[[package]]
name = "dbx"
version = "0.6.25"
version = "0.6.26"
dependencies = [
"anyhow",
"axum",
@@ -2057,6 +2057,7 @@ dependencies = [
"csv",
"dbx-core",
"dbx-mcp",
"dbx-tauri-consul",
"deadpool-postgres",
"font-kit",
"futures",
@@ -2128,14 +2129,16 @@ dependencies = [
[[package]]
name = "dbx-cli"
version = "0.4.99"
version = "0.4.100"
dependencies = [
"async-trait",
"dbx-core",
"dbx-mcp",
"dirs",
"rustls 0.23.43",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"tokio",
]
@@ -2460,7 +2463,7 @@ dependencies = [
[[package]]
name = "dbx-mcp"
version = "0.4.99"
version = "0.4.100"
dependencies = [
"async-trait",
"axum",
@@ -2607,6 +2610,16 @@ dependencies = [
"sqlparser",
]
[[package]]
name = "dbx-tauri-consul"
version = "0.1.0"
dependencies = [
"dbx-core",
"serde",
"serde_json",
"tauri",
]
[[package]]
name = "dbx-types"
version = "0.1.0"
@@ -2621,7 +2634,7 @@ dependencies = [
[[package]]
name = "dbx-web"
version = "0.6.25"
version = "0.6.26"
dependencies = [
"aes-gcm 0.10.3",
"argon2",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker"]
members = ["src-tauri", "crates/dbx-core", "crates/dbx-driver-agent", "crates/dbx-driver-support", "crates/dbx-driver-elasticsearch", "crates/dbx-driver-mongodb", "crates/dbx-driver-mysql", "crates/dbx-driver-postgres", "crates/dbx-driver-redis", "crates/dbx-driver-sqlserver", "crates/dbx-drivers", "crates/dbx-sql", "crates/dbx-sql-core", "crates/dbx-sql-data", "crates/dbx-sql-dialect", "crates/dbx-sql-schema", "crates/dbx-plugin-runtime", "crates/dbx-formats", "crates/dbx-types", "crates/dbx-platform", "crates/dbx-ai-provider", "crates/dbx-web", "crates/dbx-mcp", "crates/dbx-cli", "crates/dbx-sqlite-worker", "crates/dbx-tauri-consul"]
exclude = ["vendor/tauri-plugin-updater", "vendor/ctor", "vendor/dirs-sys", "vendor/pageant", "vendor/rumqttc", "vendor/tiberius", "vendor/webview2-com-sys", "vendor/wry"]
[patch.crates-io]
+3
View File
@@ -335,10 +335,13 @@ DBX also provides a dedicated CLI package for terminal, script, and Codex workfl
npm install -g @dbx-app/cli
# or via Homebrew
brew tap t8y2/tap && brew install dbx-cli
dbx agent setup
dbx connections list --json
dbx query local "select 1" --json
```
The CLI includes the official DBX Agent Skill. `dbx agent setup` installs or updates it offline under `~/.agents/skills/dbx`, so shell-capable AI agents know how to use DBX CLI safely.
See the [MCP server README](packages/mcp-server/README.md) and [CLI README](packages/cli/README.md) for details.
## Install
+3
View File
@@ -333,10 +333,13 @@ DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流:
npm install -g @dbx-app/cli
# 或通过 Homebrew
brew tap t8y2/tap && brew install dbx-cli
dbx agent setup
dbx connections list --json
dbx query local "select 1" --json
```
CLI 已内置官方 DBX Agent Skill;`dbx agent setup` 会离线安装或更新到 `~/.agents/skills/dbx`,让支持 Shell 的 AI Agent 知道如何安全调用 DBX CLI。
详见 [MCP Server 说明](packages/mcp-server/README.md) 和 [CLI 说明](packages/cli/README.md)。
## 安装
+186 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"testing"
@@ -42,7 +43,7 @@ func TestCassandraIntegration(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer runtime.close()
t.Cleanup(runtime.close)
server := newServer(runtime, connection)
if err := server.validateConnection(); err != nil {
t.Fatal(err)
@@ -54,7 +55,9 @@ func TestCassandraIntegration(t *testing.T) {
pagedTable := "paged_rows"
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
t.Cleanup(func() {
_, _ = server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)})
if _, err := server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)}); err != nil {
t.Errorf("drop integration keyspace %s: %v", keyspace, err)
}
})
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, table)+" ("+
"id int PRIMARY KEY, txt text, flag boolean, amount decimal, payload blob, created timestamp, address inet, "+
@@ -139,6 +142,187 @@ func TestCassandraIntegration(t *testing.T) {
}
}
func TestCassandraMaterializedViewDDLIntegration(t *testing.T) {
host := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_HOST"))
if host == "" {
t.Skip("Cassandra integration environment is not configured")
}
port := 9042
if rawPort := strings.TrimSpace(os.Getenv("CASSANDRA_TEST_PORT")); rawPort != "" {
parsedPort, err := strconv.Atoi(rawPort)
if err != nil {
t.Fatal(err)
}
port = parsedPort
}
ssl, err := strconv.ParseBool(envDefault("CASSANDRA_TEST_SSL", "false"))
if err != nil {
t.Fatal(err)
}
connection := connectParams{
Host: host,
Port: port,
Username: os.Getenv("CASSANDRA_TEST_USERNAME"),
Password: os.Getenv("CASSANDRA_TEST_PASSWORD"),
URLParams: os.Getenv("CASSANDRA_TEST_URL_PARAMS"),
SSL: ssl,
CACertPath: os.Getenv("CASSANDRA_TEST_CA_CERT_PATH"),
ClientCertPath: os.Getenv("CASSANDRA_TEST_CLIENT_CERT_PATH"),
ClientKeyPath: os.Getenv("CASSANDRA_TEST_CLIENT_KEY_PATH"),
}
runtime, err := newConnectionRuntime(connection)
if err != nil {
t.Fatal(err)
}
t.Cleanup(runtime.close)
server := newServer(runtime, connection)
if err := server.validateConnection(); err != nil {
t.Fatal(err)
}
connectionInfo, err := server.connectionInfo()
if err != nil {
t.Fatal(err)
}
t.Logf("Cassandra runtime version: %v", connectionInfo["version"])
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
keyspace := "dbx_issue_7475_regression_" + suffix
baseTable := "BaseEvents"
view := "EventsByCategory"
allColumnsView := "AllEventsByCategory"
mustCQL(t, server, "CREATE KEYSPACE "+quoteCQLIdentifier(keyspace)+" WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}", "")
t.Cleanup(func() {
if _, err := server.executeQuery(queryOptions{SQL: "DROP KEYSPACE IF EXISTS " + quoteCQLIdentifier(keyspace)}); err != nil {
t.Errorf("drop integration keyspace %s: %v", keyspace, err)
}
})
mustCQL(t, server, "CREATE TABLE "+qualifiedCQLName(keyspace, baseTable)+" ("+
`"Tenant Id" text, "Bucket" int, "Event Time" timestamp, "Category" text, "Payload" text, `+
`PRIMARY KEY ("Tenant Id", "Event Time", "Bucket")) `+
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`, keyspace)
mustCQL(t, server, "CREATE MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view)+" AS "+
`SELECT "Tenant Id", "Bucket", "Event Time", "Category" FROM `+qualifiedCQLName(keyspace, baseTable)+" "+
`WHERE "Tenant Id" IS NOT NULL AND "Event Time" IS NOT NULL AND "Bucket" IS NOT NULL AND "Category" IS NOT NULL `+
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket") `+
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC) AND comment = 'issue 7475 round trip' AND gc_grace_seconds = 86401`, keyspace)
mustCQL(t, server, "CREATE MATERIALIZED VIEW "+qualifiedCQLName(keyspace, allColumnsView)+" AS "+
`SELECT * FROM `+qualifiedCQLName(keyspace, baseTable)+" "+
`WHERE "Tenant Id" IS NOT NULL AND "Event Time" IS NOT NULL AND "Bucket" IS NOT NULL AND "Category" IS NOT NULL `+
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket") `+
`WITH CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`, keyspace)
ddl, err := server.getTableDDL(keyspace, view)
if err != nil {
t.Fatalf("materialized view DDL failed: %v", err)
}
for _, fragment := range []string{
"CREATE MATERIALIZED VIEW",
"FROM " + keyspace + "." + quoteCQLIdentifier(baseTable),
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket")`,
`CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`,
"comment = 'issue 7475 round trip'",
"gc_grace_seconds = 86401",
} {
if !strings.Contains(ddl, fragment) {
t.Fatalf("materialized view DDL omitted %q:\n%s", fragment, ddl)
}
}
projection := materializedViewProjection(t, ddl)
wantProjection := []string{`"Bucket"`, `"Category"`, `"Event Time"`, `"Tenant Id"`}
sort.Strings(projection)
sort.Strings(wantProjection)
if strings.Join(projection, ",") != strings.Join(wantProjection, ",") || strings.Contains(ddl, `"Payload"`) || strings.Contains(ddl, "SELECT *") {
t.Fatalf("materialized view DDL lost its selected-column subset:\n%s", ddl)
}
metadataSession, err := runtime.sessionFor("")
if err != nil {
t.Fatal(err)
}
legacyCatalog, err := legacyMaterializedViewCatalog(metadataSession, keyspace, view)
if err != nil {
t.Fatalf("legacy materialized view catalog failed: %v", err)
}
legacyDDL, err := materializedViewDDLFromCatalog(keyspace, view, legacyCatalog)
if err != nil {
t.Fatalf("legacy materialized view DDL failed: %v", err)
}
legacyProjection := materializedViewProjection(t, legacyDDL)
sort.Strings(legacyProjection)
if strings.Join(legacyProjection, ",") != strings.Join(wantProjection, ",") || strings.Contains(legacyDDL, `"Payload"`) || strings.Contains(legacyDDL, "SELECT *") {
t.Fatalf("legacy materialized view DDL lost its selected-column subset:\n%s", legacyDDL)
}
allColumnsDDL, err := server.getTableDDL(keyspace, allColumnsView)
if err != nil || !strings.Contains(allColumnsDDL, "SELECT *") {
t.Fatalf("materialized view SELECT * was not preserved: ddl=%q err=%v", allColumnsDDL, err)
}
result, _, err := server.dispatch("get_object_source", map[string]json.RawMessage{
"schema": json.RawMessage(strconv.Quote(keyspace)),
"name": json.RawMessage(strconv.Quote(view)),
"object_type": json.RawMessage(`"MATERIALIZED_VIEW"`),
})
if err != nil {
t.Fatalf("materialized view object-source RPC failed: %v", err)
}
source, ok := result.(objectSource)
if !ok || source.Source != ddl || source.Name != view || source.ObjectType != "MATERIALIZED_VIEW" {
t.Fatalf("unexpected materialized view object-source response: %#v", result)
}
tableDDL, err := server.getTableDDL(keyspace, baseTable)
if err != nil || !strings.HasPrefix(tableDDL, "CREATE TABLE ") || strings.Contains(tableDDL, "CREATE MATERIALIZED VIEW") {
t.Fatalf("ordinary table DDL changed: ddl=%q err=%v", tableDDL, err)
}
if _, err := server.getTableDDL(keyspace, "MissingObject"); err == nil {
t.Fatal("missing schema object unexpectedly returned DDL")
}
if _, err := server.getMaterializedViewDDL(keyspace, "MissingObject"); err == nil {
t.Fatal("missing materialized view unexpectedly returned DDL")
}
mustCQL(t, server, "DROP MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view), keyspace)
mustCQL(t, server, ddl, keyspace)
recreatedDDL, err := server.getTableDDL(keyspace, view)
if err != nil {
t.Fatalf("materialized view DDL after recreation failed: %v", err)
}
if recreatedDDL != ddl {
t.Fatalf("materialized view changed after DDL round trip:\nbefore:\n%s\nafter:\n%s", ddl, recreatedDDL)
}
mustCQL(t, server, "DROP MATERIALIZED VIEW "+qualifiedCQLName(keyspace, view), keyspace)
mustCQL(t, server, legacyDDL, keyspace)
legacyRecreatedDDL, err := server.getTableDDL(keyspace, view)
if err != nil {
t.Fatalf("materialized view DDL after legacy recreation failed: %v", err)
}
if legacyRecreatedDDL != ddl {
t.Fatalf("legacy materialized view DDL changed the definition:\nbefore:\n%s\nafter:\n%s", ddl, legacyRecreatedDDL)
}
}
func materializedViewProjection(t *testing.T, ddl string) []string {
t.Helper()
upper := strings.ToUpper(ddl)
selectIndex := strings.Index(upper, "SELECT ")
if selectIndex < 0 {
t.Fatalf("DDL has no SELECT clause:\n%s", ddl)
}
projectionStart := selectIndex + len("SELECT ")
fromOffset := strings.Index(upper[projectionStart:], "FROM ")
if fromOffset < 0 {
t.Fatalf("DDL has no FROM clause:\n%s", ddl)
}
projection := strings.TrimSpace(ddl[projectionStart : projectionStart+fromOffset])
if projection == "" {
t.Fatalf("DDL has an empty projection:\n%s", ddl)
}
columns := strings.Split(projection, ",")
for index := range columns {
columns[index] = strings.TrimSpace(columns[index])
}
return columns
}
func envDefault(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
+6 -1
View File
@@ -365,7 +365,12 @@ func (s *server) dispatch(method string, params map[string]json.RawMessage) (any
result, err := s.listTriggers(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
case "get_object_source":
return nil, false, errors.New("object source is not supported by Cassandra")
result, err := s.getObjectSource(
firstNonEmpty(stringParam(params, "schema"), stringParam(params, "database"), s.defaultKeyspace()),
firstNonEmpty(stringParam(params, "name"), stringParam(params, "table")),
stringParam(params, "object_type"),
)
return result, false, err
case "get_table_ddl":
result, err := s.getTableDDL(stringParam(params, "schema"), stringParam(params, "table"))
return result, false, err
@@ -0,0 +1,394 @@
package main
import (
"errors"
"fmt"
"sort"
"strings"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
const maxLegacyMaterializedViewColumns = 4096
const cassandraMaterializedViewObjectType = "MATERIALIZED_VIEW"
var legacyMaterializedViewOptionNames = []string{
"additional_write_policy",
"bloom_filter_fp_chance",
"caching",
"cdc",
"comment",
"compaction",
"compression",
"crc_check_chance",
"dclocal_read_repair_chance",
"gc_grace_seconds",
"max_index_interval",
"memtable",
"memtable_flush_period_in_ms",
"min_index_interval",
"read_repair",
"read_repair_chance",
"speculative_retry",
}
type objectSource struct {
Name string `json:"name"`
ObjectType string `json:"object_type"`
Schema *string `json:"schema"`
Source string `json:"source"`
}
type materializedViewColumn struct {
Name string
Kind string
Position int
ClusteringOrder string
}
type materializedViewCatalog struct {
BaseTable string
IncludeAllColumns bool
WhereClause string
Columns []materializedViewColumn
Options map[string]any
}
func (s *server) getObjectSource(schema, name, objectType string) (objectSource, error) {
return materializedViewObjectSource(schema, name, objectType, func() (string, error) {
return s.getMaterializedViewDDL(schema, name)
})
}
func materializedViewObjectSource(schema, name, objectType string, loadDDL func() (string, error)) (objectSource, error) {
if normalizeCassandraObjectType(objectType) != cassandraMaterializedViewObjectType {
return objectSource{}, errors.New("object source is not supported by Cassandra")
}
source, err := loadDDL()
if err != nil {
return objectSource{}, err
}
return objectSource{
Name: name,
ObjectType: cassandraMaterializedViewObjectType,
Schema: optionalString(schema),
Source: source,
}, nil
}
func normalizeCassandraObjectType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
return strings.ReplaceAll(value, " ", "_")
}
func (s *server) getMaterializedViewDDL(schema, view string) (string, error) {
session, err := s.runtime.sessionFor("")
if err != nil {
return "", err
}
return materializedViewDDLWithFallback(
func() (string, error) {
return describeMaterializedViewDDL(session, schema, view)
},
func() (string, error) {
catalog, err := legacyMaterializedViewCatalog(session, schema, view)
if err != nil {
return "", err
}
return materializedViewDDLFromCatalog(schema, view, catalog)
},
)
}
func materializedViewDDLWithFallback(describe, legacy func() (string, error)) (string, error) {
ddl, err := describe()
if err == nil {
if strings.TrimSpace(ddl) == "" {
return "", errors.New("Cassandra returned an empty materialized view definition")
}
return ddl, nil
}
if !isUnsupportedDescribeSyntax(err) {
return "", err
}
return legacy()
}
func isUnsupportedDescribeSyntax(err error) bool {
var requestError gocql.RequestError
return errors.As(err, &requestError) && requestError.Code() == gocql.ErrCodeSyntax
}
func describeMaterializedViewDDL(session *gocql.Session, schema, view string) (string, error) {
statement := "DESCRIBE MATERIALIZED VIEW " + qualifiedCQLIdentifier(schema, view)
var describedSchema, objectType, describedName, ddl string
if err := session.Query(statement).Scan(&describedSchema, &objectType, &describedName, &ddl); err != nil {
return "", fmt.Errorf("describe Cassandra materialized view %s: %w", qualifiedCQLIdentifier(schema, view), err)
}
if normalizeCassandraObjectType(objectType) != cassandraMaterializedViewObjectType {
return "", fmt.Errorf("Cassandra DESCRIBE returned object type %q for materialized view %s", objectType, qualifiedCQLIdentifier(schema, view))
}
if strings.TrimSpace(ddl) == "" {
return "", fmt.Errorf("Cassandra returned an empty materialized view definition for %s", qualifiedCQLIdentifier(schema, view))
}
return ddl, nil
}
func legacyMaterializedViewCatalog(session *gocql.Session, schema, view string) (materializedViewCatalog, error) {
row := map[string]any{}
err := session.Query(
"SELECT * FROM system_schema.views WHERE keyspace_name = ? AND view_name = ?",
schema,
view,
).MapScan(row)
if errors.Is(err, gocql.ErrNotFound) {
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view not found: %s.%s", schema, view)
}
if err != nil {
return materializedViewCatalog{}, fmt.Errorf("read Cassandra materialized view catalog for %s: %w", qualifiedCQLIdentifier(schema, view), err)
}
baseTable, ok := row["base_table_name"].(string)
if !ok || strings.TrimSpace(baseTable) == "" {
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no base table for %s", qualifiedCQLIdentifier(schema, view))
}
includeAllColumns, ok := row["include_all_columns"].(bool)
if !ok {
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no projection mode for %s", qualifiedCQLIdentifier(schema, view))
}
whereClause, ok := row["where_clause"].(string)
if !ok || strings.TrimSpace(whereClause) == "" {
return materializedViewCatalog{}, fmt.Errorf("Cassandra materialized view catalog has no WHERE clause for %s", qualifiedCQLIdentifier(schema, view))
}
options, err := legacyMaterializedViewOptions(row)
if err != nil {
return materializedViewCatalog{}, fmt.Errorf("read Cassandra materialized view options for %s: %w", qualifiedCQLIdentifier(schema, view), err)
}
columns, err := legacyMaterializedViewColumns(session, schema, view)
if err != nil {
return materializedViewCatalog{}, err
}
return materializedViewCatalog{
BaseTable: baseTable,
IncludeAllColumns: includeAllColumns,
WhereClause: whereClause,
Columns: columns,
Options: options,
}, nil
}
func legacyMaterializedViewOptions(row map[string]any) (map[string]any, error) {
metadataColumns := map[string]bool{
"base_table_id": true,
"base_table_name": true,
"id": true,
"include_all_columns": true,
"keyspace_name": true,
"view_name": true,
"where_clause": true,
}
knownOptions := make(map[string]bool, len(legacyMaterializedViewOptionNames)+1)
for _, name := range legacyMaterializedViewOptionNames {
knownOptions[name] = true
}
knownOptions["default_time_to_live"] = true
knownOptions["extensions"] = true
for name, value := range row {
if value != nil && !metadataColumns[name] && !knownOptions[name] {
return nil, fmt.Errorf("unsupported system_schema.views column %q", name)
}
}
if value, exists := row["default_time_to_live"]; exists && value != nil && fmt.Sprint(value) != "0" {
return nil, fmt.Errorf("unsupported non-zero default_time_to_live %v", value)
}
if value, exists := row["extensions"]; exists && value != nil && !isEmptyCQLCollection(value) {
return nil, errors.New("non-empty materialized view extensions cannot be reproduced safely")
}
options := make(map[string]any, len(legacyMaterializedViewOptionNames))
_, hasModernReadRepair := row["read_repair"]
for _, name := range legacyMaterializedViewOptionNames {
if value, exists := row[name]; exists && value != nil {
if hasModernReadRepair && (name == "dclocal_read_repair_chance" || name == "read_repair_chance") {
continue
}
if name == "memtable" && strings.TrimSpace(fmt.Sprint(value)) == "" {
continue
}
options[name] = value
}
}
return options, nil
}
func legacyMaterializedViewColumns(session *gocql.Session, schema, view string) ([]materializedViewColumn, error) {
iter := session.Query(
"SELECT column_name, kind, position, clustering_order FROM system_schema.columns WHERE keyspace_name = ? AND table_name = ?",
schema,
view,
).PageSize(512).Iter()
columns := make([]materializedViewColumn, 0)
var column materializedViewColumn
for iter.Scan(&column.Name, &column.Kind, &column.Position, &column.ClusteringOrder) {
if len(columns) >= maxLegacyMaterializedViewColumns {
_ = iter.Close()
return nil, fmt.Errorf("Cassandra materialized view %s has more than %d columns", qualifiedCQLIdentifier(schema, view), maxLegacyMaterializedViewColumns)
}
columns = append(columns, column)
column = materializedViewColumn{}
}
if err := iter.Close(); err != nil {
return nil, fmt.Errorf("read Cassandra materialized view columns for %s: %w", qualifiedCQLIdentifier(schema, view), err)
}
if len(columns) == 0 {
return nil, fmt.Errorf("Cassandra materialized view catalog has no columns for %s", qualifiedCQLIdentifier(schema, view))
}
return columns, nil
}
func materializedViewDDLFromCatalog(schema, view string, catalog materializedViewCatalog) (string, error) {
if strings.TrimSpace(catalog.BaseTable) == "" {
return "", fmt.Errorf("Cassandra materialized view catalog has no base table for %s", qualifiedCQLIdentifier(schema, view))
}
if strings.TrimSpace(catalog.WhereClause) == "" {
return "", fmt.Errorf("Cassandra materialized view catalog has no WHERE clause for %s", qualifiedCQLIdentifier(schema, view))
}
partitionKeys, clusteringColumns, selectedColumns, err := classifyMaterializedViewColumns(catalog.Columns)
if err != nil {
return "", fmt.Errorf("invalid Cassandra materialized view columns for %s: %w", qualifiedCQLIdentifier(schema, view), err)
}
if len(partitionKeys) == 0 {
return "", fmt.Errorf("Cassandra materialized view has no partition key: %s", qualifiedCQLIdentifier(schema, view))
}
projection := "*"
if !catalog.IncludeAllColumns {
quoted := make([]string, len(selectedColumns))
for index, column := range selectedColumns {
quoted[index] = quoteCQLIdentifier(column.Name)
}
projection = strings.Join(quoted, ", ")
}
primaryParts := make([]string, 0, len(clusteringColumns)+1)
if len(partitionKeys) == 1 {
primaryParts = append(primaryParts, quoteCQLIdentifier(partitionKeys[0].Name))
} else {
quoted := make([]string, len(partitionKeys))
for index, column := range partitionKeys {
quoted[index] = quoteCQLIdentifier(column.Name)
}
primaryParts = append(primaryParts, "("+strings.Join(quoted, ", ")+")")
}
for _, column := range clusteringColumns {
primaryParts = append(primaryParts, quoteCQLIdentifier(column.Name))
}
properties := make([]string, 0, len(catalog.Options)+1)
if len(clusteringColumns) > 0 {
orders := make([]string, len(clusteringColumns))
for index, column := range clusteringColumns {
order := strings.ToUpper(strings.TrimSpace(column.ClusteringOrder))
if order != "ASC" && order != "DESC" {
return "", fmt.Errorf("invalid clustering order %q for column %q", column.ClusteringOrder, column.Name)
}
orders[index] = quoteCQLIdentifier(column.Name) + " " + order
}
properties = append(properties, "CLUSTERING ORDER BY ("+strings.Join(orders, ", ")+")")
}
for _, name := range legacyMaterializedViewOptionNames {
value, exists := catalog.Options[name]
if !exists || value == nil {
continue
}
properties = append(properties, name+" = "+cqlLiteral(value))
}
if len(properties) == 0 {
return "", fmt.Errorf("Cassandra materialized view has no reproducible table options: %s", qualifiedCQLIdentifier(schema, view))
}
return "CREATE MATERIALIZED VIEW " + qualifiedCQLIdentifier(schema, view) + " AS\n" +
" SELECT " + projection + "\n" +
" FROM " + qualifiedCQLIdentifier(schema, catalog.BaseTable) + "\n" +
" WHERE " + strings.TrimSpace(catalog.WhereClause) + "\n" +
" PRIMARY KEY (" + strings.Join(primaryParts, ", ") + ")\n" +
" WITH " + strings.Join(properties, "\n AND ") + ";", nil
}
func qualifiedCQLIdentifier(schema, name string) string {
return quoteCQLIdentifier(schema) + "." + quoteCQLIdentifier(name)
}
func classifyMaterializedViewColumns(columns []materializedViewColumn) ([]materializedViewColumn, []materializedViewColumn, []materializedViewColumn, error) {
partitionKeys := make([]materializedViewColumn, 0)
clusteringColumns := make([]materializedViewColumn, 0)
regularColumns := make([]materializedViewColumn, 0)
seenNames := make(map[string]bool, len(columns))
for _, column := range columns {
if strings.TrimSpace(column.Name) == "" {
return nil, nil, nil, errors.New("column name is empty")
}
if seenNames[column.Name] {
return nil, nil, nil, fmt.Errorf("duplicate column %q", column.Name)
}
seenNames[column.Name] = true
switch strings.ToLower(strings.TrimSpace(column.Kind)) {
case "partition_key":
partitionKeys = append(partitionKeys, column)
case "clustering", "clustering_key":
clusteringColumns = append(clusteringColumns, column)
case "regular", "static":
regularColumns = append(regularColumns, column)
default:
return nil, nil, nil, fmt.Errorf("unsupported kind %q for column %q", column.Kind, column.Name)
}
}
if err := sortMaterializedViewKeyColumns(partitionKeys, "partition"); err != nil {
return nil, nil, nil, err
}
if err := sortMaterializedViewKeyColumns(clusteringColumns, "clustering"); err != nil {
return nil, nil, nil, err
}
sort.Slice(regularColumns, func(left, right int) bool { return regularColumns[left].Name < regularColumns[right].Name })
selectedColumns := make([]materializedViewColumn, 0, len(columns))
selectedColumns = append(selectedColumns, partitionKeys...)
selectedColumns = append(selectedColumns, clusteringColumns...)
selectedColumns = append(selectedColumns, regularColumns...)
return partitionKeys, clusteringColumns, selectedColumns, nil
}
func sortMaterializedViewKeyColumns(columns []materializedViewColumn, kind string) error {
sort.Slice(columns, func(left, right int) bool {
if columns[left].Position == columns[right].Position {
return columns[left].Name < columns[right].Name
}
return columns[left].Position < columns[right].Position
})
for index, column := range columns {
if column.Position < 0 {
return fmt.Errorf("invalid %s-key position %d for column %q", kind, column.Position, column.Name)
}
if index > 0 && columns[index-1].Position == column.Position {
return fmt.Errorf("duplicate %s-key position %d", kind, column.Position)
}
}
return nil
}
func isEmptyCQLCollection(value any) bool {
switch typed := value.(type) {
case map[string]string:
return len(typed) == 0
case map[string][]byte:
return len(typed) == 0
case map[string]any:
return len(typed) == 0
default:
return false
}
}
+14 -2
View File
@@ -470,11 +470,23 @@ func indexesFromMetadata(metadata *gocql.TableMetadata) []indexInfo {
}
func (s *server) getTableDDL(schema, table string) (string, error) {
metadata, err := s.tableMetadata(schema, table)
keyspace, err := s.keyspaceMetadata(schema)
if err != nil {
return "", err
}
return tableDDLFromMetadata(schema, table, metadata)
return schemaObjectDDLFromMetadata(schema, table, keyspace, func() (string, error) {
return s.getMaterializedViewDDL(schema, table)
})
}
func schemaObjectDDLFromMetadata(schema, table string, keyspace *gocql.KeyspaceMetadata, materializedViewDDL func() (string, error)) (string, error) {
if keyspace.MaterializedViews[table] != nil {
return materializedViewDDL()
}
if metadata := keyspace.Tables[table]; metadata != nil {
return tableDDLFromMetadata(schema, table, metadata)
}
return "", fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
}
func tableDDLFromMetadata(schema, table string, metadata *gocql.TableMetadata) (string, error) {
@@ -3,11 +3,21 @@ package main
import (
"errors"
"reflect"
"strings"
"testing"
gocql "github.com/apache/cassandra-gocql-driver/v2"
)
type testCassandraRequestError struct {
code int
message string
}
func (e *testCassandraRequestError) Error() string { return e.message }
func (e *testCassandraRequestError) Code() int { return e.code }
func (e *testCassandraRequestError) Message() string { return e.message }
type triggerMetadataIterator struct {
rows []struct {
name string
@@ -80,6 +90,250 @@ func TestColumnsIndexesAndDDLFromMetadata(t *testing.T) {
}
}
func TestSchemaObjectDDLChoosesMaterializedViewBeforeMirroredTableMetadata(t *testing.T) {
id := &gocql.ColumnMetadata{Name: "id", Kind: gocql.ColumnPartitionKey, Type: gocql.NewNativeType(4, gocql.TypeInt, "")}
tableMetadata := &gocql.TableMetadata{
OrderedColumns: []string{"id"},
PartitionKey: []*gocql.ColumnMetadata{id},
Columns: map[string]*gocql.ColumnMetadata{"id": id},
}
keyspace := &gocql.KeyspaceMetadata{
Tables: map[string]*gocql.TableMetadata{
"users": tableMetadata,
"users_by_email": tableMetadata,
},
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
"users_by_email": {Name: "users_by_email", BaseTable: tableMetadata},
},
}
viewCalls := 0
ddl, err := schemaObjectDDLFromMetadata("app", "users_by_email", keyspace, func() (string, error) {
viewCalls++
return "CREATE MATERIALIZED VIEW app.users_by_email", nil
})
if err != nil || ddl != "CREATE MATERIALIZED VIEW app.users_by_email" || viewCalls != 1 {
t.Fatalf("materialized view dispatch = %q, calls = %d, err = %v", ddl, viewCalls, err)
}
tableDDL, err := schemaObjectDDLFromMetadata("app", "users", keyspace, func() (string, error) {
t.Fatal("ordinary table DDL must not use the materialized-view loader")
return "", nil
})
if err != nil || !strings.HasPrefix(tableDDL, `CREATE TABLE "app"."users"`) {
t.Fatalf("ordinary table DDL = %q, err = %v", tableDDL, err)
}
if _, err := schemaObjectDDLFromMetadata("app", "missing", keyspace, func() (string, error) {
return "", errors.New("unexpected")
}); err == nil || !strings.Contains(err.Error(), "table not found") {
t.Fatalf("missing object error = %v", err)
}
}
func TestMaterializedViewObjectSourcePreservesProtocolShape(t *testing.T) {
result, err := materializedViewObjectSource("app", "users_by_email", "MATERIALIZED_VIEW", func() (string, error) {
return "CREATE MATERIALIZED VIEW app.users_by_email", nil
})
if err != nil {
t.Fatal(err)
}
if result.Name != "users_by_email" || result.ObjectType != "MATERIALIZED_VIEW" || result.Schema == nil || *result.Schema != "app" || result.Source != "CREATE MATERIALIZED VIEW app.users_by_email" {
t.Fatalf("unexpected object source: %#v", result)
}
loaderCalled := false
_, err = materializedViewObjectSource("app", "users", "TABLE", func() (string, error) {
loaderCalled = true
return "unexpected", nil
})
if err == nil || loaderCalled || err.Error() != "object source is not supported by Cassandra" {
t.Fatalf("unsupported source err = %v, loader called = %v", err, loaderCalled)
}
wantErr := errors.New("definition unavailable")
_, err = materializedViewObjectSource("app", "users_by_email", "materialized view", func() (string, error) {
return "", wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("object-source loader error = %v", err)
}
}
func TestMaterializedViewDDLFromLegacyCatalogPreservesDefinition(t *testing.T) {
catalog := materializedViewCatalog{
BaseTable: `Base"Events`,
IncludeAllColumns: false,
WhereClause: `"Tenant Id" IS NOT NULL AND "Category" IS NOT NULL`,
Columns: []materializedViewColumn{
{Name: "Bucket", Kind: "clustering", Position: 1, ClusteringOrder: "asc"},
{Name: "Payload", Kind: "regular", Position: -1},
{Name: "Category", Kind: "partition_key", Position: 1},
{Name: "Event Time", Kind: "clustering", Position: 0, ClusteringOrder: "desc"},
{Name: "Tenant Id", Kind: "partition_key", Position: 0},
},
Options: map[string]any{
"caching": map[string]string{"rows_per_partition": "NONE", "keys": "ALL"},
"comment": "owner's view",
"default_time_to_live": 0,
"extensions": map[string][]byte{},
"gc_grace_seconds": 86401,
"speculative_retry": "99PERCENTILE",
"read_repair_chance": 0.0,
"dclocal_read_repair_chance": 0.1,
},
}
ddl, err := materializedViewDDLFromCatalog(`App"Ks`, `Events"ByCategory`, catalog)
if err != nil {
t.Fatal(err)
}
for _, fragment := range []string{
`CREATE MATERIALIZED VIEW "App""Ks"."Events""ByCategory" AS`,
`SELECT "Tenant Id", "Category", "Event Time", "Bucket", "Payload"`,
`FROM "App""Ks"."Base""Events"`,
`WHERE "Tenant Id" IS NOT NULL AND "Category" IS NOT NULL`,
`PRIMARY KEY (("Tenant Id", "Category"), "Event Time", "Bucket")`,
`CLUSTERING ORDER BY ("Event Time" DESC, "Bucket" ASC)`,
`caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'}`,
`comment = 'owner''s view'`,
`dclocal_read_repair_chance = 0.1`,
`gc_grace_seconds = 86401`,
`read_repair_chance = 0`,
`speculative_retry = '99PERCENTILE'`,
} {
if !strings.Contains(ddl, fragment) {
t.Fatalf("materialized view DDL omitted %q:\n%s", fragment, ddl)
}
}
if strings.Contains(ddl, "default_time_to_live") || strings.Contains(ddl, "extensions") {
t.Fatalf("materialized view DDL emitted non-applicable empty options:\n%s", ddl)
}
}
func TestLegacyMaterializedViewOptionsUseVersionAppropriateReadRepair(t *testing.T) {
modern, err := legacyMaterializedViewOptions(map[string]any{
"read_repair": "BLOCKING",
"read_repair_chance": 0.0,
"dclocal_read_repair_chance": 0.0,
"memtable": "",
"default_time_to_live": 0,
"extensions": map[string][]byte{},
})
if err != nil {
t.Fatal(err)
}
if modern["read_repair"] != "BLOCKING" || modern["read_repair_chance"] != nil || modern["dclocal_read_repair_chance"] != nil || modern["memtable"] != nil {
t.Fatalf("unexpected modern options: %#v", modern)
}
legacy, err := legacyMaterializedViewOptions(map[string]any{
"read_repair_chance": 0.0,
"dclocal_read_repair_chance": 0.1,
"default_time_to_live": 0,
"extensions": map[string][]byte{},
})
if err != nil {
t.Fatal(err)
}
if legacy["read_repair_chance"] != 0.0 || legacy["dclocal_read_repair_chance"] != 0.1 {
t.Fatalf("unexpected Cassandra 3 options: %#v", legacy)
}
}
func TestMaterializedViewDDLFromLegacyCatalogPreservesSelectStar(t *testing.T) {
ddl, err := materializedViewDDLFromCatalog("app", "users_by_email", materializedViewCatalog{
BaseTable: "users",
IncludeAllColumns: true,
WhereClause: "email IS NOT NULL AND id IS NOT NULL",
Columns: []materializedViewColumn{
{Name: "email", Kind: "partition_key", Position: 0},
{Name: "id", Kind: "clustering", Position: 0, ClusteringOrder: "asc"},
},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ddl, "SELECT *") {
t.Fatalf("materialized view DDL did not preserve SELECT *:\n%s", ddl)
}
}
func TestMaterializedViewDDLOnlyFallsBackForUnsupportedDescribeSyntax(t *testing.T) {
legacyCalls := 0
ddl, err := materializedViewDDLWithFallback(
func() (string, error) { return "CREATE MATERIALIZED VIEW native", nil },
func() (string, error) {
legacyCalls++
return "unexpected", nil
},
)
if err != nil || ddl != "CREATE MATERIALIZED VIEW native" || legacyCalls != 0 {
t.Fatalf("native DDL = %q, legacy calls = %d, err = %v", ddl, legacyCalls, err)
}
legacyCalls = 0
ddl, err = materializedViewDDLWithFallback(
func() (string, error) {
return "", &testCassandraRequestError{code: gocql.ErrCodeSyntax, message: "no viable alternative at input 'DESCRIBE'"}
},
func() (string, error) {
legacyCalls++
return "CREATE MATERIALIZED VIEW legacy", nil
},
)
if err != nil || ddl != "CREATE MATERIALIZED VIEW legacy" || legacyCalls != 1 {
t.Fatalf("syntax fallback = %q, calls = %d, err = %v", ddl, legacyCalls, err)
}
permissionErr := &testCassandraRequestError{code: gocql.ErrCodeUnauthorized, message: "not authorized"}
legacyCalls = 0
_, err = materializedViewDDLWithFallback(
func() (string, error) { return "", permissionErr },
func() (string, error) {
legacyCalls++
return "unexpected", nil
},
)
if !errors.Is(err, permissionErr) || legacyCalls != 0 {
t.Fatalf("permission error = %v, legacy calls = %d", err, legacyCalls)
}
networkErr := errors.New("connection reset")
_, err = materializedViewDDLWithFallback(
func() (string, error) { return "", networkErr },
func() (string, error) {
legacyCalls++
return "unexpected", nil
},
)
if !errors.Is(err, networkErr) || legacyCalls != 0 {
t.Fatalf("network error = %v, legacy calls = %d", err, legacyCalls)
}
}
func TestLegacyMaterializedViewOptionsRejectLossyDefinitions(t *testing.T) {
for name, row := range map[string]map[string]any{
"unknown option": {
"base_table_name": "users",
"future_option": "cannot safely reproduce",
},
"non-zero default TTL": {
"base_table_name": "users",
"default_time_to_live": 60,
},
"non-empty extensions": {
"base_table_name": "users",
"extensions": map[string][]byte{"custom": {0x01}},
},
} {
t.Run(name, func(t *testing.T) {
if _, err := legacyMaterializedViewOptions(row); err == nil {
t.Fatal("expected an explicit error instead of a lossy legacy DDL")
}
})
}
}
func TestMetadataWindowAndFilter(t *testing.T) {
values := []string{"a", "b", "c", "d"}
if got := applyMetadataWindow(values, 1, 2); !reflect.DeepEqual(got, []string{"b", "c"}) {
@@ -2244,52 +2244,124 @@ public final class DamengAgent extends AbstractJdbcAgent {
table
);
}
return unchecked(() -> {
List<IndexInfo> result = new ArrayList<>();
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
// way a primary key does, and neither can be altered with index-level DDL (#7959).
// The constraint type therefore drives two flags — IS_PK (only 'P') and
// CONSTRAINT_BACKED ('P' or 'U'), the same split `independentIndexes` already uses to
// keep constraint-backed indexes out of the generated table DDL. A unique index
// created with CREATE UNIQUE INDEX has no ALL_CONSTRAINTS row, so it stays false and
// keeps the index-level DDL path.
String sql = """
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
i.INDEX_TYPE,
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
AND c.CONSTRAINT_TYPE IN ('P', 'U')
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
ORDER BY i.INDEX_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
IndexInfo index = new IndexInfo(
rs.getString(1),
splitNonEmpty(coalesce(rs.getString(2)), ","),
"UNIQUE".equals(rs.getString(3)),
"1".equals(rs.getString(4)),
null,
rs.getString(5),
null,
null
);
index.setConstraint_backed("1".equals(rs.getString(6)));
result.add(index);
}
return unchecked(() -> tableIndexes(schema, table));
}
private List<IndexInfo> tableIndexes(String schema, String table) throws Exception {
try {
return indexesFromSystemCatalog(schema, table);
} catch (Exception systemCatalogError) {
if (isDamengConnectionError(systemCatalogError)) {
throw systemCatalogError;
}
try {
return indexesFromDictionaryViews(schema, table);
} catch (Exception dictionaryViewError) {
dictionaryViewError.addSuppressed(systemCatalogError);
throw dictionaryViewError;
}
}
}
private List<IndexInfo> indexesFromSystemCatalog(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// ALL_INDEXES applies visibility checks across the complete catalog. Resolve the one table
// first, then inspect only its index children; the official catalog exposes key order via
// SF_GET_INDEX_KEY_SEQ and constraint ownership through SYSCONS.
String sql = """
SELECT /*+ PARALLEL(1) */ index_object.NAME,
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
) AS COLUMNS,
index_metadata.ISUNIQUE,
MAX(CASE WHEN constraint_metadata.TYPE$ = 'P' THEN 1 ELSE 0 END) AS IS_PK,
index_metadata.TYPE$,
MAX(CASE WHEN constraint_metadata.TYPE$ IN ('P', 'U') THEN 1 ELSE 0 END) AS CONSTRAINT_BACKED,
index_metadata.FLAG
FROM SYS.SYSOBJECTS schema_object
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
AND index_object.SUBTYPE$ = 'INDEX'
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
AND SF_COL_IS_IDX_KEY(
index_metadata.KEYNUM,
index_metadata.KEYINFO,
column_object.COLID
) = 1
LEFT JOIN SYS.SYSCONS constraint_metadata ON constraint_metadata.TABLEID = table_object.ID
AND constraint_metadata.INDEXID = index_metadata.ID
AND constraint_metadata.TYPE$ IN ('P', 'U')
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
ORDER BY index_object.NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
boolean isPrimary = "1".equals(rs.getString(4));
boolean constraintBacked = "1".equals(rs.getString(6));
IndexInfo index = new IndexInfo(
rs.getString(1),
splitNonEmpty(coalesce(rs.getString(2)), ","),
"Y".equalsIgnoreCase(rs.getString(3)),
isPrimary,
null,
damengSystemIndexType(rs.getString(5), rs.getInt(7), constraintBacked),
null,
null
);
index.setConstraint_backed(constraintBacked);
result.add(index);
}
}
return result;
});
}
return result;
}
private List<IndexInfo> indexesFromDictionaryViews(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// The LEFT JOIN also matches 'U': a UNIQUE constraint owns its backing index the same
// way a primary key does, and neither can be altered with index-level DDL (#7959).
String sql = """
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK,
i.INDEX_TYPE,
CASE WHEN c.CONSTRAINT_TYPE IN ('P', 'U') THEN 1 ELSE 0 END AS CONSTRAINT_BACKED
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
LEFT JOIN ALL_CONSTRAINTS c ON i.INDEX_NAME = c.INDEX_NAME AND i.TABLE_OWNER = c.OWNER
AND c.CONSTRAINT_TYPE IN ('P', 'U')
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
GROUP BY i.INDEX_NAME, i.UNIQUENESS, c.CONSTRAINT_TYPE, i.INDEX_TYPE
ORDER BY i.INDEX_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
IndexInfo index = new IndexInfo(
rs.getString(1),
splitNonEmpty(coalesce(rs.getString(2)), ","),
"UNIQUE".equals(rs.getString(3)),
"1".equals(rs.getString(4)),
null,
rs.getString(5),
null,
null
);
index.setConstraint_backed("1".equals(rs.getString(6)));
result.add(index);
}
}
}
return result;
}
@Override
@@ -2298,33 +2370,108 @@ public final class DamengAgent extends AbstractJdbcAgent {
if (legacyJdbcMetadata) {
return StandardJdbcMetadata.INSTANCE.listForeignKeys(requireConnected(), schema, table);
}
return unchecked(() -> {
List<ForeignKeyInfo> result = new ArrayList<>();
String sql = """
SELECT c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME
FROM ALL_CONSTRAINTS c
JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER
JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER
JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER
WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ?
ORDER BY c.CONSTRAINT_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ForeignKeyInfo(
rs.getString(1),
rs.getString(2),
rs.getString(3),
rs.getString(4)
));
}
return unchecked(() -> foreignKeys(schema, table));
}
private List<ForeignKeyInfo> foreignKeys(String schema, String table) throws Exception {
try {
return foreignKeysFromSystemCatalog(schema, table);
} catch (Exception systemCatalogError) {
if (isDamengConnectionError(systemCatalogError)) {
throw systemCatalogError;
}
try {
return foreignKeysFromDictionaryViews(schema, table);
} catch (Exception dictionaryViewError) {
dictionaryViewError.addSuppressed(systemCatalogError);
throw dictionaryViewError;
}
}
}
private List<ForeignKeyInfo> foreignKeysFromSystemCatalog(String schema, String table) throws Exception {
List<ForeignKeyInfo> result = new ArrayList<>();
String sql = """
SELECT /*+ MAX_OPT_N_TABLES(5) PARALLEL(1) */ constraint_object.NAME,
local_column.NAME,
referenced_table.NAME,
referenced_column.NAME
FROM SYS.SYSOBJECTS schema_object
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
JOIN SYS.SYSCONS foreign_key ON foreign_key.TABLEID = table_object.ID
AND foreign_key.TYPE$ = 'F'
JOIN SYS.SYSOBJECTS constraint_object ON constraint_object.ID = foreign_key.ID
AND constraint_object.PID = table_object.ID
AND constraint_object.SUBTYPE$ = 'CONS'
JOIN SYS.SYSINDEXES local_index ON local_index.ID = foreign_key.INDEXID
JOIN SYS.SYSCOLUMNS local_column ON local_column.ID = table_object.ID
AND SF_COL_IS_IDX_KEY(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID) = 1
JOIN SYS.SYSOBJECTS referenced_index_object ON referenced_index_object.ID = foreign_key.FINDEXID
AND referenced_index_object.SUBTYPE$ = 'INDEX'
JOIN SYS.SYSINDEXES referenced_index ON referenced_index.ID = referenced_index_object.ID
JOIN SYS.SYSOBJECTS referenced_table ON referenced_table.ID = referenced_index_object.PID
AND referenced_table.TYPE$ = 'SCHOBJ' AND referenced_table.SUBTYPE$ = 'UTAB'
JOIN SYS.SYSCOLUMNS referenced_column ON referenced_column.ID = referenced_table.ID
AND SF_COL_IS_IDX_KEY(
referenced_index.KEYNUM,
referenced_index.KEYINFO,
referenced_column.COLID
) = 1
AND SF_GET_INDEX_KEY_SEQ(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID)
= SF_GET_INDEX_KEY_SEQ(
referenced_index.KEYNUM,
referenced_index.KEYINFO,
referenced_column.COLID
)
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
ORDER BY constraint_object.NAME,
SF_GET_INDEX_KEY_SEQ(local_index.KEYNUM, local_index.KEYINFO, local_column.COLID)
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ForeignKeyInfo(
rs.getString(1),
rs.getString(2),
rs.getString(3),
rs.getString(4)
));
}
}
return result;
});
}
return result;
}
private List<ForeignKeyInfo> foreignKeysFromDictionaryViews(String schema, String table) throws Exception {
List<ForeignKeyInfo> result = new ArrayList<>();
String sql = """
SELECT /*+ PARALLEL(1) */ c.CONSTRAINT_NAME, cc.COLUMN_NAME, rc.TABLE_NAME, rcc.COLUMN_NAME
FROM ALL_CONSTRAINTS c
JOIN ALL_CONS_COLUMNS cc ON c.CONSTRAINT_NAME = cc.CONSTRAINT_NAME AND c.OWNER = cc.OWNER
JOIN ALL_CONSTRAINTS rc ON c.R_CONSTRAINT_NAME = rc.CONSTRAINT_NAME AND c.R_OWNER = rc.OWNER
JOIN ALL_CONS_COLUMNS rcc ON rc.CONSTRAINT_NAME = rcc.CONSTRAINT_NAME AND rc.OWNER = rcc.OWNER
AND cc.POSITION = rcc.POSITION
WHERE c.CONSTRAINT_TYPE = 'R' AND c.OWNER = ? AND c.TABLE_NAME = ?
ORDER BY c.CONSTRAINT_NAME, cc.POSITION
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(new ForeignKeyInfo(
rs.getString(1),
rs.getString(2),
rs.getString(3),
rs.getString(4)
));
}
}
}
return result;
}
@Override
@@ -2895,134 +3042,22 @@ public final class DamengAgent extends AbstractJdbcAgent {
}
private List<IndexInfo> independentIndexes(String schema, String table) throws Exception {
try {
return independentIndexesFromSystemCatalog(schema, table);
} catch (Exception systemCatalogError) {
if (isDamengConnectionError(systemCatalogError)) {
throw systemCatalogError;
}
try {
return independentIndexesFromDictionaryViews(schema, table);
} catch (Exception dictionaryViewError) {
dictionaryViewError.addSuppressed(systemCatalogError);
throw dictionaryViewError;
}
}
return tableIndexes(schema, table).stream()
.filter(index -> !index.getConstraint_backed())
.toList();
}
private List<IndexInfo> independentIndexesFromSystemCatalog(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// DM's ALL_INDEXES view performs privilege filtering across the complete catalog and can
// take tens of seconds in installations with very large index dictionaries (#10075).
// The DM JDBC driver and DBeaver instead resolve the requested table id first and read its
// SYS index rows directly. Keep the ALL_* implementation below as a compatibility and
// permission fallback for deployments that do not expose these catalog tables.
String sql = """
SELECT /*+ PARALLEL(1) */ index_object.NAME,
LISTAGG(column_object.NAME, ',') WITHIN GROUP (
ORDER BY SF_GET_INDEX_KEY_SEQ(index_metadata.KEYNUM, index_metadata.KEYINFO, column_object.COLID)
) AS COLUMNS,
index_metadata.ISUNIQUE,
index_metadata.TYPE$,
index_metadata.FLAG
FROM SYS.SYSOBJECTS schema_object
JOIN SYS.SYSOBJECTS table_object ON table_object.SCHID = schema_object.ID
AND table_object.TYPE$ = 'SCHOBJ' AND table_object.SUBTYPE$ = 'UTAB'
JOIN SYS.SYSOBJECTS index_object ON index_object.PID = table_object.ID
AND index_object.SUBTYPE$ = 'INDEX'
JOIN SYS.SYSINDEXES index_metadata ON index_metadata.ID = index_object.ID
JOIN SYS.SYSCOLUMNS column_object ON column_object.ID = table_object.ID
AND SF_COL_IS_IDX_KEY(
index_metadata.KEYNUM,
index_metadata.KEYINFO,
column_object.COLID
) = 1
WHERE schema_object.TYPE$ = 'SCH' AND schema_object.NAME = ? AND table_object.NAME = ?
AND NOT EXISTS (
SELECT 1
FROM SYS.SYSCONS constraint_metadata
WHERE constraint_metadata.TABLEID = table_object.ID
AND constraint_metadata.INDEXID = index_metadata.ID
AND constraint_metadata.TYPE$ IN ('P', 'U')
)
GROUP BY index_object.NAME, index_metadata.ISUNIQUE, index_metadata.TYPE$, index_metadata.FLAG
ORDER BY index_object.NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String indexName = rs.getString(1);
if (notBlank(indexName)) {
result.add(new IndexInfo(
indexName,
splitNonEmpty(coalesce(rs.getString(2)), ","),
"Y".equalsIgnoreCase(rs.getString(3)),
false,
null,
damengSystemIndexType(rs.getString(4), rs.getInt(5)),
null,
null
));
}
}
}
}
return result;
}
private List<IndexInfo> independentIndexesFromDictionaryViews(String schema, String table) throws Exception {
List<IndexInfo> result = new ArrayList<>();
// Primary-key and unique-constraint backing indexes are already represented in table DDL.
String sql = """
SELECT /*+ PARALLEL(1) */ i.INDEX_NAME,
LISTAGG(ic.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ic.COLUMN_POSITION) AS COLUMNS,
i.UNIQUENESS,
i.INDEX_TYPE
FROM ALL_INDEXES i
JOIN ALL_IND_COLUMNS ic ON i.INDEX_NAME = ic.INDEX_NAME AND i.OWNER = ic.INDEX_OWNER AND i.TABLE_OWNER = ic.TABLE_OWNER
WHERE i.TABLE_OWNER = ? AND i.TABLE_NAME = ?
AND NOT EXISTS (
SELECT 1
FROM ALL_CONSTRAINTS c
WHERE c.OWNER = i.TABLE_OWNER
AND c.TABLE_NAME = i.TABLE_NAME
AND c.INDEX_NAME = i.INDEX_NAME
AND c.CONSTRAINT_TYPE IN ('P', 'U')
)
GROUP BY i.INDEX_NAME, i.UNIQUENESS, i.INDEX_TYPE
ORDER BY i.INDEX_NAME
""".stripIndent().trim();
try (PreparedStatement stmt = requireConnected().prepareStatement(sql)) {
stmt.setString(1, schema);
stmt.setString(2, table);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
String indexName = rs.getString(1);
if (notBlank(indexName)) {
result.add(new IndexInfo(
indexName,
splitNonEmpty(coalesce(rs.getString(2)), ","),
"UNIQUE".equals(rs.getString(3)),
false,
null,
rs.getString(4),
null,
null
));
}
}
}
}
return result;
}
private static String damengSystemIndexType(String catalogType, int flags) {
if ((flags & 3) != 0) {
private static String damengSystemIndexType(String catalogType, int flags, boolean constraintBacked) {
if ((flags & 1) != 0 || ((flags & 2) != 0 && !constraintBacked)) {
return "INTERNAL";
}
return "ST".equalsIgnoreCase(coalesce(catalogType).trim()) ? "SPATIAL" : catalogType;
String normalized = coalesce(catalogType).trim().toUpperCase(Locale.ROOT);
return switch (normalized) {
case "BT" -> "NORMAL";
case "BM" -> "BITMAP";
case "ST" -> "SPATIAL";
default -> catalogType;
};
}
static String indexDdl(String schema, String table, IndexInfo index) {
@@ -2,6 +2,7 @@ package com.dbx.agent.dameng;
import com.dbx.agent.ColumnInfo;
import com.dbx.agent.DatabaseInfo;
import com.dbx.agent.ForeignKeyInfo;
import com.dbx.agent.IndexInfo;
import com.dbx.agent.MetadataListConstraints;
import com.dbx.agent.ObjectInfo;
@@ -188,17 +189,25 @@ class DamengAgentMetadataTest {
}
@Test
void disablesParallelExecutionForIndexMetadataQuery() {
void usesBoundedSystemCatalogForIndexMetadata() {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, JdbcMetadataSqlFake.connection());
agent.listIndexes("APP", "USERS");
String indexesSql = JdbcMetadataSqlFake.statements.stream()
.filter(sql -> sql.contains("ALL_INDEXES"))
.filter(sql -> sql.contains("SYS.SYSINDEXES"))
.findFirst()
.orElseThrow();
Assertions.assertTrue(indexesSql.startsWith("SELECT /*+ PARALLEL(1) */"), indexesSql);
Assertions.assertTrue(indexesSql.contains("schema_object.NAME = ?"), indexesSql);
Assertions.assertTrue(indexesSql.contains("table_object.NAME = ?"), indexesSql);
Assertions.assertTrue(indexesSql.contains("SF_COL_IS_IDX_KEY"), indexesSql);
Assertions.assertTrue(indexesSql.contains("SF_GET_INDEX_KEY_SEQ"), indexesSql);
Assertions.assertTrue(
JdbcMetadataSqlFake.statements.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")),
String.join("\n", JdbcMetadataSqlFake.statements)
);
}
@Test
@@ -209,12 +218,15 @@ class DamengAgentMetadataTest {
agent.listIndexes("APP", "USERS");
String indexesSql = JdbcMetadataSqlFake.statements.stream()
.filter(sql -> sql.contains("ALL_INDEXES") && sql.contains("ALL_CONSTRAINTS"))
.filter(sql -> sql.contains("SYS.SYSINDEXES") && sql.contains("SYS.SYSCONS"))
.findFirst()
.orElseThrow();
Assertions.assertTrue(indexesSql.contains("AND c.CONSTRAINT_TYPE IN ('P', 'U')"), indexesSql);
Assertions.assertTrue(indexesSql.contains("constraint_metadata.TYPE$ IN ('P', 'U')"), indexesSql);
Assertions.assertTrue(indexesSql.contains("AS CONSTRAINT_BACKED"), indexesSql);
Assertions.assertTrue(indexesSql.contains("CASE WHEN c.CONSTRAINT_TYPE = 'P' THEN 1 ELSE 0 END AS IS_PK"), indexesSql);
Assertions.assertTrue(
indexesSql.contains("MAX(CASE WHEN constraint_metadata.TYPE$ = 'P' THEN 1 ELSE 0 END) AS IS_PK"),
indexesSql
);
}
@Test
@@ -222,10 +234,10 @@ class DamengAgentMetadataTest {
DamengAgent agent = new DamengAgent();
TestSupport.setPrivateConnection(agent, indexMetadataConnection(List.of(
// UNIQUE constraint (虚索引) / standalone CREATE UNIQUE INDEX (实索引) / plain index.
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "UNIQUE", "0", "NORMAL", "1"),
Arrays.asList("UX_USERS_CODE", "CODE", "UNIQUE", "0", "NORMAL", "0"),
Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"),
Arrays.asList("PK_USERS", "ID", "UNIQUE", "1", "NORMAL", "1")
Arrays.asList("UX_USERS_EMAIL", "EMAIL", "Y", "0", "BT", "1", 0),
Arrays.asList("UX_USERS_CODE", "CODE", "Y", "0", "BT", "0", 0),
Arrays.asList("IDX_USERS_NAME", "NAME", "N", "0", "BT", "0", 0),
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
)));
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
@@ -246,6 +258,142 @@ class DamengAgentMetadataTest {
List.of(true, true, false, true),
indexes.stream().map(IndexInfo::getIs_unique).toList()
);
Assertions.assertEquals(
List.of("NORMAL", "NORMAL", "NORMAL", "NORMAL"),
indexes.stream().map(IndexInfo::getIndex_type).toList()
);
}
@Test
void fallsBackToDictionaryViewsForIndexMetadataWithoutSystemCatalogAccess() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSINDEXES index_metadata",
List.of(),
new SQLException("no SYS index catalog privilege"),
"FROM ALL_INDEXES i",
List.of(Arrays.asList("IDX_USERS_NAME", "NAME", "NONUNIQUE", "0", "NORMAL", "0"))
));
List<IndexInfo> indexes = agent.listIndexes("APP", "USERS");
Assertions.assertEquals(List.of("IDX_USERS_NAME"), indexes.stream().map(IndexInfo::getName).toList());
Assertions.assertTrue(indexOfSql(sqls, "SYS.SYSINDEXES") >= 0, String.join("\n", sqls));
Assertions.assertTrue(
indexOfSql(sqls, "ALL_INDEXES") > indexOfSql(sqls, "SYS.SYSINDEXES"),
String.join("\n", sqls)
);
}
@Test
void acceptsEmptySystemIndexMetadataWithoutRunningExpensiveFallback() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSINDEXES index_metadata",
List.of(),
null,
"FROM ALL_INDEXES i",
List.of(Arrays.asList("SHOULD_NOT_LOAD", "ID", "NONUNIQUE", "0", "NORMAL", "0"))
));
Assertions.assertTrue(agent.listIndexes("APP", "USERS").isEmpty());
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_INDEXES")), String.join("\n", sqls));
}
@Test
void usesBoundedSystemCatalogForForeignKeyMetadata() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSCONS foreign_key",
List.of(
List.of("FK_ORDER_CUSTOMER", "TENANT_ID", "CUSTOMERS", "TENANT_ID"),
List.of("FK_ORDER_CUSTOMER", "CUSTOMER_ID", "CUSTOMERS", "ID")
),
null,
"FROM ALL_CONSTRAINTS c",
List.of()
));
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("APP", "ORDERS");
Assertions.assertEquals(List.of("TENANT_ID", "CUSTOMER_ID"), foreignKeys.stream().map(ForeignKeyInfo::getColumn).toList());
Assertions.assertEquals(List.of("TENANT_ID", "ID"), foreignKeys.stream().map(ForeignKeyInfo::getRef_column).toList());
String sql = sqls.get(0);
Assertions.assertTrue(sql.contains("foreign_key.TABLEID = table_object.ID"), sql);
Assertions.assertTrue(sql.contains("foreign_key.FINDEXID"), sql);
Assertions.assertTrue(sql.contains("foreign_key.TYPE$ = 'F'"), sql);
Assertions.assertTrue(sql.contains("SF_GET_INDEX_KEY_SEQ"), sql);
Assertions.assertTrue(sql.contains("schema_object.NAME = ?"), sql);
Assertions.assertTrue(sql.contains("table_object.NAME = ?"), sql);
Assertions.assertTrue(sqls.stream().noneMatch(query -> query.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
}
@Test
void fallsBackToPositionMatchedDictionaryForeignKeysWithoutSystemCatalogAccess() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSCONS foreign_key",
List.of(),
new SQLException("no SYS constraint catalog privilege"),
"FROM ALL_CONSTRAINTS c",
List.of(List.of("FK_ORDER_CUSTOMER", "CUSTOMER_ID", "CUSTOMERS", "ID"))
));
List<ForeignKeyInfo> foreignKeys = agent.listForeignKeys("APP", "ORDERS");
Assertions.assertEquals(1, foreignKeys.size());
Assertions.assertEquals("FK_ORDER_CUSTOMER", foreignKeys.get(0).getName());
int systemQuery = indexOfSql(sqls, "SYS.SYSCONS foreign_key");
int fallbackQuery = indexOfSql(sqls, "ALL_CONSTRAINTS");
Assertions.assertTrue(fallbackQuery > systemQuery, String.join("\n", sqls));
Assertions.assertTrue(sqls.get(fallbackQuery).contains("cc.POSITION = rcc.POSITION"), sqls.get(fallbackQuery));
}
@Test
void acceptsEmptySystemForeignKeysWithoutRunningExpensiveFallback() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSCONS foreign_key",
List.of(),
null,
"FROM ALL_CONSTRAINTS c",
List.of(List.of("SHOULD_NOT_LOAD", "ID", "PARENT", "ID"))
));
Assertions.assertTrue(agent.listForeignKeys("APP", "ORDERS").isEmpty());
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
}
@Test
void doesNotRetryForeignKeyCatalogAfterConnectionFailure() {
DamengAgent agent = new DamengAgent();
List<String> sqls = new ArrayList<>();
TestSupport.setPrivateConnection(agent, catalogFallbackConnection(
sqls,
"SYS.SYSCONS foreign_key",
List.of(),
new SQLNonTransientConnectionException("connection lost"),
"FROM ALL_CONSTRAINTS c",
List.of()
));
RuntimeException error = Assertions.assertThrows(
RuntimeException.class,
() -> agent.listForeignKeys("APP", "ORDERS")
);
Assertions.assertEquals("connection lost", error.getCause().getMessage());
Assertions.assertTrue(sqls.stream().noneMatch(sql -> sql.contains("ALL_CONSTRAINTS")), String.join("\n", sqls));
}
@Test
@@ -2178,7 +2326,8 @@ class DamengAgentMetadataTest {
List.of(
indexRow("IDX_USERS_NAME", "NAME", "N", "BT", 0),
indexRow("UX_USERS_EMAIL", "EMAIL", "Y", "BT", 0),
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0)
indexRow("IDX_USERS_GEO", "GEO", "N", "ST", 0),
Arrays.asList("PK_USERS", "ID", "Y", "1", "BT", "1", 4)
),
sqls
);
@@ -2480,7 +2629,7 @@ class DamengAgentMetadataTest {
}
private static List<Object> indexRow(String name, String columns, String uniqueness, String indexType, int flags) {
return List.of(name, columns, uniqueness, indexType, flags);
return List.of(name, columns, uniqueness, "0", indexType, "0", flags);
}
private static List<List<Object>> dictionaryIndexRows(List<List<Object>> systemRows) {
@@ -2488,10 +2637,45 @@ class DamengAgentMetadataTest {
row.get(0),
row.get(1),
"Y".equals(row.get(2)) ? "UNIQUE" : "NONUNIQUE",
"ST".equals(row.get(3)) ? "SPATIAL" : "NORMAL"
row.get(3),
"ST".equals(row.get(4)) ? "SPATIAL" : "NORMAL",
row.get(5)
)).toList();
}
private static Connection catalogFallbackConnection(
List<String> sqls,
String systemQueryMarker,
List<List<Object>> systemRows,
SQLException systemError,
String fallbackQueryMarker,
List<List<Object>> fallbackRows
) {
return proxy(Connection.class, (method, args) -> {
String name = method.getName();
if ("prepareStatement".equals(name)) {
String sql = (String) args[0];
sqls.add(sql);
if (sql.contains(systemQueryMarker)) {
return systemError == null
? metadataStatement(systemRows)
: failingMetadataStatement(systemError);
}
if (sql.contains(fallbackQueryMarker)) {
return metadataStatement(fallbackRows);
}
throw new AssertionError("Unexpected metadata query: " + sql);
}
if ("close".equals(name)) {
return null;
}
if ("isClosed".equals(name)) {
return false;
}
return defaultValue(method.getReturnType());
});
}
private static int indexOfSql(List<String> sqls, String fragment) {
for (int i = 0; i < sqls.size(); i++) {
if (sqls.get(i).contains(fragment)) {
+25
View File
@@ -290,6 +290,31 @@ func TestDecodeQueryOptions(t *testing.T) {
}
}
func TestIsQueryStatementRecognizesListStatements(t *testing.T) {
tests := []struct {
sql string
want bool
}{
{sql: "LIST USER", want: true},
{sql: "list role", want: true},
{sql: " LiSt PRIVILEGES OF USER root; ", want: true},
{sql: "\tLIST PRIVILEGES OF ROLE dbx7359_role\n", want: true},
{sql: "SHOW VERSION", want: true},
{sql: "SELECT * FROM root.dbx_7359.d", want: true},
{sql: ""},
{sql: " ; "},
{sql: "INSERT INTO root.dbx_7359.d(time,s) VALUES(1,1)"},
}
for _, test := range tests {
t.Run(test.sql, func(t *testing.T) {
if got := isQueryStatement(trimStatementSQL(test.sql)); got != test.want {
t.Fatalf("isQueryStatement(%q) = %t, want %t", test.sql, got, test.want)
}
})
}
}
func TestCancelActiveOperationInvalidatesClient(t *testing.T) {
blocking := &blockingIoTDBSession{closed: make(chan struct{})}
connected := &sessionClient{session: blocking, dialect: client.TreeSqlDialect}
+1 -1
View File
@@ -556,7 +556,7 @@ func isQueryStatement(sql string) bool {
return false
}
switch strings.ToUpper(fields[0]) {
case "SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH":
case "SELECT", "SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH", "LIST":
return true
default:
return false
+1 -1
View File
@@ -1,6 +1,6 @@
dependencies {
implementation 'com.google.code.gson:gson:2.12.1'
implementation 'org.apache.kafka:kafka-clients:3.9.0'
implementation 'org.apache.kafka:kafka-clients:3.9.1'
implementation('org.apache.zookeeper:zookeeper:3.8.4') {
exclude group: 'log4j', module: 'log4j'
exclude group: 'org.slf4j', module: 'slf4j-log4j12'
@@ -0,0 +1,40 @@
package com.dbx.agent.kafka;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.List;
import java.util.Map;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import org.apache.kafka.common.internals.SecurityManagerCompatibility;
import org.apache.kafka.common.security.authenticator.SaslClientCallbackHandler;
import org.junit.jupiter.api.Test;
class KafkaClientCompatibilityTest {
@Test
void passwordSaslCallbacksUseTheCurrentSubject() {
// KAFKA-17078 routes Subject access through this shim when newer JDKs
// disable the legacy Subject.getSubject API.
Subject subject = new Subject();
subject.getPublicCredentials().add("dbx-user");
subject.getPrivateCredentials().add("dbx-password");
for (String mechanism : List.of("PLAIN", "SCRAM-SHA-256", "SCRAM-SHA-512")) {
SaslClientCallbackHandler handler = new SaslClientCallbackHandler();
handler.configure(Map.of(), mechanism, List.of());
NameCallback name = new NameCallback("username");
PasswordCallback password = new PasswordCallback("password", false);
assertDoesNotThrow(() -> SecurityManagerCompatibility.get().callAs(subject, () -> {
handler.handle(new Callback[] { name, password });
return null;
}));
assertEquals("dbx-user", name.getName());
assertArrayEquals("dbx-password".toCharArray(), password.getPassword());
}
}
}
+219
View File
@@ -11,6 +11,9 @@ import (
"io"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -21,10 +24,28 @@ const (
defaultManagementTLSPort = 15671
defaultAMQPTLSPort = 5671
managementPageSize = 100
managementMaxPageSize = 500
managementConnectTimeout = 10 * time.Second
managementRequestTimeout = 20 * time.Second
)
type managementPageRequest struct {
Page int
PageSize int
Search string
UseRegex bool
Sort string
SortReverse bool
}
type managementPageResult struct {
Items []any
Page int
PageSize int
TotalCount int
HasMore bool
}
type managementStatusError struct {
status int
method string
@@ -172,6 +193,204 @@ func managementGetAll(connection jsonObject, path string) ([]any, error) {
}
}
// managementGetPage requests exactly one RabbitMQ management API page. Queue
// and exchange endpoints in RabbitMQ 3.8.x return a pagination envelope when
// page/page_size are present. The plain-array fallback keeps the agent usable
// with endpoints or older brokers that ignore those parameters, while still
// presenting the same explicit page contract to callers.
func managementGetPage(connection jsonObject, path string, request managementPageRequest) (managementPageResult, error) {
if err := validateManagementPageRequest(request); err != nil {
return managementPageResult{}, err
}
query := url.Values{}
query.Set("page", strconv.Itoa(request.Page))
query.Set("page_size", strconv.Itoa(request.PageSize))
if request.Search != "" {
query.Set("name", request.Search)
query.Set("use_regex", strconv.FormatBool(request.UseRegex))
}
if request.Sort != "" {
query.Set("sort", request.Sort)
}
if request.SortReverse {
query.Set("sort_reverse", "true")
}
response, err := managementGet(connection, appendManagementQuery(path, query))
if err != nil {
return managementPageResult{}, err
}
switch typed := response.(type) {
case []any:
return paginateManagementArray(typed, request)
case map[string]any:
object := jsonObject(typed)
items, exists := typed["items"]
if !exists {
return managementPageResult{}, fmt.Errorf("Unexpected management API response for list endpoint %s", path)
}
array, ok := items.([]any)
if !ok {
return managementPageResult{}, fmt.Errorf("Unexpected management API items for list endpoint %s", path)
}
page := intOrDefault(object, "page", request.Page)
pageSize := intOrDefault(object, "page_size", request.PageSize)
totalCount := integerOrNull(object, "filtered_count")
if totalCount == nil {
totalCount = integerOrNull(object, "total_count")
}
if totalCount == nil {
inferred := (page-1)*pageSize + len(array)
totalCount = &inferred
}
pageCount := integerOrNull(object, "page_count")
hasMore := pageCount != nil && page < *pageCount
if pageCount == nil {
hasMore = page*pageSize < *totalCount
}
return managementPageResult{
Items: array,
Page: page,
PageSize: pageSize,
TotalCount: *totalCount,
HasMore: hasMore,
}, nil
default:
return managementPageResult{}, fmt.Errorf("Unexpected management API response for list endpoint %s", path)
}
}
func managementPageRequestFromParams(params jsonObject, allowedSorts ...string) (*managementPageRequest, error) {
page := integerOrNull(params, "page")
pageSize := integerOrNull(params, "page_size")
if page == nil && pageSize == nil {
return nil, nil
}
if page == nil || pageSize == nil {
return nil, errors.New("page and page_size must be provided together")
}
sortName := stringOrDefault(params, "sort", "name")
allowed := false
for _, candidate := range allowedSorts {
if sortName == candidate {
allowed = true
break
}
}
if !allowed {
return nil, fmt.Errorf("Unsupported list sort %q", sortName)
}
request := managementPageRequest{
Page: *page,
PageSize: *pageSize,
Search: strings.TrimSpace(stringOrEmpty(params, "search")),
Sort: sortName,
SortReverse: boolOrDefault(params, "sort_reverse", false),
}
if err := validateManagementPageRequest(request); err != nil {
return nil, err
}
return &request, nil
}
func validateManagementPageRequest(request managementPageRequest) error {
if request.Page < 1 {
return errors.New("page must be at least 1")
}
if request.PageSize < 1 || request.PageSize > managementMaxPageSize {
return fmt.Errorf("page_size must be between 1 and %d", managementMaxPageSize)
}
maxInt := int(^uint(0) >> 1)
if request.Page-1 > maxInt/request.PageSize {
return errors.New("page is too large")
}
return nil
}
func appendManagementQuery(path string, values url.Values) string {
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}
return path + separator + values.Encode()
}
func paginateManagementArray(items []any, request managementPageRequest) (managementPageResult, error) {
filtered := make([]any, 0, len(items))
var expression *regexp.Regexp
if request.Search != "" && request.UseRegex {
compiled, err := regexp.Compile("(?i)" + request.Search)
if err != nil {
return managementPageResult{}, fmt.Errorf("Invalid management list search: %w", err)
}
expression = compiled
}
search := strings.ToLower(request.Search)
for _, item := range items {
if request.Search != "" {
object, ok := item.(map[string]any)
if !ok {
continue
}
name := stringOrEmpty(jsonObject(object), "name")
if expression != nil {
if !expression.MatchString(name) {
continue
}
} else if !strings.Contains(strings.ToLower(name), search) {
continue
}
}
filtered = append(filtered, item)
}
sort.SliceStable(filtered, func(left, right int) bool {
leftObject, _ := filtered[left].(map[string]any)
rightObject, _ := filtered[right].(map[string]any)
leftJSON := jsonObject(leftObject)
rightJSON := jsonObject(rightObject)
comparison := 0
if request.Sort == "messages_ready" {
leftCount := longOrDefault(leftJSON, "messages_ready", 0)
rightCount := longOrDefault(rightJSON, "messages_ready", 0)
if leftCount < rightCount {
comparison = -1
} else if leftCount > rightCount {
comparison = 1
}
}
if comparison == 0 {
comparison = strings.Compare(stringOrEmpty(leftJSON, "name"), stringOrEmpty(rightJSON, "name"))
}
if request.SortReverse {
comparison = -comparison
}
return comparison < 0
})
totalCount := len(filtered)
start := (request.Page - 1) * request.PageSize
if start >= totalCount {
return managementPageResult{
Items: []any{},
Page: request.Page,
PageSize: request.PageSize,
TotalCount: totalCount,
HasMore: false,
}, nil
}
end := start + request.PageSize
if end > totalCount {
end = totalCount
}
return managementPageResult{
Items: filtered[start:end],
Page: request.Page,
PageSize: request.PageSize,
TotalCount: totalCount,
HasMore: end < totalCount,
}, nil
}
func managementBaseURLs(connection jsonObject) ([]string, error) {
if explicit := stringOrNull(connection, "management_url"); explicit != nil && strings.TrimSpace(*explicit) != "" {
return []string{normalizeManagementURL(*explicit)}, nil
+128
View File
@@ -135,6 +135,134 @@ func TestManagementGetAllPlainArray(t *testing.T) {
}
}
func TestManagementGetPageRequestsOnlyOneRabbitMqPage(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCount++
query := request.URL.Query()
if query.Get("page") != "2" || query.Get("page_size") != "100" {
t.Fatalf("unexpected pagination query %q", request.URL.RawQuery)
}
if query.Get("name") != "orders & paid" || query.Get("use_regex") != "false" {
t.Fatalf("unexpected search query %q", request.URL.RawQuery)
}
if query.Get("sort") != "messages_ready" || query.Get("sort_reverse") != "true" {
t.Fatalf("unexpected sort query %q", request.URL.RawQuery)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"items":[{"name":"orders.paid"}],"page":2,"page_size":100,"page_count":3,"total_count":10000,"filtered_count":250}`)
}))
defer server.Close()
page, err := managementGetPage(jsonObject{"management_url": server.URL}, "/api/queues?columns=name", managementPageRequest{
Page: 2, PageSize: 100, Search: "orders & paid", Sort: "messages_ready", SortReverse: true,
})
if err != nil {
t.Fatal(err)
}
if requestCount != 1 {
t.Fatalf("expected one management request, got %d", requestCount)
}
if len(page.Items) != 1 || page.Page != 2 || page.PageSize != 100 || page.TotalCount != 250 || !page.HasMore {
t.Fatalf("unexpected page %#v", page)
}
}
func TestManagementGetPageFallsBackForPlainArrayResponses(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `[{"name":"alpha","messages_ready":1},{"name":"beta","messages_ready":9},{"name":"gamma","messages_ready":4}]`)
}))
defer server.Close()
page, err := managementGetPage(jsonObject{"management_url": server.URL}, "/api/queues", managementPageRequest{
Page: 1, PageSize: 2, Search: "a", Sort: "messages_ready", SortReverse: true,
})
if err != nil {
t.Fatal(err)
}
if page.TotalCount != 3 || !page.HasMore || len(page.Items) != 2 {
t.Fatalf("unexpected page %#v", page)
}
first, _ := page.Items[0].(map[string]any)
second, _ := page.Items[1].(map[string]any)
if stringOrEmpty(jsonObject(first), "name") != "beta" || stringOrEmpty(jsonObject(second), "name") != "gamma" {
t.Fatalf("unexpected sorted items %#v", page.Items)
}
}
func TestManagementPageRequestValidation(t *testing.T) {
for _, params := range []jsonObject{
{"page": 0, "page_size": 100, "sort": "name"},
{"page": 1, "page_size": managementMaxPageSize + 1, "sort": "name"},
{"page": 1},
{"page": 1, "page_size": 100, "sort": "unsupported"},
} {
if _, err := managementPageRequestFromParams(params, "name"); err == nil {
t.Fatalf("expected invalid page request %#v to fail", params)
}
}
if page, err := managementPageRequestFromParams(jsonObject{}, "name"); err != nil || page != nil {
t.Fatalf("legacy unpaged request changed: %#v, %v", page, err)
}
}
func TestListTopicsPagePreservesAllVhostIdentityWithoutDrainingPages(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCount++
if request.URL.Path != "/api/queues" {
t.Fatalf("unexpected all-vhosts path %q", request.URL.Path)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"items":[{"name":"orders","vhost":"staging","durable":true}],"page":1,"page_size":100,"page_count":100,"total_count":10000,"filtered_count":10000}`)
}))
defer server.Close()
service := newServer()
service.cachedConnection = jsonObject{"management_url": server.URL}
listed, err := service.listTopics(jsonObject{
"all_vhosts": true, "page": 1, "page_size": 100, "sort": "name",
})
if err != nil {
t.Fatal(err)
}
result := listed.(jsonObject)
topics := result["topics"].([]jsonObject)
if requestCount != 1 || len(topics) != 1 || stringOrEmpty(topics[0], "vhost") != "staging" {
t.Fatalf("unexpected paged topics %#v after %d requests", result, requestCount)
}
if intOrDefault(result, "totalCount", 0) != 10000 || !boolOrDefault(result, "hasMore", false) {
t.Fatalf("unexpected page metadata %#v", result)
}
}
func TestListExchangesPageSearchIncludesDisplayedDefaultExchange(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
query := request.URL.Query()
if query.Get("use_regex") != "true" || !strings.Contains(query.Get("name"), "^$") {
t.Fatalf("default exchange search was not translated to a regex: %q", request.URL.RawQuery)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(writer, `{"items":[{"name":"","type":"direct","durable":true}],"page":1,"page_size":100,"page_count":1,"total_count":8,"filtered_count":1}`)
}))
defer server.Close()
service := newServer()
service.cachedConnection = jsonObject{"management_url": server.URL}
listed, err := service.listExchanges(jsonObject{
"virtual_host": "/", "page": 1, "page_size": 100, "search": "default", "sort": "name",
})
if err != nil {
t.Fatal(err)
}
result := listed.(jsonObject)
exchanges := result["exchanges"].([]jsonObject)
if len(exchanges) != 1 || stringOrEmpty(exchanges[0], "name") != "" || intOrDefault(result, "totalCount", 0) != 1 {
t.Fatalf("unexpected exchange page %#v", result)
}
}
func TestManagementRequestFailsOverConnectionErrors(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.2:0")
if err != nil {
+64 -10
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"os"
"regexp"
"sort"
"strings"
"unicode/utf8"
@@ -30,7 +31,22 @@ func (s *server) listTopics(params jsonObject) (any, error) {
return nil, err
}
allVhosts := allVhostsRequested(params)
queues, err := managementGetAll(connection, managementListPath(params, connection, "queues"))
pageRequest, err := managementPageRequestFromParams(params, "name", "messages_ready")
if err != nil {
return nil, err
}
var queues []any
var pageResult *managementPageResult
if pageRequest == nil {
queues, err = managementGetAll(connection, managementListPath(params, connection, "queues"))
} else {
page, pageErr := managementGetPage(connection, managementListPath(params, connection, "queues"), *pageRequest)
if pageErr != nil {
return nil, pageErr
}
queues = page.Items
pageResult = &page
}
if err != nil {
return nil, err
}
@@ -46,10 +62,12 @@ func (s *server) listTopics(params jsonObject) (any, error) {
}
topics = append(topics, info)
}
sort.SliceStable(topics, func(left, right int) bool {
return stringOrEmpty(topics[left], "name") < stringOrEmpty(topics[right], "name")
})
return jsonObject{"topics": topics}, nil
if pageRequest == nil {
sort.SliceStable(topics, func(left, right int) bool {
return stringOrEmpty(topics[left], "name") < stringOrEmpty(topics[right], "name")
})
}
return managementListResult("topics", topics, pageResult), nil
}
func topicInfoFromJSON(queue jsonObject) jsonObject {
@@ -382,7 +400,29 @@ func (s *server) listExchanges(params jsonObject) (any, error) {
return nil, err
}
allVhosts := allVhostsRequested(params)
exchanges, err := managementGetAll(connection, managementListPath(params, connection, "exchanges"))
pageRequest, err := managementPageRequestFromParams(params, "name")
if err != nil {
return nil, err
}
if pageRequest != nil && pageRequest.Search != "" && strings.Contains(strings.ToLower("(AMQP default)"), strings.ToLower(pageRequest.Search)) {
// The UI displays the empty-name default exchange as "(AMQP default)".
// Include it in the broker-side name search whenever that display label
// matches, while retaining ordinary substring matches.
pageRequest.Search = "(" + regexp.QuoteMeta(pageRequest.Search) + "|^$)"
pageRequest.UseRegex = true
}
var exchanges []any
var pageResult *managementPageResult
if pageRequest == nil {
exchanges, err = managementGetAll(connection, managementListPath(params, connection, "exchanges"))
} else {
page, pageErr := managementGetPage(connection, managementListPath(params, connection, "exchanges"), *pageRequest)
if pageErr != nil {
return nil, pageErr
}
exchanges = page.Items
pageResult = &page
}
if err != nil {
return nil, err
}
@@ -398,10 +438,24 @@ func (s *server) listExchanges(params jsonObject) (any, error) {
}
result = append(result, info)
}
sort.SliceStable(result, func(left, right int) bool {
return stringOrEmpty(result[left], "name") < stringOrEmpty(result[right], "name")
})
return jsonObject{"exchanges": result}, nil
if pageRequest == nil {
sort.SliceStable(result, func(left, right int) bool {
return stringOrEmpty(result[left], "name") < stringOrEmpty(result[right], "name")
})
}
return managementListResult("exchanges", result, pageResult), nil
}
func managementListResult(key string, items []jsonObject, page *managementPageResult) jsonObject {
result := jsonObject{key: items}
if page == nil {
return result
}
result["page"] = page.Page
result["pageSize"] = page.PageSize
result["totalCount"] = page.TotalCount
result["hasMore"] = page.HasMore
return result
}
func exchangeInfoFromJSON(exchange jsonObject) jsonObject {
+6 -6
View File
@@ -3,7 +3,7 @@
"bigquery": "0.1.69",
"cache": "0.1.13",
"cassandra": "0.1.50",
"dameng": "0.1.78",
"dameng": "0.1.79",
"databend": "0.1.56",
"databricks": "0.1.63",
"db2": "0.1.69",
@@ -15,7 +15,7 @@
"goldendb": "0.1.68",
"h2": "0.1.69",
"h2-legacy": "0.1.38",
"hive": "0.1.55",
"hive": "0.1.56",
"argo": "0.1.0",
"ignite": "0.1.21",
"ignite3": "0.1.21",
@@ -43,12 +43,12 @@
"oscar": "0.1.43",
"xugu": "0.1.47",
"iris": "0.1.62",
"iotdb": "0.1.42",
"iotdb": "0.1.43",
"etcd": "0.1.43",
"etcd2": "0.1.1",
"zookeeper": "0.1.31",
"kafka": "0.1.15",
"kafka": "0.1.16",
"rocketmq": "0.1.13",
"rabbitmq": "0.1.11",
"sqlserver-legacy": "0.1.41"
"rabbitmq": "0.1.12",
"sqlserver-legacy": "0.1.42"
}
+19 -5
View File
@@ -93,7 +93,7 @@ import { uuid } from "@/lib/common/utils";
import { isMacOS, isWindows } from "@/lib/backend/platform";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
@@ -2435,8 +2435,7 @@ async function saveActiveSqlAsLocalFile() {
if (tab) await saveExternalSqlTabAs(tab);
}
function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
function applyExternalSqlTarget(tab: QueryTab, target: ExternalSqlFileTarget) {
if (target.connectionId !== tab.connectionId) {
queryStore.updateConnection(tab.id, target.connectionId, target.database);
}
@@ -2449,6 +2448,13 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
if (target.schema !== tab.schema) queryStore.updateSchema(tab.id, target.schema);
}
function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
applyExternalSqlTarget(
tab,
resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, tab.id, (connectionId) => connectionStore.getConfig(connectionId)),
);
}
async function openSqlFile() {
const tab = activeTab.value;
if (!tab) return;
@@ -2477,6 +2483,10 @@ async function openSqlFile() {
if (!file) return;
try {
queryStore.updateSql(tab.id, await readBrowserSqlFile(file, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)));
applyExternalSqlTarget(
tab,
activeTabExternalSqlFileTarget(queryStore.tabs, tab.id, (connectionId) => connectionStore.getConfig(connectionId)),
);
} catch (e: any) {
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
}
@@ -2520,6 +2530,8 @@ async function openSqlFilePath(path: string) {
try {
await desktopOpenTabsRestorationBarrier?.settled;
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
// Startup and OS-open events have no initiating editor tab. Keep new files
// unassociated even if persisted tab restoration has since selected one.
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
@@ -3290,7 +3302,7 @@ async function handleQuickOpenSelect(item: any) {
if (item.type === "content_match" && item.filePath) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column });
} catch (e: any) {
toast(
@@ -3304,7 +3316,7 @@ async function handleQuickOpenSelect(item: any) {
if (item.type === "sql_file" && item.filePath) {
try {
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
toast(
@@ -4198,6 +4210,8 @@ onUnmounted(() => {
@open-sql-file="dialogs.showSqlFileDialog.value = true"
@open-schema-diff="dialogs.showSchemaDiffDialog.value = true"
@open-data-compare="dialogs.showDataCompareDialog.value = true"
@open-backups="openSettings('backups')"
@open-mcp-settings="openSettings('mcp')"
/>
<div :class="isDetachedWindowContext ? 'flex-1 flex min-h-0' : isClassicLayout ? 'app-layout-classic flex-1 flex min-h-0' : 'app-panel-gutter flex-1 flex min-h-0 gap-1 p-1'">
@@ -2231,8 +2231,10 @@ html.dark .driver-store-agent-row--installed::before {
overflow-x: hidden;
}
/* Storage tab: cards scroll together inside the tab */
.driver-store-storage-tab {
/* Storage tab: cards scroll together inside the tab. The selector must out-specify
`.driver-store-tabs-root > [data-slot="tabs-content"]` above, otherwise the
`overflow: hidden` there wins and the cards get clipped with no way to scroll. */
.driver-store-tabs-root > [data-slot="tabs-content"].driver-store-storage-tab {
display: flex !important;
flex-direction: column;
flex: 1 1 0;
@@ -2241,6 +2243,12 @@ html.dark .driver-store-agent-row--installed::before {
overflow-x: hidden;
}
/* Cards must keep their natural height, otherwise `flex` squeezes the runtime
list inside the last card instead of letting the tab scroll. */
.driver-store-tabs-root > [data-slot="tabs-content"].driver-store-storage-tab > * {
flex-shrink: 0;
}
.driver-store-agent-name,
.driver-store-jdbc-name {
flex: 1 1 auto !important;
@@ -148,7 +148,7 @@ import {
} from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleObjectFiltersNeedReset } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, filterSchemaNamesForVisiblePicker, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase, supportsDataDictionary } from "@/lib/database/databaseFeatureSupport";
import { normalizeConnectionScope, normalizeConnectionTimeouts } from "@/lib/connection/connectionSubmitNormalization";
import { databaseConnectionFormKind } from "@/lib/database/databaseDriverManifest";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
@@ -231,6 +231,7 @@ const DREMIO_ARROW_FLIGHT_SQL_JDBC_DRIVER_CLASS = "org.apache.arrow.driver.jdbc.
const DREMIO_LEGACY_JDBC_URL = "jdbc:dremio:direct=127.0.0.1:31010";
const DREMIO_LEGACY_JDBC_DRIVER_CLASS = "com.dremio.jdbc.Driver";
const DEFAULT_SSH_USER = "root";
const DIRECT_SIDEBAR_OBJECT_TYPES = new Set<DatabaseType>(["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "mqtt", "nacos", "plugin"]);
const ETCD_GRPC_MAX_INBOUND_DEFAULT_MIB = 32;
const ETCD_GRPC_MAX_INBOUND_MIN_MIB = 1;
const ETCD_GRPC_MAX_INBOUND_MAX_MIB = 256;
@@ -441,6 +442,7 @@ const defaultForm = (): ConnectionForm => ({
docs_notes_path: undefined,
read_only: false,
show_system_schemas: false,
sidebar_auto_load_all_tables: false,
is_production: false,
production_databases: [],
visible_databases: undefined,
@@ -632,6 +634,7 @@ function sshLayersForConfig(config: LegacyConnectionConfig): SshTunnelConfig[] {
}
const form = ref(defaultForm());
const supportsAutomaticTableLoading = computed(() => supportsDataDictionary(form.value.db_type) && !DIRECT_SIDEBAR_OBJECT_TYPES.has(form.value.db_type));
const redisKeyTemplatesText = ref("");
const noteTextareaRef = ref<HTMLTextAreaElement | null>(null);
const showGaussdbConnectionMode = computed(() => form.value.db_type === "gaussdb");
@@ -3110,6 +3113,7 @@ watch(
docs_notes_path: config.docs_notes_path,
read_only: config.read_only || false,
show_system_schemas: config.show_system_schemas || false,
sidebar_auto_load_all_tables: config.sidebar_auto_load_all_tables === true,
is_production: config.is_production || false,
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
@@ -5141,6 +5145,7 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.visible_databases = Array.isArray(config.visible_databases) && config.visible_databases.length > 0 ? config.visible_databases : undefined;
}
if (!config.show_system_schemas) config.show_system_schemas = undefined;
if (!config.sidebar_auto_load_all_tables) config.sidebar_auto_load_all_tables = undefined;
if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined;
if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined;
// Pasted credentials may carry invisible characters that trim() keeps (#9043).
@@ -9878,6 +9883,16 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
</label>
</div>
<div v-if="supportsAutomaticTableLoading" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallPaddedClass">{{ t("connection.tableLoading") }}</Label>
<div class="col-span-3 grid gap-1.5">
<label class="flex cursor-pointer items-center gap-2">
<Switch v-model="form.sidebar_auto_load_all_tables" />
<span class="text-sm font-medium">{{ t("connection.autoLoadAllTables") }}</span>
</label>
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.autoLoadAllTablesHint") }}</p>
</div>
</div>
<!-- Documentation notes are a relational-only feature, so this
follows the same isSchemaAware gate as the row above. -->
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-start gap-4">
@@ -234,7 +234,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
});
it("updates the existing ID only after Save and preserves omitted fields", async () => {
await mountDialog(savedConnection(), "dbx://connection/new?id=saved-production&password=new-password");
await mountDialog(savedConnection({ sidebar_auto_load_all_tables: true }), "dbx://connection/new?id=saved-production&password=new-password");
const save = [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Save");
expect(save).toBeTruthy();
save!.click();
@@ -255,6 +255,7 @@ describe("ConnectionDialog deep-link edit confirmation", () => {
is_production: true,
production_databases: ["orders"],
visible_databases: ["orders"],
sidebar_auto_load_all_tables: true,
url_params: "connectTimeout=25",
}),
);
@@ -121,7 +121,7 @@ watch([sourceDbType, targetDbType], ([src, tgt]) => {
});
const optionTree = computed(() => {
const targetConfig = store.getConfig(targetConnectionId.value);
const dbType = targetConfig?.db_type || "postgres";
const dbType = schemaDiffEngineDatabaseType(targetConfig) || targetConfig?.db_type || "postgres";
return getSchemaDiffOptionsForDbType(dbType);
});
@@ -1647,12 +1647,14 @@ const targetConnectionInfo = computed(() => {
<!-- Options Panel Overlay -->
<div v-if="showOptionsPanel" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="showOptionsPanel = false">
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-4">
<div class="flex h-[calc(100%-2rem)] max-h-[680px] w-[900px] max-w-[calc(100%-2rem)] flex-col overflow-hidden rounded-lg border bg-card shadow-lg">
<div class="flex shrink-0 items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-medium">{{ t("schemaDiff.optionsTitle") }}</h3>
<Button variant="ghost" size="sm" @click="showOptionsPanel = false" :aria-label="t('common.close')">✕</Button>
</div>
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
<div class="min-h-0 flex-1 p-4">
<SchemaDiffOptionsPanel :options="schemaDiffPanelOptions" :option-tree="optionTree" @update:options="handleOptionsUpdate" @close="showOptionsPanel = false" />
</div>
</div>
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import { ListChecks, ListFilter, Settings2, TableProperties } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -22,6 +23,13 @@ const { t } = useI18n();
// The panel is a transactional editor: it is recreated each time it opens and
// only commits its local snapshot when the user clicks Done.
const localOptions = ref<SchemaDiffCompareOptions>(normalizeSchemaDiffCompareOptions(props.options));
const objectOptionIds = new Set<BooleanSchemaDiffCompareOptionKey>(["views", "functions", "indexes", "sequences", "triggers", "rules", "owners"]);
const tableDetailOptionIds = new Set<BooleanSchemaDiffCompareOptionKey>(["compareColumnOrder", "compareCharset"]);
const tableOption = computed(() => props.optionTree.find((item) => item.id === "tables"));
const objectOptions = computed(() => props.optionTree.filter((item) => objectOptionIds.has(item.id)));
const tableStructureOptions = computed(() => [...(tableOption.value?.children ?? []), ...props.optionTree.filter((item) => tableDetailOptionIds.has(item.id))]);
const behaviorOptions = computed(() => props.optionTree.filter((item) => item.id !== "tables" && !objectOptionIds.has(item.id) && !tableDetailOptionIds.has(item.id)));
function isChecked(id: BooleanSchemaDiffCompareOptionKey): boolean {
return !!localOptions.value[id];
@@ -66,7 +74,7 @@ function handleCancel() {
}
function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): string {
const base = "h-4 w-4 rounded border flex items-center justify-center transition-colors cursor-pointer";
const base = "flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors";
if (state === "checked") {
return `${base} bg-primary border-primary text-primary-foreground`;
}
@@ -75,104 +83,165 @@ function getItemClasses(state: "checked" | "unchecked" | "indeterminate"): strin
}
return `${base} bg-background border-input hover:border-muted-foreground`;
}
function getItemRowClasses(item: SchemaDiffOptionItem): string {
const state = getChildState(item);
const base = "group flex min-h-8 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
return state === "unchecked" ? `${base} text-foreground hover:bg-muted/60` : `${base} bg-primary/5 text-foreground hover:bg-primary/10`;
}
function getAriaChecked(item: SchemaDiffOptionItem): boolean | "mixed" {
const state = getChildState(item);
return state === "indeterminate" ? "mixed" : state === "checked";
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="grid flex-1 grid-cols-[minmax(0,1fr)_minmax(300px,0.95fr)] gap-4 overflow-auto pr-1">
<div class="min-w-0 space-y-1">
<div class="px-2 pb-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.optionsTitle") }}</div>
<div class="space-y-1">
<template v-for="item in optionTree" :key="item.id">
<div class="space-y-1">
<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(item)">
<div :class="getItemClasses(getChildState(item))">
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12" />
</svg>
<svg v-else-if="getChildState(item) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</div>
<span class="text-sm select-none">{{ t(item.labelKey) }}</span>
</div>
<div v-if="item.children" class="ml-6 space-y-1">
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-1 px-2 rounded hover:bg-muted/50 cursor-pointer" @click="toggleItem(child)">
<div :class="getItemClasses(getChildState(child))">
<svg v-if="getChildState(child) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<span class="text-sm select-none">{{ t(child.labelKey) }}</span>
</div>
</div>
</div>
</template>
</div>
<div class="flex h-full min-h-0 flex-col">
<div class="grid min-h-0 flex-1 grid-cols-[minmax(0,1.15fr)_minmax(300px,0.85fr)] overflow-hidden">
<div class="min-h-0 space-y-4 overflow-y-auto pr-4">
<section data-section="object-scope">
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
<ListChecks class="h-4 w-4 shrink-0 text-muted-foreground" />
<h4>{{ t("schemaDiff.options.objectScopeSection") }}</h4>
</div>
<div class="grid grid-cols-2 gap-1">
<button v-if="tableOption" type="button" role="checkbox" :aria-checked="getAriaChecked(tableOption)" :class="getItemRowClasses(tableOption)" @click="toggleItem(tableOption)">
<span :class="getItemClasses(getChildState(tableOption))">
<svg v-if="getChildState(tableOption) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
<svg v-else-if="getChildState(tableOption) === 'indeterminate'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
<span class="select-none">{{ t(tableOption.labelKey) }}</span>
</button>
<button v-for="item in objectOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
<span :class="getItemClasses(getChildState(item))">
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
</span>
<span class="select-none">{{ t(item.labelKey) }}</span>
</button>
</div>
</section>
<section data-section="table-structure" class="border-t pt-4">
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
<TableProperties class="h-4 w-4 shrink-0 text-muted-foreground" />
<h4>{{ t("schemaDiff.options.tableStructureSection") }}</h4>
</div>
<div class="grid grid-cols-2 gap-1">
<button v-for="item in tableStructureOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
<span :class="getItemClasses(getChildState(item))">
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
</span>
<span class="select-none">{{ t(item.labelKey) }}</span>
</button>
</div>
</section>
<section v-if="behaviorOptions.length" data-section="compare-behavior" class="border-t pt-4">
<div class="mb-2 flex items-center gap-2 text-xs font-semibold">
<Settings2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<h4>{{ t("schemaDiff.options.behaviorSection") }}</h4>
</div>
<div class="grid grid-cols-2 gap-1">
<button v-for="item in behaviorOptions" :key="item.id" type="button" role="checkbox" :aria-checked="getAriaChecked(item)" :class="getItemRowClasses(item)" @click="toggleItem(item)">
<span :class="getItemClasses(getChildState(item))">
<svg v-if="getChildState(item) === 'checked'" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12" /></svg>
</span>
<span class="select-none">{{ t(item.labelKey) }}</span>
</button>
</div>
</section>
</div>
<div class="grid min-w-0 grid-cols-[112px_minmax(0,1fr)] content-start gap-x-3 gap-y-2 border-l pl-4">
<div class="pt-1 text-xs font-medium text-muted-foreground">{{ t("schemaDiff.tableFilter") }}</div>
<div class="flex min-w-0 items-center justify-between gap-2">
<span class="truncate text-xs text-muted-foreground">{{ t("schemaDiff.tableFilterHintShort") }}</span>
<HelpTooltip :label="t('schemaDiff.tableFilterHelp')" side="left">
<div class="space-y-1">
<div>{{ t("schemaDiff.tableFilterHelpRule") }}</div>
<div>{{ t("schemaDiff.tableFilterHelpBlank") }}</div>
<div>{{ t("schemaDiff.tableFilterHelpExample") }}</div>
<div class="min-h-0 space-y-4 overflow-y-auto border-l pl-4 pr-1">
<section>
<div class="mb-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs font-semibold">
<ListFilter class="h-4 w-4 shrink-0 text-muted-foreground" />
<h4>{{ t("schemaDiff.tableFilter") }}</h4>
</div>
</HelpTooltip>
</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-include">{{ t("schemaDiff.tableIncludePattern") }}</label>
<input id="schema-diff-table-include" v-model="localOptions.tableIncludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableIncludePatternPlaceholder')" />
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-exclude">{{ t("schemaDiff.tableExcludePattern") }}</label>
<input id="schema-diff-table-exclude" v-model="localOptions.tableExcludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableExcludePatternPlaceholder')" />
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-table-priority">{{ t("schemaDiff.tableFilterPriority") }}</label>
<div>
<Select v-model="localOptions.tableFilterPriority">
<SelectTrigger id="schema-diff-table-priority" class="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">{{ t("schemaDiff.tableFilterPriorityExclude") }}</SelectItem>
<SelectItem value="include">{{ t("schemaDiff.tableFilterPriorityInclude") }}</SelectItem>
</SelectContent>
</Select>
</div>
<HelpTooltip :label="t('schemaDiff.tableFilterHelp')" side="left">
<div class="space-y-1">
<div>{{ t("schemaDiff.tableFilterHelpRule") }}</div>
<div>{{ t("schemaDiff.tableFilterHelpBlank") }}</div>
<div>{{ t("schemaDiff.tableFilterHelpExample") }}</div>
</div>
</HelpTooltip>
</div>
<div class="space-y-2.5">
<div class="space-y-1">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-include">{{ t("schemaDiff.tableIncludePattern") }}</label>
<input id="schema-diff-table-include" v-model="localOptions.tableIncludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableIncludePatternPlaceholder')" />
</div>
<div class="space-y-1">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-exclude">{{ t("schemaDiff.tableExcludePattern") }}</label>
<input id="schema-diff-table-exclude" v-model="localOptions.tableExcludePattern" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.tableExcludePatternPlaceholder')" />
</div>
<div class="space-y-1">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-table-priority">{{ t("schemaDiff.tableFilterPriority") }}</label>
<Select v-model="localOptions.tableFilterPriority">
<SelectTrigger id="schema-diff-table-priority" class="h-8 w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">{{ t("schemaDiff.tableFilterPriorityExclude") }}</SelectItem>
<SelectItem value="include">{{ t("schemaDiff.tableFilterPriorityInclude") }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</section>
<!-- Advanced options -->
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.advancedSection") }}</div>
<section class="border-t pt-4">
<div class="mb-3 flex items-center gap-2 text-xs font-semibold">
<Settings2 class="h-4 w-4 shrink-0 text-muted-foreground" />
<h4>{{ t("schemaDiff.options.advancedSection") }}</h4>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
<span class="font-mono text-[11px]" :class="localOptions.detectRenames || localOptions.detectTableRenames ? 'text-foreground' : 'text-muted-foreground'">{{ localOptions.renameThreshold.toFixed(2) }}</span>
</div>
<input
id="schema-diff-rename-threshold"
v-model.number="localOptions.renameThreshold"
type="range"
min="0"
max="1"
step="0.05"
:disabled="!localOptions.detectRenames && !localOptions.detectTableRenames"
class="h-1.5 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-40"
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
<span class="font-mono text-[11px] text-foreground">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
</div>
<input id="schema-diff-compat-threshold" v-model.number="localOptions.compatibilityThreshold" type="range" min="0" max="1" step="0.05" class="h-1.5 w-full cursor-pointer accent-primary" />
</div>
<div class="grid grid-cols-2 gap-2">
<div class="min-w-0 space-y-1">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
</div>
<div class="min-w-0 space-y-1">
<label class="text-[11px] font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
</div>
</div>
</div>
</section>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-rename-threshold">{{ t("schemaDiff.options.renameThreshold") }}</label>
<div v-if="localOptions.detectRenames || localOptions.detectTableRenames" class="flex items-center gap-2 pt-1">
<input id="schema-diff-rename-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.renameThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
<span class="text-xs font-mono w-10 text-right">{{ localOptions.renameThreshold.toFixed(2) }}</span>
</div>
<div v-else class="pt-1 text-xs text-muted-foreground italic">
{{ t("schemaDiff.options.detectRenames") }}
</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-compat-threshold">{{ t("schemaDiff.options.compatibilityThreshold") }}</label>
<div class="flex items-center gap-2 pt-1">
<input id="schema-diff-compat-threshold" type="range" min="0" max="1" step="0.05" v-model.number="localOptions.compatibilityThreshold" class="flex-1 h-1.5 accent-primary cursor-pointer" />
<span class="text-xs font-mono w-10 text-right">{{ localOptions.compatibilityThreshold.toFixed(2) }}</span>
</div>
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-source-dialect">{{ t("schemaDiff.options.sourceDialect") }}</label>
<input id="schema-diff-source-dialect" v-model="localOptions.sourceDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
<label class="pt-2 text-xs font-medium text-muted-foreground" for="schema-diff-target-dialect">{{ t("schemaDiff.options.targetDialect") }}</label>
<input id="schema-diff-target-dialect" v-model="localOptions.targetDialect" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.dialectAuto')" />
<div class="col-span-2 pt-3 pb-1 text-xs font-medium text-muted-foreground border-t mt-2">{{ t("schemaDiff.options.batchSection") }}</div>
<div class="flex items-center gap-2 col-span-2 pt-1">
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
</div>
<section class="border-t pt-4">
<h4 class="mb-2.5 text-xs font-semibold">{{ t("schemaDiff.options.batchSection") }}</h4>
<input id="schema-diff-batch-patterns" v-model="localOptions.batchPatterns" class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs outline-none transition-shadow focus:ring-1 focus:ring-ring" :placeholder="t('schemaDiff.options.batchPatterns')" />
</section>
</div>
</div>
<div class="flex items-center justify-end gap-2 pt-4 border-t mt-2">
<div class="mt-3 flex shrink-0 items-center justify-end gap-2 border-t pt-3">
<Button variant="outline" size="sm" @click="handleCancel">
{{ t("common.cancel") }}
</Button>
@@ -58,6 +58,33 @@ describe("SchemaDiffOptionsPanel", () => {
expect(normalized.ignoreColumnNameCase).toBe(false);
});
it("exposes charset comparison only for supported MySQL targets and defaults it on", () => {
expect(getSchemaDiffOptionsForDbType("mysql").map((item) => item.id)).toContain("compareCharset");
expect(getSchemaDiffOptionsForDbType("goldendb").map((item) => item.id)).toContain("compareCharset");
expect(getSchemaDiffOptionsForDbType("starrocks").map((item) => item.id)).not.toContain("compareCharset");
expect(getSchemaDiffOptionsForDbType("doris").map((item) => item.id)).not.toContain("compareCharset");
expect(getSchemaDiffOptionsForDbType("postgres").map((item) => item.id)).not.toContain("compareCharset");
expect(normalizeSchemaDiffCompareOptions({}, "mysql").compareCharset).toBe(true);
});
it("groups MySQL charset comparison with table structure options", async () => {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(SchemaDiffOptionsPanel, {
options: normalizeSchemaDiffCompareOptions({}, "mysql"),
optionTree: getSchemaDiffOptionsForDbType("mysql"),
});
mountedApps.push(app);
app.mount(host);
await nextTick();
const tableStructure = host.querySelector('[data-section="table-structure"]');
const behavior = host.querySelector('[data-section="compare-behavior"]');
expect(tableStructure?.textContent).toContain("schemaDiff.options.compareCharset");
expect(behavior?.textContent).not.toContain("schemaDiff.options.compareCharset");
});
it("keeps unsaved checkbox edits when the parent refreshes equivalent options", async () => {
const options = ref<SchemaDiffCompareOptions>({ ...DEFAULT_POSTGRES_OPTIONS });
const optionTree: SchemaDiffOptionItem[] = [{ id: "views", labelKey: "views", defaultChecked: true }];
@@ -66,6 +66,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useUserSkillStore } from "@/stores/userSkillStore";
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
import { aiConversationTypographyCssVariables } from "@/lib/ai/aiTypography";
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
@@ -210,6 +211,12 @@ const AiHtmlPreview = defineAsyncComponent({
loader: () => import("@/components/ai/rich/AiHtmlPreview.vue"),
});
const settings = useSettingsStore();
const aiTypographyStyle = computed(() =>
aiConversationTypographyCssVariables({
fontFamily: settings.editorSettings.aiFontFamily,
fontSize: settings.editorSettings.aiFontSize,
}),
);
const connectionStore = useConnectionStore();
const savedSqlStore = useSavedSqlStore();
const promptTemplateStore = usePromptTemplateStore();
@@ -5419,7 +5426,7 @@ async function openExternalUrl(url: string) {
</script>
<template>
<div ref="assistantRootRef" data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" @dragenter="onAttachmentDragEnter" @dragover="onAttachmentDragOver" @dragleave="onAttachmentDragLeave" @drop="onAttachmentDrop">
<div ref="assistantRootRef" data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" :style="aiTypographyStyle" @dragenter="onAttachmentDragEnter" @dragover="onAttachmentDragOver" @dragleave="onAttachmentDragLeave" @drop="onAttachmentDrop">
<div class="flex items-center gap-2 border-b px-3 shrink-0" :class="settings.editorSettings.appLayout === 'classic' ? 'h-9' : 'h-10'">
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
{{ chatTitle }}
@@ -5655,7 +5662,7 @@ async function openExternalUrl(url: string) {
data-edit-textarea
v-model="editingContent"
rows="3"
class="w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
class="ai-conversation-text w-full resize-none rounded-lg border bg-background px-3 py-2 text-xs outline-none focus:ring-1 focus:ring-primary"
@keydown="onEditKeydown($event, i)"
@compositionstart="editCompositionActive = true"
@compositionend="editCompositionActive = false"
@@ -5707,7 +5714,7 @@ async function openExternalUrl(url: string) {
class="w-44"
/>
</div>
<div v-if="messageReferenceMentions(msg).length || msg.content" class="min-w-0 rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
<div v-if="messageReferenceMentions(msg).length || msg.content" class="ai-conversation-text min-w-0 rounded-lg bg-primary px-3 py-2 text-xs text-primary-foreground">
<div v-if="messageReferenceMentions(msg).length" class="mb-1.5 flex flex-wrap justify-end gap-1">
<button
v-for="mention in messageReferenceMentions(msg)"
@@ -5777,7 +5784,7 @@ async function openExternalUrl(url: string) {
<!-- Keep the metadata row as wide as the reply card so its export action stays right-aligned. -->
<div v-else-if="msg.content || msg.reasoning || msg.isThinking" class="flex w-full max-w-[95%] min-w-0 flex-col">
<div class="w-full rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
<div data-ai-assistant-message-content class="ai-conversation-text w-full rounded-lg bg-muted px-3 py-2 text-xs leading-relaxed [overflow-wrap:anywhere]">
<div v-if="msg.reasoning || msg.isThinking" class="mb-2">
<button class="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" @click="toggleReasoning()">
<ChevronRight class="h-3 w-3 transition-transform duration-200" :class="{ 'rotate-90': reasoningExpanded }" />
@@ -6330,7 +6337,7 @@ async function openExternalUrl(url: string) {
ref="promptTextareaRef"
v-model="prompt"
:style="{ height: `${textareaHeight}px`, maxHeight: `${maxTextareaHeight()}px` }"
class="w-full resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground mb-1"
class="ai-conversation-text w-full resize-none bg-transparent text-xs outline-none placeholder:text-muted-foreground mb-1"
:placeholder="activePlaceholder"
@input="refreshMentionState"
@click="refreshMentionState"
@@ -6675,6 +6682,11 @@ async function openExternalUrl(url: string) {
</template>
<style scoped>
.ai-conversation-text {
font-family: var(--dbx-ai-content-font-family, inherit);
font-size: var(--dbx-ai-content-font-size, 0.75rem);
}
.ai-prompt-context-row--compact .ai-prompt-context-spacer {
flex: 0 0 0;
}
@@ -6791,7 +6803,7 @@ async function openExternalUrl(url: string) {
border-radius: 0.25rem;
background: var(--muted);
padding: 0.125rem 0.375rem;
font-size: 11px;
font-size: var(--dbx-ai-inline-code-font-size, 11px);
font-family: ui-monospace, monospace;
}
.ai-markdown :deep(pre) {
@@ -6873,6 +6885,11 @@ html.dbx-legacy-webview.dark .ai-markdown :deep(.ai-markdown-table-wrap:hover::-
min-height: 1lh;
}
.ai-code-block {
font-family: ui-monospace, monospace;
font-size: var(--dbx-ai-code-font-size, 0.75rem);
}
.ai-message-scroll :deep([data-slot="scroll-area-viewport"]) {
overflow-anchor: none;
}
@@ -271,6 +271,16 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { currentLocale, previewLocale, restoreLocalePreview, setLocale, type Locale } from "@/i18n";
import {
AI_CONVERSATION_FONT_FAMILY_DEFAULT,
AI_CONVERSATION_FONT_SIZE_DEFAULT,
AI_CONVERSATION_FONT_SIZE_MAX,
AI_CONVERSATION_FONT_SIZE_MIN,
aiConversationFontFamilyForName,
normalizeAiConversationFontFamily,
normalizeAiConversationFontFamilyInput,
normalizeAiConversationFontSize,
} from "@/lib/ai/aiTypography";
import {
SETTINGS_SEARCH_DEFINITIONS,
TOOLBAR_VISIBILITY_ITEMS,
@@ -592,6 +602,9 @@ function createEmptyTableColumnTemplateRow(): TableColumnTemplateGridRow {
// Local edit state
const editFontFamily = ref(settingsStore.editorSettings.fontFamily);
const editFontSize = ref(settingsStore.editorSettings.fontSize);
const editAiFontFamily = ref(settingsStore.editorSettings.aiFontFamily);
const editAiFontSize = ref<number | string>(settingsStore.editorSettings.aiFontSize);
const aiTypographySaving = ref(false);
const editTableFontFamily = ref(settingsStore.editorSettings.tableFontFamily);
const editUiFontFamily = ref(settingsStore.editorSettings.uiFontFamily);
const editUiScale = ref(settingsStore.editorSettings.uiScale);
@@ -651,6 +664,7 @@ const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editShowWhitespace = ref(settingsStore.editorSettings.showWhitespace);
const editDdlOpenMode = ref<EditorSettings["ddlOpenMode"]>(settingsStore.editorSettings.ddlOpenMode);
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
const editDoubleClickStringSelectionMode = ref<EditorSettings["doubleClickStringSelectionMode"]>(settingsStore.editorSettings.doubleClickStringSelectionMode);
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(settingsStore.editorSettings.sqlSemanticDiagnosticsMode);
const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
@@ -1011,6 +1025,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
showWhitespace: editShowWhitespace.value,
ddlOpenMode: editDdlOpenMode.value,
vimModeEnabled: editVimModeEnabled.value,
doubleClickStringSelectionMode: editDoubleClickStringSelectionMode.value,
autoCloseBrackets: editAutoCloseBrackets.value,
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value,
@@ -1589,6 +1604,12 @@ const systemFontOptions = computed(() => {
const tableFontOptions = computed(() => buildFontFamilyOptions(systemFonts.value, [editTableFontFamily.value], [DEFAULT_DATA_GRID_FONT_FAMILY]));
const aiFontOptions = computed(() => {
const options = new Set([DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY, ...systemFonts.value.map(aiConversationFontFamilyForName)]);
if (editAiFontFamily.value) options.add(editAiFontFamily.value);
return [...options];
});
const uiFontOptions = computed(() => {
const options = new Set([SYSTEM_UI_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, ...systemFontOptions.value]);
if (editUiFontFamily.value) options.add(editUiFontFamily.value);
@@ -1601,6 +1622,11 @@ function displayUiFontFamily(value: string): string {
return displayFontFamily(value);
}
function displayAiFontFamily(value: string): string {
if (!value) return t("ai.conversationFontFollowInterface");
return displayUiFontFamily(value);
}
function fontOptionStyle(value: string, selectedValue = editFontFamily.value) {
return isPresetFontFamily(value) || uiFontPreviewValues.has(value) || value === selectedValue ? { fontFamily: value } : undefined;
}
@@ -1630,6 +1656,8 @@ const hasImportedSettingsPendingApply = ref(false);
function syncEditorSettingsDraftFromStore() {
editFontFamily.value = settingsStore.editorSettings.fontFamily;
editFontSize.value = settingsStore.editorSettings.fontSize;
editAiFontFamily.value = settingsStore.editorSettings.aiFontFamily;
editAiFontSize.value = settingsStore.editorSettings.aiFontSize;
editTableFontFamily.value = settingsStore.editorSettings.tableFontFamily;
editUiFontFamily.value = settingsStore.editorSettings.uiFontFamily;
editUiScale.value = settingsStore.editorSettings.uiScale;
@@ -1658,6 +1686,7 @@ function syncEditorSettingsDraftFromStore() {
editShowWhitespace.value = settingsStore.editorSettings.showWhitespace;
editDdlOpenMode.value = settingsStore.editorSettings.ddlOpenMode;
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
editDoubleClickStringSelectionMode.value = settingsStore.editorSettings.doubleClickStringSelectionMode;
editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
@@ -1796,6 +1825,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
showWhitespace: editShowWhitespace,
ddlOpenMode: editDdlOpenMode,
vimModeEnabled: editVimModeEnabled,
doubleClickStringSelectionMode: editDoubleClickStringSelectionMode,
autoCloseBrackets: editAutoCloseBrackets,
sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode,
confirmDangerousSqlExecution: editConfirmDangerousSqlExecution,
@@ -2286,6 +2316,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace;
editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editDoubleClickStringSelectionMode.value = DEFAULT_EDITOR_SETTINGS.doubleClickStringSelectionMode;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
@@ -2444,6 +2475,7 @@ function resetAllDefaults() {
editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace;
editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode;
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
editDoubleClickStringSelectionMode.value = DEFAULT_EDITOR_SETTINGS.doubleClickStringSelectionMode;
editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets;
editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode;
editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled;
@@ -2716,6 +2748,44 @@ function onUiFontFamilyChange(v: any) {
}
}
async function persistAiTypography(partial: Partial<Pick<EditorSettings, "aiFontFamily" | "aiFontSize">>) {
aiTypographySaving.value = true;
try {
await settingsStore.updateEditorSettingsAndPersist(partial);
} catch (error) {
applySettingsErrorToast(error);
} finally {
editAiFontFamily.value = settingsStore.editorSettings.aiFontFamily;
editAiFontSize.value = settingsStore.editorSettings.aiFontSize;
aiTypographySaving.value = false;
}
}
function onAiFontFamilyChange(value: unknown) {
const fontFamily = normalizeAiConversationFontFamily(value);
editAiFontFamily.value = fontFamily;
if (fontFamily !== settingsStore.editorSettings.aiFontFamily) void persistAiTypography({ aiFontFamily: fontFamily });
}
function commitAiFontSize() {
const fontSize = normalizeAiConversationFontSize(editAiFontSize.value);
editAiFontSize.value = fontSize;
if (fontSize !== settingsStore.editorSettings.aiFontSize) void persistAiTypography({ aiFontSize: fontSize });
}
function blurAiFontSizeInput(event: KeyboardEvent) {
(event.target as HTMLInputElement | null)?.blur();
}
function resetAiTypography() {
editAiFontFamily.value = AI_CONVERSATION_FONT_FAMILY_DEFAULT;
editAiFontSize.value = AI_CONVERSATION_FONT_SIZE_DEFAULT;
void persistAiTypography({
aiFontFamily: AI_CONVERSATION_FONT_FAMILY_DEFAULT,
aiFontSize: AI_CONVERSATION_FONT_SIZE_DEFAULT,
});
}
const themeSelectValue = computed(() => {
if (editTheme.value === "custom") {
return `custom:${editActiveCustomThemeId.value}`;
@@ -2805,6 +2875,10 @@ function setDdlOpenMode(value: unknown) {
if (value === "dialog" || value === "tab") editDdlOpenMode.value = value;
}
function setDoubleClickStringSelectionMode(value: unknown) {
if (value === "content" || value === "word") editDoubleClickStringSelectionMode.value = value;
}
function setIconTheme(value: DesktopIconTheme) {
editIconTheme.value = value;
}
@@ -3144,6 +3218,7 @@ async function revealSettingsSearchTarget(result: SettingsSearchEntry) {
async function selectSettingsSearchResult(result: SettingsSearchEntry) {
pendingSettingsSearchResult = result;
if (result.shortcutId) shortcutSearchQuery.value = result.title;
if (result.category === "ai") aiConfigListMode.value = "list";
applySettingsSearchRoute(result);
settingsSearchQuery.value = "";
settingsSearchOpen.value = false;
@@ -6077,7 +6152,7 @@ onUnmounted(() => {
</button>
</nav>
<div class="min-w-0 flex-1 overflow-visible pl-1 pr-0 flex flex-col">
<div class="min-w-0 min-h-0 flex-1 overflow-visible pl-1 pr-0 flex flex-col">
<div class="shrink-0 px-2 pt-1 pb-3">
<div ref="settingsSearchInputContainerRef" class="relative">
<Search class="pointer-events-none absolute top-1/2 left-4 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -6540,6 +6615,22 @@ onUnmounted(() => {
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-double-click-string">{{ t("settings.doubleClickStringSelectionMode") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.doubleClickStringSelectionModeDescription") }}</p>
</div>
<Select :model-value="editDoubleClickStringSelectionMode" @update:model-value="setDoubleClickStringSelectionMode">
<SelectTrigger id="editor-double-click-string" class="h-8 w-36 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">{{ t("settings.doubleClickStringSelectionModeContent") }}</SelectItem>
<SelectItem value="word">{{ t("settings.doubleClickStringSelectionModeWord") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-vim-mode">{{ t("settings.vimMode") }}</Label>
@@ -9241,6 +9332,80 @@ LIMIT 100;</pre
</div>
</div>
<div v-if="aiConfigListMode === 'list'" data-settings-search-id="ai-typography" class="space-y-3">
<Separator />
<div class="settings-item space-y-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-sm font-medium">{{ t("ai.conversationTypography") }}</h3>
<p class="text-xs text-muted-foreground">{{ t("ai.conversationTypographyDescription") }}</p>
</div>
<Button type="button" size="sm" variant="ghost" class="h-7 shrink-0 gap-1 px-2 text-xs" :disabled="aiTypographySaving || (editAiFontFamily === AI_CONVERSATION_FONT_FAMILY_DEFAULT && Number(editAiFontSize) === AI_CONVERSATION_FONT_SIZE_DEFAULT)" @click="resetAiTypography">
<RotateCcw class="h-3.5 w-3.5" />
{{ t("settings.reset") }}
</Button>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<div class="min-w-0 space-y-1.5">
<Label>{{ t("ai.conversationFontFamily") }}</Label>
<SearchableSelect
:model-value="editAiFontFamily"
:options="aiFontOptions"
:placeholder="t('ai.conversationFontFollowInterface')"
:search-placeholder="t('settings.searchFont')"
:empty-text="t('settings.noFontsFound')"
:loading-text="t('settings.loadingFonts')"
:disabled="aiTypographySaving"
allow-custom
clearable
:display-name="displayAiFontFamily"
:normalize-custom="normalizeAiConversationFontFamilyInput"
:trigger-class="appearanceFontSearchTriggerClass"
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
@update:model-value="onAiFontFamilyChange"
@update:open="(open: boolean) => open && loadSystemFontOptions()"
>
<template #trigger-label="{ label, loading }">
<span class="truncate" :style="editAiFontFamily ? { fontFamily: editAiFontFamily } : undefined">
{{ loading ? t("settings.loadingFonts") : label }}
</span>
</template>
<template #option-label="{ option, label }">
<span class="truncate" :style="fontOptionStyle(option, editAiFontFamily)">{{ label }}</span>
</template>
<template #custom-option-label="{ value }">
<span class="truncate" :style="{ fontFamily: value }">
{{ t("settings.useCustomFont", { font: readableFontFamily(value) }) }}
</span>
</template>
</SearchableSelect>
</div>
<div class="space-y-1.5">
<Label for="ai-conversation-font-size">{{ t("ai.conversationFontSize") }}</Label>
<div class="flex items-center gap-2">
<Input
id="ai-conversation-font-size"
v-model.number="editAiFontSize"
type="number"
:min="AI_CONVERSATION_FONT_SIZE_MIN"
:max="AI_CONVERSATION_FONT_SIZE_MAX"
step="1"
class="h-8 text-xs"
:disabled="aiTypographySaving"
@change="commitAiFontSize"
@keydown.enter="blurAiFontSizeInput"
/>
<span class="text-xs text-muted-foreground">px</span>
</div>
<p class="text-[11px] text-muted-foreground">
{{ t("ai.conversationFontSizeRange", { min: AI_CONVERSATION_FONT_SIZE_MIN, max: AI_CONVERSATION_FONT_SIZE_MAX }) }}
</p>
</div>
</div>
</div>
</div>
<!-- Agent Turn Limit (list mode, global) -->
<div v-if="aiConfigListMode === 'list'" class="space-y-3">
<Separator />
@@ -55,10 +55,11 @@ import { createColumnReferencePayload, tableReferenceInsertText } from "@/lib/ed
import { clearRememberedFocusedQueryEditorView, focusedQueryEditorView, queryEditorInsertContext, registerQueryEditorInsertContext, rememberFocusedQueryEditorView, unregisterQueryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
import { structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
import { parkEditorNativeSelection, type EditorNativeSelectionPark } from "@/lib/editor/queryEditorNativeSelection";
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
import QueryEditorContextMenu, { type QueryEditorContextMenuState, type QueryEditorContextMenuActions } from "./QueryEditorContextMenu.vue";
import { readTextFromClipboard } from "@/lib/common/clipboard";
import { clipboardLineEndings, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import { supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
@@ -109,10 +110,10 @@ import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboar
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { createSqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
import { shouldUseQueryEditorLargeDocumentMode } from "@/lib/editor/queryEditorLargeDocument";
import { shouldUseQueryEditorLargeDocumentMode, shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
@@ -192,6 +193,37 @@ const view = shallowRef<EditorViewType | null>(null);
const contextMenuOpen = ref(false);
let contextMenuPointerCleanup: (() => void) | null = null;
// The editor's own context menu is a DOM overlay, so a long selection stays
// live underneath it and macOS keeps asking the web view to serialize that
// selection while the pointer moves over the menu. Parking the browser
// selection for as long as the menu is up is what keeps right click usable on
// a few hundred selected lines; see queryEditorNativeSelection.ts.
let parkedNativeSelection: EditorNativeSelectionPark | null = null;
function parkNativeSelectionUnderContextMenu() {
const currentView = view.value;
if (!currentView) return;
parkedNativeSelection?.release();
// `clipboardLineEndings` is what the editor registers as its
// `clipboardOutputFilter`; passing it here keeps Cmd+C working while the
// selection is parked and leaves the copied bytes unchanged.
parkedNativeSelection = parkEditorNativeSelection(currentView, { finalizeClipboardText: clipboardLineEndings });
}
function releaseParkedNativeSelection() {
parkedNativeSelection?.release();
parkedNativeSelection = null;
}
watch(
contextMenuOpen,
(open) => {
if (open) parkNativeSelectionUnderContextMenu();
else releaseParkedNativeSelection();
},
{ flush: "sync" },
);
const executionViewportOwnership = createQueryEditorExecutionViewportOwnership();
let contextMenuDoc: Text | null = null;
@@ -438,8 +470,8 @@ interface EditorGestureEvent extends Event {
}
const codeMirrorRuntime = createQueryEditorCodeMirrorRuntime();
const largeDocumentMode = shouldUseQueryEditorLargeDocumentMode(props.modelValue);
const fullEditorFeaturesEnabled = () => !largeDocumentMode;
const largeDocumentMode = ref(shouldUseQueryEditorLargeDocumentMode(props.modelValue));
const boundedEditorAnalysisEnabled = () => largeDocumentMode.value;
let previewContextRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let editorIsActive = true;
@@ -450,7 +482,6 @@ const postCompositionKeyGuard = createQueryEditorPostCompositionKeyGuard();
let postCompositionKeyGuardCleanup: (() => void) | null = null;
function runStatementGutterExtension(): import("@codemirror/state").Extension {
if (!fullEditorFeaturesEnabled()) return [];
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
return shouldShowStatementGutter(showRunButtons) ? (codeMirrorRuntime.buildRunStatementGutterExtension?.() ?? []) : [];
}
@@ -462,6 +493,7 @@ const statementBoundaries = useQueryEditorStatementBoundaries({
view,
sqlStatementParameterOptions,
runtime: codeMirrorRuntime,
driverProfile: () => sqlDriverProfile.value,
cache: {
get value() {
return executableStatementRangeCache;
@@ -770,17 +802,21 @@ function schedulePreviewContextRefresh(currentView: EditorViewType) {
clearScheduledPreviewContextRefresh();
const expectedDoc = currentView.state.doc;
const expectedSelection = currentView.state.selection.main;
previewContextRefreshTimer = setTimeout(() => {
previewContextRefreshTimer = setTimeout(async () => {
previewContextRefreshTimer = null;
const currentSelection = currentView.state.selection.main;
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentSelection.from !== expectedSelection.from || currentSelection.to !== expectedSelection.to || !editorIsActive) return;
if (boundedEditorAnalysisEnabled()) {
const result = await statementBoundaries.ensureStatementCache(currentView.state);
if (!result || !editorIsActive || view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main !== currentSelection) return;
}
previewContextSql.value = resolvePreviewDmlCandidate();
emit("previewChangesAvailable", !!previewContextSql.value);
}, 120);
}
function selectStarExpansionTargetForView(currentView: EditorViewType, position?: number): SelectStarExpansionTarget | null {
if (!fullEditorFeaturesEnabled() || !props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
if (!props.connectionId || props.database == null || props.readOnly || !SEMANTIC_SQL_COMPLETION_ENABLED) return null;
const sql = currentEditorDocText(currentView);
const selection = currentView.state.selection.main;
@@ -867,11 +903,6 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
return;
}
if (!fullEditorFeaturesEnabled()) {
contextObjectTarget.value = null;
return;
}
const sql = currentEditorDocText(currentView);
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({
@@ -937,7 +968,6 @@ function exportQueryFromContextMenu(format: "csv" | "xlsx" | "txt") {
// 与「执行」使用同一套候选解析:选区优先,否则取 position(右键点击处)/ 光标处的单条语句。
// 注意:不跟随 executeAllOnBlankLine 回退到“整篇文档”(那会包含多条语句)。
function resolvePreviewDmlCandidate(position?: number): string {
if (!fullEditorFeaturesEnabled()) return "";
const currentView = view.value;
if (!currentView) return "";
const selection = currentView.state.selection.main;
@@ -946,8 +976,7 @@ function resolvePreviewDmlCandidate(position?: number): string {
return looksLikeDmlStatement(text) ? text : "";
}
const cursorPos = position ?? selection.head;
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
const cursorRange = executableStatementRangeAtCursor(executableStatementRangeCache, cursorPos);
const cursorRange = executableStatementRangeAtPosition(currentView, cursorPos);
return cursorRange && looksLikeDmlStatement(cursorRange.sql) ? cursorRange.sql : "";
}
@@ -1080,10 +1109,15 @@ function executableStatementRangeStartingAt(currentView: EditorViewType, lineFro
return executableStatementRangeStartingAtLine(executableStatementRangeCache, lineFrom);
}
function executableStatementRangeAtPosition(currentView: EditorViewType, position: number): SqlTextRange | null {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeAtCursor(executableStatementRangeCache, position);
}
function currentExecutableStatementRange(currentView: EditorViewType): SqlTextRange | null {
if (!supportsExecutionTargetPicker(props.databaseType) && props.databaseType !== "mongodb") return null;
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
return executableStatementRangeAtCursor(executableStatementRangeCache, currentView.state.selection.main.head);
if (boundedEditorAnalysisEnabled() && executableStatementRangeCache?.doc !== currentView.state.doc) return null;
return executableStatementRangeAtPosition(currentView, currentView.state.selection.main.head);
}
function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from: number; to: number }, event: Event): boolean {
@@ -1520,7 +1554,7 @@ const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnos
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
boundedAnalysisEnabled: boundedEditorAnalysisEnabled,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
@@ -1721,7 +1755,7 @@ function isEditorComposing(currentView: EditorViewType): boolean {
return imeCompositionActive || currentView.compositionStarted || currentView.composing;
}
function flushImeComposition() {
async function flushImeComposition() {
const currentView = view.value;
if (!currentView || !pendingImeModelEmit) return;
pendingImeModelEmit = false;
@@ -1735,7 +1769,8 @@ function flushImeComposition() {
documentState.recordSelection(currentView);
const fullDoc = currentView.state.doc.toString();
const position = currentView.state.selection.main.head;
if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
if (await shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
if (view.value !== currentView || currentView.state.selection.main.head !== position || isEditorComposing(currentView)) return;
scheduleSqlCompletionStart(currentView);
}
}
@@ -1750,7 +1785,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
view,
runtime: codeMirrorRuntime,
beforeLoad() {
if (fullEditorFeaturesEnabled()) hoverContent.initializeHighlighter();
hoverContent.initializeHighlighter();
},
async prepare(modules) {
const initializedRuntime = modules.runtime;
@@ -1807,7 +1842,7 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
currentExecutableStatementRange,
executeSqlStatementFromGutter,
signatureHelpWindowChars: SQL_SIGNATURE_HELP_WINDOW_CHARS,
fullFeaturesEnabled: fullEditorFeaturesEnabled,
boundedAnalysisEnabled: boundedEditorAnalysisEnabled,
});
const initialSettings = settingsStore.editorSettings;
const theme = await loadEditorTheme(initialSettings.theme, editorThemeAppearance(), getCurrentCustomThemeColors(), themePalette.value);
@@ -1815,6 +1850,9 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
await ensureCodeMirrorVim();
}
const { currentStatementFrameExtension, activeLineHighlighter } = sqlExtensions.createViewDecorations();
function updateLargeDocumentMode(currentView: EditorViewType) {
largeDocumentMode.value = shouldUseQueryEditorLargeDocumentModeForSize(currentView.state.doc.length, currentView.state.doc.lines);
}
const editorElement = editorRef.value;
if (!editorElement) return;
const tooltipParent = editorElement.closest<HTMLElement>("#root")?.querySelector<HTMLElement>("#dbx-query-editor-tooltip-root") ?? editorElement;
@@ -1841,23 +1879,22 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
initializedRuntime.runGutterComp.of(runStatementGutterExtension()),
initializedRuntime.lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
fullEditorFeaturesEnabled() ? currentStatementFrameExtension : [],
currentStatementFrameExtension,
highlightActiveLineGutter(),
highlightSpecialChars(),
initializedRuntime.historyResetComp.of(history()),
fullEditorFeaturesEnabled()
? foldGutter({
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
})
: [],
fullEditorFeaturesEnabled() ? sqlBlockFoldService : [],
foldGutter({
foldingChanged: (update) => update.transactions.some((transaction) => transaction.effects.some((effect) => !!codeMirrorRuntime.statementBoundariesRefreshEffect && effect.is(codeMirrorRuntime.statementBoundariesRefreshEffect))),
markerDOM(open: boolean) {
const span = document.createElement("span");
span.className = "cm-foldMarker-svg";
span.innerHTML = open
? '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return span;
},
}),
createSqlBlockFoldService(() => props.databaseType, statementBoundaries.foldRangeForState),
drawSelection(),
editorClipboardLineEndingsExtension(EditorView),
trimmedSelectionLayer(),
@@ -1872,12 +1909,13 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
dialect: sqlBehaviorDialect(),
language: queryEditorSelectionLanguage(),
composing: isEditorComposing(currentView),
selectStringContent: settingsStore.editorSettings.doubleClickStringSelectionMode !== "word",
}),
),
),
EditorState.allowMultipleSelections.of(true),
fullEditorFeaturesEnabled() ? indentOnInput() : [],
fullEditorFeaturesEnabled() ? syntaxHighlighting(defaultHighlightStyle, { fallback: true }) : [],
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
crosshairCursor(),
activeLineHighlighter,
// Vim must be mounted before DBX/default keymaps so normal-mode keys are handled first.
@@ -1887,14 +1925,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
fullEditorFeaturesEnabled()
? createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
})
: [],
createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
}),
tooltips({ parent: tooltipParent }),
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
@@ -1921,17 +1957,15 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
return true;
}),
),
fullEditorFeaturesEnabled() ? hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)) : [],
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
initializedRuntime.sqlSignatureComp.of(sqlExtensions.buildSqlSignatureExtension()),
initializedRuntime.diagnosticComp.of(sqlExtensions.buildSqlDiagnosticExtension()),
fullEditorFeaturesEnabled()
? createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
})
: [],
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && supportsInsertValueHints(props.databaseType),
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
getDialectId: () => resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() }),
}),
initializedRuntime.previewRangeComp.of(sqlExtensions.buildPreviewRangeExtension()),
sqlExtensions.buildResultSourceRangeExtension(),
Prec.highest(
@@ -1975,10 +2009,11 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
rememberFocusedQueryEditorView(update.view);
}
if (update.docChanged) {
completion.invalidateRequests();
updateLargeDocumentMode(update.view);
searchPanelRef.value?.scheduleDocumentSearchUpdate();
if (isEditorComposing(update.view)) {
pendingImeModelEmit = true;
completion.invalidateRequests();
} else {
emitModelValue(update.view);
invalidateSemanticDiagnosticsForDocumentChange();
@@ -2106,7 +2141,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
restoreEditorSelection(props.initialSelection, !props.initialViewport);
restoreEditorViewport();
performEditorReveal();
syncContextMenuState(view.value);
if (boundedEditorAnalysisEnabled()) {
syncEditorSelectionState(view.value);
schedulePreviewContextRefresh(view.value);
} else {
syncContextMenuState(view.value);
}
emit("previewChangesAvailable", !!previewContextSql.value);
syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily);
syncEditorDiagnosticCssVars();
@@ -2141,7 +2181,7 @@ onMounted(codeMirrorLifecycle.initialize);
// that would no longer be allowed under the new mode.
watch(
() => settingsStore.editorSettings.completionTriggerMode,
(newMode) => {
async (newMode) => {
if (!view.value || !codeMirrorRuntime.codeMirrorCompletionStatus || !codeMirrorRuntime.codeMirrorCloseCompletion) return;
const status = codeMirrorRuntime.codeMirrorCompletionStatus(view.value.state);
if (!status || completion.activeOrigin !== "typing") return;
@@ -2152,10 +2192,11 @@ watch(
}
// For other mode changes, re-evaluate the policy.
// If the current position would not trigger under the new mode, close.
const fullDoc = view.value.state.doc.toString();
const position = view.value.state.selection.main.head;
if (!shouldTriggerSqlCompletionForPosition(fullDoc, position)) {
codeMirrorRuntime.codeMirrorCloseCompletion(view.value);
const currentView = view.value;
const doc = currentView.state.doc;
const position = currentView.state.selection.main.head;
if (!(await shouldTriggerSqlCompletionForPosition(doc.toString(), position))) {
if (view.value === currentView && currentView.state.doc === doc && currentView.state.selection.main.head === position && settingsStore.editorSettings.completionTriggerMode === newMode) codeMirrorRuntime.codeMirrorCloseCompletion(currentView);
}
},
);
@@ -2514,6 +2555,8 @@ function pauseQueryEditorBackgroundWork() {
clearScheduledSemanticDiagnostics();
completion.invalidateRequests();
unregisterTableReferenceDropListener();
completionMetadata.disposeCompletionAnalysis();
statementBoundaries.clear();
}
function resumeQueryEditorBackgroundWork() {
@@ -2555,6 +2598,7 @@ onBeforeUnmount(() => {
pointerInteractions.dispose();
objectNavigation.dispose();
contextMenuPointerCleanup?.();
releaseParkedNativeSelection();
postCompositionKeyGuardCleanup?.();
postCompositionKeyGuardCleanup = null;
batchSelection.dispose();
@@ -16,13 +16,30 @@ import { useConnectionStore } from "@/stores/connectionStore";
import type { ConnectionConfig } from "@/types/database";
import type { PluginAiRecommendationHostUpdate } from "@/lib/plugins/pluginHostBridge";
const aiAssistantMountApi = vi.hoisted(() => ({
conversations: [] as Array<Record<string, unknown>>,
runAgentStream: undefined as undefined | ((onEvent: (event: { type: string; delta?: string }) => void) => Promise<string>),
}));
vi.mock("@/lib/ai/ai", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
runAgentStream: async (...args: unknown[]) => {
const onEvent = args[2] as (event: { type: string; delta?: string }) => void;
return aiAssistantMountApi.runAgentStream?.(onEvent) ?? "";
},
};
});
vi.mock("@/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const empty = () => Promise.resolve([]);
return {
...actual,
loadAiConversations: empty,
loadAiConversations: () => Promise.resolve(aiAssistantMountApi.conversations),
loadAiRuns: empty,
saveAiConversation: () => Promise.resolve(),
readUserSkills: empty,
loadAiConfigs: empty,
listPlugins: empty,
@@ -36,6 +53,8 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
const cleanups: Array<() => void> = [];
afterEach(() => {
aiAssistantMountApi.conversations = [];
aiAssistantMountApi.runAgentStream = undefined;
while (cleanups.length) cleanups.pop()?.();
});
@@ -64,6 +83,104 @@ async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig
}
describe("AiAssistant mount", () => {
it("applies custom typography to restored message content and the prompt only", async () => {
aiAssistantMountApi.conversations = [
{
id: "typography-conversation",
title: "Typography",
connectionName: "",
connectionId: "",
database: "",
messages: [
{ role: "user", content: "User message" },
{ role: "assistant", content: "### Heading\n\nInline `value`.\n\n```sql\nSELECT 1\n```" },
],
createdAt: "2026-09-27T00:00:00.000Z",
updatedAt: "2026-09-27T00:00:00.000Z",
},
];
const { container, errors } = await mountPanel(true, undefined, (settings) => {
settings.restoreLastConversation = true;
settings.editorSettings.aiFontFamily = "Georgia, serif";
settings.editorSettings.aiFontSize = 18;
});
const root = container.querySelector<HTMLElement>("[data-ai-assistant-root]");
expect(root?.style.getPropertyValue("--dbx-ai-content-font-family")).toBe("Georgia, serif");
expect(root?.style.getPropertyValue("--dbx-ai-content-font-size")).toBe("18px");
expect(root?.style.getPropertyValue("--dbx-ai-code-font-size")).toBe("18px");
expect(container.querySelector("[data-ai-user-message-content]")?.closest(".ai-conversation-text")).not.toBeNull();
expect(container.querySelector("[data-ai-assistant-message-content]")?.classList.contains("ai-conversation-text")).toBe(true);
expect(container.querySelector(".ai-markdown code")).not.toBeNull();
expect(container.querySelector(".ai-code-block")).not.toBeNull();
expect(container.querySelector("textarea.ai-conversation-text")).not.toBeNull();
expect(container.firstElementChild?.firstElementChild?.classList.contains("ai-conversation-text")).toBe(false);
expect(errors.map(String)).toEqual([]);
});
it("keeps the same typography scope while an answer streams and after it completes", async () => {
let finishStream!: () => void;
const streamPending = new Promise<void>((resolve) => {
finishStream = resolve;
});
aiAssistantMountApi.runAgentStream = async (onEvent) => {
onEvent({ type: "text_delta", delta: "**Streaming answer**" });
await streamPending;
onEvent({ type: "agent_end" });
return "Streaming answer";
};
const recommendation = {
pluginId: "sample.plugin",
pluginName: "Sample",
contributionId: "workbench",
workbenchId: "cluster",
context: { connectionId: "plugin-connection" },
items: [{ id: "stream", label: "Stream answer", prompt: "Stream answer" }],
};
const { container, errors } = await mountPanel(
true,
{ id: "plugin-connection", name: "Sample", db_type: "plugin", plugin_id: "sample.plugin", host: "localhost", port: 22, username: "", password: "" },
(settings) => {
settings.aiConfigs = [
{
id: "custom",
name: "Custom",
provider: "openai-compatible",
apiKey: "test-key",
authMethod: "api-key",
endpoint: "https://example.com/v1",
model: "test-model",
apiStyle: "completions",
isDefault: true,
},
];
settings.activeModel = { configId: "custom", modelId: "test-model" };
settings.editorSettings.aiFontFamily = "Georgia, serif";
settings.editorSettings.aiFontSize = 18;
},
recommendation,
);
container.querySelector<HTMLButtonElement>('button[title="Stream answer"]')!.click();
await new Promise((resolve) => setTimeout(resolve, 30));
const streamingContent = container.querySelector<HTMLElement>("[data-ai-assistant-message-content]");
expect(streamingContent?.classList.contains("ai-conversation-text")).toBe(true);
expect(streamingContent?.textContent).toContain("Streaming answer");
expect(container.querySelector("[data-ai-generation-status]")).not.toBeNull();
finishStream();
await new Promise((resolve) => setTimeout(resolve, 30));
const completedContent = container.querySelector<HTMLElement>("[data-ai-assistant-message-content]");
expect(completedContent?.classList.contains("ai-conversation-text")).toBe(true);
expect(completedContent?.textContent).toContain("Streaming answer");
expect(container.querySelector("[data-ai-generation-status]")).toBeNull();
expect(errors.map(String)).toEqual([]);
});
it("keeps the real Agent mode and an interactive picker after clicking a plugin recommendation", async () => {
const { container, errors } = await mountPanel(
true,
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import assistantSource from "../AiAssistant.vue?raw";
describe("AiAssistant typography scope", () => {
it("uses one scoped content wrapper for streaming and completed Markdown", () => {
expect(assistantSource).toContain("messageRenderer.value.render(msg.content, { streaming: isStreamingMessage(msg) })");
expect(assistantSource).toContain('data-ai-assistant-message-content class="ai-conversation-text');
expect(assistantSource).toContain('data-ai-user-message-content class="whitespace-pre-wrap"');
});
it("scopes typography to message and input text while preserving monospace code", () => {
expect(assistantSource).toContain('data-ai-assistant-root class="flex h-full min-h-0 flex-col overflow-hidden" :style="aiTypographyStyle"');
expect(assistantSource).toContain('data-edit-textarea\n v-model="editingContent"');
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none rounded-lg');
expect(assistantSource).toContain('class="ai-conversation-text w-full resize-none bg-transparent');
expect(assistantSource).toMatch(/\.ai-markdown :deep\(code\)[\s\S]*font-family: ui-monospace, monospace;/);
expect(assistantSource).toMatch(/\.ai-code-block \{[\s\S]*font-family: ui-monospace, monospace;/);
expect(assistantSource).not.toContain("document.body.style.fontFamily");
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_SEARCH_DEFINITIONS } from "@/lib/settings/settingsSearch";
import dialogSource from "../EditorSettingsDialog.vue?raw";
describe("EditorSettingsDialog AI typography settings", () => {
it("exposes the family and size controls through settings search", () => {
const definitions = SETTINGS_SEARCH_DEFINITIONS.filter((definition) => definition.id.startsWith("ai-conversation-font-"));
expect(definitions).toEqual([expect.objectContaining({ id: "ai-conversation-font-family", category: "ai", targetId: "ai-typography" }), expect.objectContaining({ id: "ai-conversation-font-size", category: "ai", targetId: "ai-typography" })]);
expect(dialogSource).toContain('data-settings-search-id="ai-typography"');
expect(dialogSource).toContain('id="ai-conversation-font-size"');
});
it("persists each control immediately and offers a dedicated reset", () => {
expect(dialogSource).toContain('@update:model-value="onAiFontFamilyChange"');
expect(dialogSource).toContain('@change="commitAiFontSize"');
expect(dialogSource).toContain('@click="resetAiTypography"');
expect(dialogSource).toContain("settingsStore.updateEditorSettingsAndPersist(partial)");
});
it("keeps system and custom conversation fonts independent from editor fallbacks", () => {
expect(dialogSource).toContain("systemFonts.value.map(aiConversationFontFamilyForName)");
expect(dialogSource).toContain(':normalize-custom="normalizeAiConversationFontFamilyInput"');
});
});
@@ -6,12 +6,15 @@ import { createI18n } from "vue-i18n";
import { language } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH } from "@/lib/editor/queryEditorLargeDocument";
import { QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH, QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT } from "@/lib/editor/queryEditorLargeDocument";
import type { QueryEditorProps } from "../queryEditorTypes";
const analysisProbe = vi.hoisted(() => ({
documentLengths: [] as number[],
documentLineCounts: [] as number[],
backgroundDocuments: [] as string[],
failAtLength: Number.POSITIVE_INFINITY,
failAtLineCount: Number.POSITIVE_INFINITY,
}));
vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
@@ -19,13 +22,41 @@ vi.mock("@/lib/sql/executableStatementRangeCache", async (importOriginal) => {
return {
...actual,
executableStatementRangeCacheForDoc: (...args: Parameters<typeof actual.executableStatementRangeCacheForDoc>) => {
if (args[0]?.doc === args[1]) return actual.executableStatementRangeCacheForDoc(...args);
analysisProbe.documentLengths.push(args[1].length);
if (args[1].length >= analysisProbe.failAtLength) throw new Error("unbounded statement analysis");
analysisProbe.documentLineCounts.push(args[1].lines);
if (args[1].length >= analysisProbe.failAtLength || args[1].lines >= analysisProbe.failAtLineCount) throw new Error("unbounded statement analysis");
return actual.executableStatementRangeCacheForDoc(...args);
},
};
});
vi.mock("@/lib/sql/sqlStatementAnalysisWorker", async () => {
const actual = await vi.importActual<typeof import("@/lib/sql/executableStatementRangeCache")>("@/lib/sql/executableStatementRangeCache");
const { Text } = await import("@codemirror/state");
return {
createSqlStatementAnalysisWorker: () => {
let generation = 0;
return {
async analyze(request: import("@/lib/sql/sqlStatementAnalysis").SqlStatementAnalysisRequest) {
const current = ++generation;
analysisProbe.backgroundDocuments.push(request.sql);
await Promise.resolve();
if (current !== generation) return null;
const { doc: _doc, ...result } = actual.executableStatementRangeCacheForDoc(null, Text.of(request.sql.split("\n")), request.databaseType, request.parameterOptions);
return result;
},
cancel() {
generation++;
},
dispose() {
generation++;
},
};
},
};
});
import QueryEditor from "../QueryEditor.vue";
function oraclePackage(procedureCount: number): string {
@@ -50,7 +81,10 @@ const cleanups: Array<() => void> = [];
beforeEach(() => {
analysisProbe.documentLengths = [];
analysisProbe.documentLineCounts = [];
analysisProbe.backgroundDocuments = [];
analysisProbe.failAtLength = Number.POSITIVE_INFINITY;
analysisProbe.failAtLineCount = Number.POSITIVE_INFINITY;
});
afterEach(() => {
@@ -103,6 +137,40 @@ describe("QueryEditor large document mode", () => {
expect(view.state.doc.toString()).toBe(source);
});
it("keeps SQL language features while bounding statement analysis above the line budget", async () => {
const lineCount = QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1;
const source = Array.from({ length: lineCount }, (_, index) => `SELECT ${index};`).join("\n");
expect(source.length).toBeLessThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
analysisProbe.failAtLineCount = lineCount;
const { host, view } = await mountEditor(source);
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLineCounts).not.toContain(lineCount);
expect(view.state.facet(language)).not.toBeNull();
expect(analysisProbe.backgroundDocuments).toContain(source);
});
it("enters bounded mode when typing crosses the line budget", async () => {
const source = Array.from({ length: QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT }, (_, index) => `SELECT ${index};`).join("\n");
const { host, view } = await mountEditor(source);
analysisProbe.documentLengths = [];
analysisProbe.documentLineCounts = [];
view.dispatch({
changes: { from: view.state.doc.length, insert: "\nS" },
selection: { anchor: view.state.doc.length + 2 },
userEvent: "input.type",
});
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 250));
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(view.state.doc.lines).toBe(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1);
expect(view.state.facet(language)).not.toBeNull();
expect(analysisProbe.documentLineCounts).not.toContain(QUERY_EDITOR_FULL_FEATURE_MAX_LINE_COUNT + 1);
});
it("keeps a realistically large package editable and searchable without full-document analysis", async () => {
const source = oraclePackage(4_000);
expect(source.length).toBeGreaterThan(QUERY_EDITOR_FULL_FEATURE_MAX_DOCUMENT_LENGTH);
@@ -112,7 +180,7 @@ describe("QueryEditor large document mode", () => {
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
expect(analysisProbe.documentLengths).not.toContain(source.length);
expect(view.state.facet(language)).toBeNull();
expect(view.state.facet(language)).not.toBeNull();
expect(view.state.doc.toString()).toBe(source);
expect(view.state.readOnly).toBe(false);
expect(view.state.sliceDoc(0, 24)).toBe(source.slice(0, 24));
@@ -137,4 +205,18 @@ describe("QueryEditor large document mode", () => {
expect(view.state.readOnly).toBe(true);
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
});
it("returns to the ordinary path after shrinking and preserves undo", async () => {
const source = Array.from({ length: 2_001 }, (_, index) => `SELECT ${index};`).join("\n");
const { host, view } = await mountEditor(source);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "SELECT 1;" }, userEvent: "input" });
await nextTick();
expect(host.firstElementChild?.hasAttribute("data-large-document-mode")).toBe(false);
expect(view.state.facet(language)).not.toBeNull();
const { undo } = await import("@codemirror/commands");
expect(undo(view)).toBe(true);
await nextTick();
expect(view.state.doc.toString()).toBe(source);
expect(host.firstElementChild?.getAttribute("data-large-document-mode")).toBe("true");
});
});
@@ -10,6 +10,7 @@ import { useQueryEditorCompletionMetadata } from "../useQueryEditorCompletionMet
import type { QueryEditorProps } from "../queryEditorTypes";
import type { RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
vi.mock("@/stores/connectionStore", () => ({ COMPLETION_METADATA_CONCURRENCY: 4 }));
vi.mock("@/lib/backend/api", () => ({}));
@@ -31,7 +32,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function createHarness(overrides: Partial<QueryEditorProps> = {}) {
function createHarness(overrides: Partial<QueryEditorProps> = {}, configureMetadata?: (metadata: ReturnType<typeof useQueryEditorCompletionMetadata>) => void) {
const props = reactive<QueryEditorProps>({ modelValue: "SEL", databaseType: "mysql", dialect: "mysql", connectionId: "connection", database: "demo", ...overrides });
const parent = document.createElement("div");
document.body.append(parent);
@@ -65,6 +66,7 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
const connectionStore = store as unknown as Options["connectionStore"];
const settings = reactive({ editorSettings: { completionTriggerMode: "positional", snippets: [], sqlFormatter: { keywordCase: "upper", functionCase: "upper" }, autoAliasTables: false, tableCompletionSchemaQualification: "collision", generateSqlQuoteIdentifiers: false } });
const metadata = useQueryEditorCompletionMetadata({ props, view, connectionStore, sqlBehaviorDialect: () => props.dialect, remoteLatencyBudgetMs: 40, maxCompletionTables: 100, onDemandMinPrefix: 2, semanticCompletionEnabled: false });
configureMetadata?.(metadata);
const startCompletion = vi.fn(() => true);
const runtime: Options["runtime"] = { codeMirrorStartCompletion: startCompletion, codeMirrorInsertCompletionText: insertCompletionText, codeMirrorSnippetCompletion: snippetCompletion, codeMirrorCompletionStatus: () => null, imeCompositionActive: false };
const batchSelection = {
@@ -102,6 +104,40 @@ function createHarness(overrides: Partial<QueryEditorProps> = {}) {
}
describe("QueryEditor completion provider ownership", () => {
it.each(["cursor", "document", "view", "composition", "mode"])("rejects asynchronous analysis after a %s change", async (change) => {
let resolve!: (result: SqlCompletionAnalysisResult) => void;
const pending = new Promise<SqlCompletionAnalysisResult>((done) => {
resolve = done;
});
const { currentView, view, runtime, settings, completion, startCompletion } = createHarness({}, (metadata) => {
vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending);
});
completion.scheduleDeferredCompletionTrigger(currentView, "L", "");
await vi.advanceTimersByTimeAsync(25);
expect(startCompletion).not.toHaveBeenCalled();
if (change === "cursor") currentView.dispatch({ selection: { anchor: 0 } });
if (change === "document") currentView.dispatch({ changes: { from: 0, insert: " " } });
if (change === "view") view.value = null;
if (change === "composition") runtime.imeCompositionActive = true;
if (change === "mode") settings.editorSettings.completionTriggerMode = "manual";
resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false }));
await vi.advanceTimersByTimeAsync(50);
expect(startCompletion).not.toHaveBeenCalled();
});
it("discards a provider result invalidated while background analysis is running", async () => {
let resolve!: (result: SqlCompletionAnalysisResult) => void;
const pending = new Promise<SqlCompletionAnalysisResult>((done) => {
resolve = done;
});
const { provide, completion } = createHarness({}, (metadata) => {
vi.spyOn(metadata, "getEditorSqlCompletionAnalysis").mockReturnValue(pending);
});
const result = provide();
completion.invalidateRequests();
resolve(analyzeSqlCompletion({ sql: "SEL", cursor: 3, databaseType: "mysql", semanticCompletionEnabled: false }));
await expect(result).resolves.toBeNull();
});
it("keeps manual completion available while automatic completion is disabled", async () => {
const { settings, provide, completion, currentView, startCompletion } = createHarness({ database: undefined });
settings.editorSettings.completionTriggerMode = "manual";
@@ -20,7 +20,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
function createHarness() {
function createHarness(doc = "SELECT 1;\nSELECT 2;") {
const view = shallowRef<EditorView | null>(null);
const cache = { value: null as ExecutableStatementRangeCache | null };
const runtime = createQueryEditorCodeMirrorRuntime();
@@ -34,7 +34,7 @@ function createHarness() {
});
const parent = document.createElement("div");
document.body.append(parent);
const currentView = new EditorView({ parent, state: EditorState.create({ doc: "SELECT 1;\nSELECT 2;", extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) });
const currentView = new EditorView({ parent, state: EditorState.create({ doc, extensions: [boundaries.createTrackingPlugin(ViewPlugin)] }) });
view.value = currentView;
cleanups.push(() => {
currentView.destroy();
@@ -87,6 +87,19 @@ describe("QueryEditor shared statement boundaries", () => {
expect(boundaries.statementBoundariesForState(currentView.state).doc).toBe(currentView.state.doc);
});
it("clears a pending refresh when the editor is paused", async () => {
const { currentView, cache, boundaries } = createHarness();
boundaries.statementBoundariesForState(currentView.state);
currentView.dispatch({ changes: { from: 0, insert: " " } });
boundaries.clear();
const dispatch = vi.spyOn(currentView, "dispatch");
await vi.advanceTimersByTimeAsync(200);
expect(cache.value).toBeNull();
expect(dispatch).not.toHaveBeenCalled();
});
it.each(["destroyed", "replaced", "detached"])("does not refresh an editor that was %s", async (reason) => {
const { currentView, view, parent, boundaries, cache } = createHarness();
boundaries.statementBoundariesForState(currentView.state);
@@ -98,6 +111,33 @@ describe("QueryEditor shared statement boundaries", () => {
const dispatch = vi.spyOn(currentView, "dispatch");
await vi.advanceTimersByTimeAsync(200);
expect(dispatch).not.toHaveBeenCalled();
expect(cache.value).toBe(previousCache);
expect(cache.value).toBe(reason === "destroyed" ? null : previousCache);
});
it("maps large-document folds while an exact background rebuild is pending", async () => {
const source = `BEGIN\n${"SELECT 1;\n".repeat(2_001)}END;`;
const { currentView, boundaries } = createHarness(source);
const initial = boundaries.statementBoundariesForState(currentView.state);
expect(initial.fresh).toBe(false);
await boundaries.ensureStatementCache(currentView.state);
const original = boundaries.foldRangeForState(currentView.state, 0);
expect(original).toEqual({ from: 5, to: source.indexOf("END;") });
currentView.dispatch({ changes: { from: 0, insert: "-- prefix\n" } });
expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(false);
expect(boundaries.foldRangeForState(currentView.state, 10)).toEqual({ from: original!.from + 10, to: original!.to + 10 });
await vi.advanceTimersByTimeAsync(150);
expect(boundaries.statementBoundariesForState(currentView.state).fresh).toBe(true);
});
it("never installs a background result for an edited or paused document", async () => {
const { currentView, boundaries, cache } = createHarness("SELECT 1;\n".repeat(2_001));
const pending = boundaries.ensureStatementCache(currentView.state);
currentView.dispatch({ changes: { from: 0, insert: " " } });
await expect(pending).resolves.toBeNull();
expect(cache.value).toBeNull();
const paused = boundaries.ensureStatementCache(currentView.state);
boundaries.clear();
await expect(paused).resolves.toBeNull();
expect(cache.value).toBeNull();
});
});
@@ -1,4 +1,5 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import { syntaxTreeAvailable } from "@codemirror/language";
import { type QueryCompletionOption } from "./useQueryEditorBatchSelection";
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, statementGutterStartIndexHasStartAt } from "@/lib/sql/executableStatementRangeCache";
@@ -50,11 +51,11 @@ interface QueryEditorSqlExtensionsOptions {
currentExecutableStatementRange: (view: EditorViewType) => SqlTextRange | null;
executeSqlStatementFromGutter: (view: EditorViewType, line: { from: number; to: number }, event: Event) => boolean;
signatureHelpWindowChars: number;
fullFeaturesEnabled: () => boolean;
boundedAnalysisEnabled: () => boolean;
}
export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensionsOptions) {
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, fullFeaturesEnabled } = options;
const { props, runtime: codeMirrorRuntime, settingsStore, t, sqlBehaviorDialect, sqlDriverProfile, sqlStatementParameterOptions, queryEditorSelectionLanguage, cache, batchSelection, currentExecutableStatementRange, executeSqlStatementFromGutter, boundedAnalysisEnabled } = options;
const { EditorView, Decoration, StateField, StateEffect, GutterMarker, RangeSet, lineNumberMarkers, gutter, showTooltip, autocompletion, ViewPlugin, highlightingFor, syntaxTree, langSql, Prec, EditorState, ensureSyntaxTree, layer, RectangleMarker } = options.modules;
const { provideSqlCompletions } = options.completion;
const { renderBatchColumnSelectionActionMarker, renderBatchColumnSelectionCheckbox } = batchSelection;
@@ -74,7 +75,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
});
codeMirrorRuntime.buildSqlDiagnosticExtension = () => {
if (!fullFeaturesEnabled()) return [];
const diagnosticEffect = codeMirrorRuntime.setSqlDiagnosticsEffect;
const buildDecorations = (state: import("@codemirror/state").EditorState) => {
const errorDecorations = sqlErrorDecorationRange(state).map((range) =>
@@ -243,7 +243,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
codeMirrorRuntime.setStatementExecutionMarkersEffect = StateEffect.define<StatementExecutionMarker[]>();
codeMirrorRuntime.buildRunStatementGutterExtension = () => {
if (!fullFeaturesEnabled()) return [];
const effectType = codeMirrorRuntime.setStatementExecutionMarkersEffect!;
const showRunButtons = !props.hideExecutionControls && settingsStore.editorSettings.showStatementRunButtons;
const markersForState = (state: import("@codemirror/state").EditorState, markers: readonly StatementExecutionMarker[]) => {
@@ -294,7 +293,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
};
codeMirrorRuntime.buildSqlSignatureExtension = () => {
if (!fullFeaturesEnabled()) return [];
return showTooltip.compute(["doc", "selection"], (currentState) => {
const cursor = currentState.selection.main.head;
// Signature detection only scans backward from the cursor, so window the
@@ -313,7 +311,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
};
codeMirrorRuntime.buildSqlCompletionExtension = () => {
if (!fullFeaturesEnabled()) return [];
return autocompletion({
activateOnTyping: true,
defaultKeymap: false,
@@ -333,11 +330,9 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
const shellLineCommentHighlightPlugin = createShellLineCommentHighlight({ ViewPlugin, Decoration, highlightingFor, syntaxTree });
codeMirrorRuntime.buildSqlLanguageExtension = () => [
fullFeaturesEnabled()
? langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
})
: [],
langSql.sql({
dialect: createDbxCodeMirrorSqlDialect(langSql, props.syntaxDialect ?? props.dialect, props.databaseType, sqlDriverProfile.value),
}),
// Non-SQL editors (MongoDB shell) keep the SQL grammar for highlighting, so override the
// comment marker that toggleLineComment reads from language data.
Prec.highest(EditorState.languageData.of(() => [{ commentTokens: queryEditorCommentTokens(props.databaseType) }])),
@@ -355,7 +350,6 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
const refreshSqlSemanticHighlightEffect = StateEffect.define<null>();
codeMirrorRuntime.buildSqlSemanticHighlightExtension = () => {
if (!fullFeaturesEnabled()) return [];
return [
createSqlAliasHighlights({ databaseType: props.databaseType, dialect: sqlBehaviorDialect(), enabled: queryEditorSelectionLanguage() === "sql" }),
ViewPlugin.fromClass(
@@ -422,7 +416,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
}
const sql = this.cachedSql;
const shouldPrewarmFullDocument = this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
const shouldPrewarmFullDocument = !boundedAnalysisEnabled() && this.cachedWindows.length === 0 && this.prewarmedDoc !== doc && sql.length <= MAX_FULL_DOCUMENT_SQL_SEMANTIC_HIGHLIGHT_LENGTH;
const windows: Array<{
from: number;
to: number;
@@ -451,7 +445,7 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
if (pendingWindows.length > 0) {
const requestedTo = Math.max(...pendingWindows.map((window) => window.to));
const tree = ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
const tree = boundedAnalysisEnabled() ? (syntaxTreeAvailable(currentView.state, requestedTo) ? syntaxTree(currentView.state) : null) : ensureSyntaxTree(currentView.state, requestedTo, requestedTo === sql.length ? 250 : 25);
if (!tree) {
// The Lezer parse has not reached the pending windows yet (long
// documents). Keep the decorations that are still valid and let
@@ -508,12 +502,13 @@ export function configureQueryEditorSqlExtensions(options: QueryEditorSqlExtensi
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
const cursorPos = view.state.selection.main.head;
const boundaries = statementBoundariesForState(view.state);
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
if (!boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
// Typing in progress: reuse the ChangeSet-shifted range instead of
// re-parsing the whole document. The debounced refresh rebuilds and
// repaints the exact frame once typing pauses.
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
}
if (boundedAnalysisEnabled() && !boundaries.fresh) return null;
let range = currentExecutableStatementRange(view);
if (!range) {
const cursorLine = view.state.doc.lineAt(cursorPos);
@@ -9,9 +9,7 @@ import {
buildSqlCompletionItemsFromContext,
buildPostgresSequenceLiteralCompletionItems,
getSqlCompletionContext,
getPostgresSequenceLiteralCompletionContext,
getSqlCompletionResultValidFor,
isSqlCompletionSuppressedContext,
isSqlLikeCompletionStatement,
prepareSqlCompletionReplacement,
recordCompletionSelection,
@@ -21,8 +19,6 @@ import {
} from "@/lib/sql/sqlCompletion";
import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy";
import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfileExtensions";
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion";
import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
@@ -33,7 +29,6 @@ import {
resolveSqlCompletionSchemaLookupDatabase,
resolveSqlCompletionScope,
resolveSqlCompletionTableLookupTarget,
resolveSqlServerUseDatabaseCompletion,
sqlServerUseCompletionDatabaseNames,
sqlServerUseDatabaseBeforeCursor,
} from "@/lib/sql/sqlCompletionLookupTarget";
@@ -42,7 +37,7 @@ import { completionReplacementTo, shouldResolveSqlColumnCompletion } from "@/lib
import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { supportsDatabaseNameCompletion } from "@/lib/database/databaseFeatureSupport";
import { sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
import { oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
import { isOracleCompletionDatabase } from "@/lib/sql/oracleCompletionSession";
import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument } from "@/lib/redis/redisCompletion";
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject } from "@/lib/sql/sqlCompletion";
@@ -87,6 +82,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
supportsDatabaseSchemaQualifierCompletion,
sqlCompletionDialectOptions,
getEditorSqlCompletionContext,
getEditorSqlCompletionAnalysis,
cancelEditorSqlCompletionAnalysis,
supportsDatabaseQualifierCompletion,
completionObjectsForScope,
usesOracleSessionCompletionColumns,
@@ -119,7 +116,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
const COMPLETION_TRIGGER_DEFER_DELAY_MS = options.triggerDeferDelayMs;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = options.onDemandTableLimit;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
let analyzedCtes: { sql: string; definitions: ReturnType<typeof extractCteDefinitions> } | null = null;
const snippetDatabaseType = computed(() => {
const connection = props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined;
@@ -573,7 +570,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
if (props.database && mongoCompletionNeedsCollections(completionContext.mode)) {
try {
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, props.database);
collections = await connectionStore.listMongoCompletionCollections(props.connectionId, completionContext.database ?? props.database);
} catch {
collections = [];
}
@@ -581,7 +578,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
if (props.database && mongoCompletionNeedsFields(completionContext.mode) && completionContext.collection) {
try {
fields = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, completionContext.collection);
fields = await connectionStore.listMongoCompletionFields(props.connectionId, completionContext.database ?? props.database, completionContext.collection);
} catch {
fields = [];
}
@@ -679,26 +676,26 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
return provideSoqlCompletions(currentState, position, explicit);
}
const hasDatabase = props.database != null;
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
const epoch = ++completionEpoch;
context.addEventListener(
"abort",
() => {
if (epoch !== completionEpoch) return;
completionEpoch++;
cancelEditorSqlCompletionAnalysis();
},
{ onDocChange: true },
);
try {
// 1. Suppressed context (comment / string literal) rejects everything, including explicit.
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: currentState }) && !sequenceLiteralContext) return null;
// 2. Determine completion origin (session-level marker).
activeCompletionOrigin = originForSqlCompletionProvider(activeCompletionOrigin, context.explicit);
const origin = activeCompletionOrigin;
// 3. Explicit (manual shortcut) -> always proceed. No mode gating.
// 4. For typing sessions, apply mode gating with lazy fact computation.
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
sql: fullDoc,
cursor: position,
databaseType: props.databaseType,
});
if (origin !== "explicit" && settingsStore.editorSettings.completionTriggerMode === "manual") return null;
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentState);
if (!analysis || context.aborted || epoch !== completionEpoch) return null;
analyzedCtes = { sql: fullDoc, definitions: analysis.cteDefinitions };
const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis;
if (analysis.suppressed && !sequenceLiteralContext) return null;
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (origin !== "explicit") {
@@ -709,7 +706,8 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
// require-prefix: only compute local facts (no positionalEligible).
if (mode === "require-prefix") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext;
if (!ctx || epoch !== completionEpoch) return null;
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin,
@@ -722,9 +720,10 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
// positional: compute positionalEligible (lazy).
if (mode === "positional") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? analysis?.completionContext;
if (!ctx || epoch !== completionEpoch) return null;
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const positionalEligible = !!databaseLinkContext || !!sequenceLiteralContext || !!analysis?.positionalEligible;
const facts: SqlCompletionTriggerFacts = {
origin,
hasIdentifierPrefix: ctx.prefix.length > 0,
@@ -792,9 +791,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
return buildCompletionResult(buildPostgresSequenceLiteralCompletionItems(sequenceLiteralContext, sequences), sequenceLiteralContext.from, undefined, sequenceLiteralContext.prefix);
}
const legacyCompletionContext = getEditorSqlCompletionContext(fullDoc, position);
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(fullDoc, position, sqlCompletionDialectOptions()) : null;
let completionContext = semanticModel ? sqlCompletionContextFromSemantic(semanticModel, legacyCompletionContext) : legacyCompletionContext;
let completionContext = analysis.completionContext;
if (!hasDatabase) {
const items = buildSqlCompletionItemsFromContext(completionContext, {
@@ -960,10 +957,11 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
clearDeferredCompletionTrigger();
const expectedDoc = currentView.state.doc;
const expectedPosition = currentView.state.selection.main.head;
deferredCompletionTriggerTimer = setTimeout(() => {
deferredCompletionTriggerTimer = setTimeout(async () => {
deferredCompletionTriggerTimer = null;
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return;
if (shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) {
if (await shouldStartSqlCompletionAfterInput(insertedText, removedText, currentView)) {
if (view.value !== currentView || currentView.state.doc !== expectedDoc || currentView.state.selection.main.head !== expectedPosition || isEditorComposing(currentView)) return;
scheduleSqlCompletionStart(currentView);
}
}, COMPLETION_TRIGGER_DEFER_DELAY_MS);
@@ -973,47 +971,27 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
* Returns true when the current SQL position should trigger completion under the active trigger mode.
* Used by flushImeComposition and shouldStartSqlCompletionAfterInput.
*/
function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean {
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
if (isSqlCompletionSuppressedContext(fullDoc, position, { databaseType: props.databaseType, editorState: view.value?.state }) && !sequenceLiteralContext) return false;
async function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): Promise<boolean> {
const mode = settingsStore.editorSettings.completionTriggerMode;
if (mode === "manual") return false;
const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({
sql: fullDoc,
cursor: position,
databaseType: props.databaseType,
});
const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null;
if (mode === "require-prefix") {
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const prevChar = fullDoc[position - 1] ?? "";
const facts: SqlCompletionTriggerFacts = {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
};
return shouldAllowSqlCompletionTrigger(mode, facts);
}
// positional
const ctx = databaseLinkContext ?? sequenceLiteralContext ?? getEditorSqlCompletionContext(fullDoc, position);
const currentView = view.value;
const expectedDoc = currentView?.state.doc;
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView?.state);
if (!analysis || view.value !== currentView || currentView?.state.doc !== expectedDoc || settingsStore.editorSettings.completionTriggerMode !== mode) return false;
const { sequenceLiteralContext, databaseLinkContext, useDatabaseCompletion } = analysis;
if (analysis.suppressed && !sequenceLiteralContext) return false;
const context = databaseLinkContext ?? sequenceLiteralContext ?? analysis.completionContext;
const prevChar = fullDoc[position - 1] ?? "";
const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
const facts: SqlCompletionTriggerFacts = {
return shouldAllowSqlCompletionTrigger(mode, {
origin: "typing",
hasIdentifierPrefix: ctx.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in ctx ? ctx.schema != null : "qualifier" in ctx && ctx.qualifier != null)),
useDatabasePrefix,
positionalEligible,
};
return shouldAllowSqlCompletionTrigger(mode, facts);
hasIdentifierPrefix: context.prefix.length > 0,
qualifierTriggered: !!databaseLinkContext || (prevChar === "." && ("schema" in context ? context.schema != null : "qualifier" in context && context.qualifier != null)),
useDatabasePrefix: useDatabaseCompletion?.prefix ?? null,
positionalEligible: analysis.positionalEligible,
});
}
function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean {
async function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): Promise<boolean> {
const position = currentView.state.selection.main.head;
const fullDoc = currentView.state.doc.toString();
@@ -1122,7 +1100,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
}
}
const cteDefs = extractCteDefinitions(fullDoc);
const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc);
for (const refTable of completionContext.referencedTables) {
if (refTable.columns?.length) {
columnsByTable.set(
@@ -1541,7 +1519,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
}
// Populate CTE columns from parsed definitions
const cteDefs = extractCteDefinitions(fullDoc);
const cteDefs = analyzedCtes?.sql === fullDoc ? analyzedCtes.definitions : extractCteDefinitions(fullDoc);
for (const refTable of refs) {
if (refTable.columns) continue;
const cteDef = cteDefs.find((c) => c.name.toLowerCase() === refTable.name.toLowerCase());
@@ -1676,11 +1654,14 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
return completionContext.suggestTables || completionContext.exclusiveTableSuggestions;
}
function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
async function refreshActiveSqlCompletion(fullDoc: string, position: number, completionContext: ReturnType<typeof getSqlCompletionContext>) {
const currentView = view.value;
if (!currentView || runtime.codeMirrorCompletionStatus?.(currentView.state) !== "active") return;
if (currentView.state.doc.toString() !== fullDoc || currentView.state.selection.main.head !== position) return;
const currentContext = getSqlCompletionContext(fullDoc, position);
const doc = currentView.state.doc;
const analysis = await getEditorSqlCompletionAnalysis(fullDoc, position, currentView.state);
if (!analysis || view.value !== currentView || currentView.state.doc !== doc || currentView.state.selection.main.head !== position) return;
const currentContext = analysis.completionContext;
if (currentContext.prefix !== completionContext.prefix || currentContext.contextKind !== completionContext.contextKind) return;
scheduleSqlCompletionStart(currentView);
}
@@ -1696,6 +1677,7 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
clearDeferredCompletionTrigger,
invalidateRequests() {
completionEpoch++;
cancelEditorSqlCompletionAnalysis();
},
get activeOrigin() {
return activeCompletionOrigin;
@@ -5,6 +5,9 @@ import { COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
import { getSqlCompletionContext } from "@/lib/sql/sqlCompletion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import type { SqlSemanticModel } from "@/lib/sql/semantic/types";
import { analyzeSqlCompletion, type SqlCompletionAnalysisResult } from "@/lib/sql/sqlCompletionAnalysis";
import { createSqlCompletionAnalysisWorker } from "@/lib/sql/sqlCompletionAnalysisWorker";
import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession";
import { mergeSqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import { requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
@@ -59,6 +62,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
const cachedForeignKeysByTable = new Map<string, SqlCompletionForeignKey[]>();
const loadedColumnsByTable = new Set<string>();
const completionAnalysisWorker = createSqlCompletionAnalysisWorker();
function sqlCompletionDialectOptions() {
return {
@@ -102,6 +106,64 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
return context;
}
let editorCompletionAnalysisCache: {
doc: Text;
editorState: EditorState;
position: number;
databaseType: DatabaseType | undefined;
dialect: QueryEditorProps["dialect"];
driverProfile: string | undefined;
syntaxDialect: QueryEditorProps["syntaxDialect"];
result: Promise<SqlCompletionAnalysisResult | null>;
} | null = null;
function getEditorSqlCompletionAnalysis(sql: string, position: number, editorState = view.value?.state): Promise<SqlCompletionAnalysisResult | null> {
const dialect = sqlBehaviorDialect();
const doc = editorState?.doc;
const driverProfile = props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined;
if (
doc &&
editorCompletionAnalysisCache?.doc === doc &&
editorCompletionAnalysisCache.editorState === editorState &&
editorCompletionAnalysisCache.position === position &&
editorCompletionAnalysisCache.databaseType === props.databaseType &&
editorCompletionAnalysisCache.dialect === dialect &&
editorCompletionAnalysisCache.driverProfile === driverProfile &&
editorCompletionAnalysisCache.syntaxDialect === props.syntaxDialect
) {
return editorCompletionAnalysisCache.result;
}
const request = {
sql,
cursor: position,
databaseType: props.databaseType,
dialect,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
syntaxDialect: props.syntaxDialect,
driverProfile,
};
const result = editorState && shouldUseQueryEditorLargeDocumentModeForSize(editorState.doc.length, editorState.doc.lines) ? completionAnalysisWorker.analyze(request, editorState) : Promise.resolve(analyzeSqlCompletion(request, editorState));
if (doc && editorState) {
editorCompletionAnalysisCache = {
doc,
editorState,
position,
databaseType: props.databaseType,
dialect,
driverProfile,
syntaxDialect: props.syntaxDialect,
result,
};
}
return result;
}
function cancelEditorSqlCompletionAnalysis() {
editorCompletionAnalysisCache = null;
completionAnalysisWorker.cancel();
}
// CTE definition metadata is cursor-independent, but hover/ctrl-click models still carry the
// position; memoize per (Text node, position, dialect, state) so hover and click at the same
// position share one parse. Any edit swaps the Text node, invalidating the cache for free.
@@ -541,6 +603,7 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
}
function refreshCompletionCache() {
cancelEditorSqlCompletionAnalysis();
cachedTables = [];
cachedCompletionObjectsByScope.clear();
cachedColumnsByTable.clear();
@@ -553,6 +616,9 @@ export function useQueryEditorCompletionMetadata(options: QueryEditorCompletionM
return {
sqlCompletionDialectOptions,
getEditorSqlCompletionContext,
getEditorSqlCompletionAnalysis,
cancelEditorSqlCompletionAnalysis,
disposeCompletionAnalysis: completionAnalysisWorker.dispose,
ensureColumnsForTable,
cachedColumnsByTable,
completionCacheKey,
@@ -4,10 +4,12 @@ import type { useConnectionStore } from "@/stores/connectionStore";
import type { QueryEditorProps, CompletionMetadataScope } from "./queryEditorTypes";
import * as api from "@/lib/backend/api";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { createSqlDiagnosticAnalysisWorker } from "@/lib/sql/sqlDiagnosticAnalysisWorker";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeCacheForDoc, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { editorRootSelection } from "@/lib/editor/queryEditorNativeSelection";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { mergeSqlSemanticReferenceAnalysis } from "@/lib/sql/semantic/references";
import { sqlServerUseDatabaseBeforeCursor } from "@/lib/sql/sqlCompletionLookupTarget";
@@ -69,12 +71,13 @@ interface QueryEditorDiagnosticsOptions {
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
unknownObjectHighlightEnabled: boolean;
fullFeaturesEnabled?: () => boolean;
boundedAnalysisEnabled?: () => boolean;
runtime: QueryEditorDiagnosticsRuntime;
metadata: QueryEditorDiagnosticMetadata;
}
export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions) {
const diagnosticAnalysisWorker = createSqlDiagnosticAnalysisWorker();
const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
@@ -137,15 +140,6 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
);
}
// Mirrors CodeMirror's own root handling: shadow roots only expose
// `getSelection` on some browsers, otherwise the owner document holds it.
function editorRootSelection(currentView: EditorViewType): Selection | null {
const root = currentView.root as unknown as ShadowRoot & { getSelection?: () => Selection | null };
if (root.nodeType !== 11) return (root as unknown as Document).getSelection();
if (typeof root.getSelection === "function") return root.getSelection() ?? null;
return root.ownerDocument?.getSelection() ?? null;
}
// See queryEditorDiagnosticCaretAnchor.ts for why the browser caret needs re-anchoring.
function reanchorCaretAfterDiagnostics(currentView: EditorViewType) {
const selection = currentView.state.selection;
@@ -189,6 +183,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
}
function clearScheduledSemanticDiagnostics() {
diagnosticAnalysisWorker.dispose();
semanticDiagnosticRunId++;
if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer);
semanticDiagnosticTimer = null;
@@ -196,12 +191,13 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
}
function invalidateSemanticDiagnosticsForDocumentChange() {
diagnosticAnalysisWorker.cancel();
semanticDiagnosticRunId++;
semanticDiagnostics = [];
}
function shouldSkipSqlSemanticDiagnostics() {
return options.fullFeaturesEnabled?.() === false || props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
@@ -330,7 +326,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
return kind === "cte" || kind === "subquery" || kind === "table_function";
}
async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boolean } = {}) {
async function refreshSemanticDiagnostics(refreshOptions: { preserveOutsideRanges?: boolean } = {}) {
const currentView = view.value;
const runId = ++semanticDiagnosticRunId;
if (!currentView || !props.connectionId || props.database == null) {
@@ -357,7 +353,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
// Redis has no SQL semantics; run command-name / arity / quote / danger checks instead.
if (!shouldRunRedisDiagnostics(sql, currentView.state.selection.main.head)) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
});
return;
}
@@ -368,35 +364,38 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
setSemanticDiagnostics([]);
return;
}
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
const backgroundAnalysis = options.boundedAnalysisEnabled?.() === true;
const prepared = backgroundAnalysis ? (await diagnosticAnalysisWorker.analyze({ sql, cursor: currentView.state.selection.main.head, databaseType: props.databaseType, driverProfile: sqlDriverProfile.value, parameterOptions: sqlStatementParameterOptions(), visibleRanges }))?.document : undefined;
if (runId !== semanticDiagnosticRunId || (backgroundAnalysis && !prepared)) return;
if (!(prepared?.shouldRun ?? shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) {
scheduleSemanticDiagnostics(1200, {
preserveOutsideRanges: options.preserveOutsideRanges,
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
});
return;
}
if (runtime.codeMirrorCompletionStatus?.(currentView.state) && isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })) {
if (runtime.codeMirrorCompletionStatus?.(currentView.state) && (prepared?.inputContext ?? isSqlSemanticDiagnosticInputContext(sql, currentView.state.selection.main.head, { databaseType: props.databaseType }))) {
scheduleSemanticDiagnostics(900, {
preserveOutsideRanges: options.preserveOutsideRanges,
preserveOutsideRanges: refreshOptions.preserveOutsideRanges,
});
return;
}
const visibleRanges = currentView.visibleRanges.length > 0 ? currentView.visibleRanges : [currentView.viewport];
if (props.databaseType !== "sqlserver") {
if (!prepared && props.databaseType !== "sqlserver") {
runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
}
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
const diagnosticRanges = prepared?.diagnosticRanges ?? sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
// SQL Server routine batches are excluded from `diagnosticRanges` (see
// `sqlServerRoutineDefinitionRangesForViewport`), so they are recomputed here
// and stay part of the replaced range set below.
const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];
const sqlServerRoutineRanges = prepared?.sqlServerRoutineRanges ?? (props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : []);
if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
if (!refreshOptions.preserveOutsideRanges) setSemanticDiagnostics([]);
return;
}
const nextDiagnostics: SqlSemanticDiagnostic[] = [];
const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType);
const oracleSyntaxDiagnostics = prepared?.oracleSyntaxDiagnostics ?? buildOracleSyntaxDiagnostics(sql, props.databaseType);
nextDiagnostics.push(
...oracleSyntaxDiagnostics.filter((diagnostic) => {
const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span);
@@ -406,10 +405,10 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
// The analyzer never sees routine batches (the MsSql grammar cannot parse their
// parameter list), so run the token-based routine syntax rules instead of leaving
// a stored procedure without any check at all (dbx#9315).
for (const range of sqlServerRoutineRanges) {
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
for (const [index, range] of sqlServerRoutineRanges.entries()) {
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(prepared?.sqlServerRoutineDiagnostics[index] ?? buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
}
const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
const mysqlRoutineAnalysis = prepared ? prepared.mysqlRoutineAnalysis : props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
if (mysqlRoutineAnalysis) {
nextDiagnostics.push(
...mysqlRoutineAnalysis.diagnostics.filter((diagnostic) => {
@@ -432,13 +431,20 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
if (runId !== semanticDiagnosticRunId) return;
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(range.sql, semanticCursor, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
})
: null;
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
const semanticModel =
SEMANTIC_SQL_COMPLETION_ENABLED && !backgroundAnalysis
? buildSqlSemanticModel(range.sql, semanticCursor, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
})
: null;
const semanticAnalysis =
SEMANTIC_SQL_COMPLETION_ENABLED && backgroundAnalysis
? (await diagnosticAnalysisWorker.analyze({ sql: range.sql, cursor: semanticCursor, databaseType: props.databaseType, dialect: sqlBehaviorDialect(), visibleRanges: [], referenceAnalysis: analysis }))?.referenceAnalysis
: semanticModel
? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel)
: analysis;
if (!semanticAnalysis || runId !== semanticDiagnosticRunId) return;
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
const scopedAnalysis = {
...semanticAnalysis,
@@ -473,7 +479,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
if (diagnostic) nextDiagnostics.push(...offsetSqlSemanticDiagnostics([diagnostic], range, sql));
}
}
if (options.preserveOutsideRanges) {
if (refreshOptions.preserveOutsideRanges) {
replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);
} else {
setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics));
@@ -1,9 +1,20 @@
import type { ShallowRef } from "vue";
import { RangeSet, RangeValue, type EditorState } from "@codemirror/state";
import type { EditorView as EditorViewType, ViewPlugin as ViewPluginType } from "@codemirror/view";
import { executableStatementRangeCacheForDoc, statementGutterStartIndexForCache, mapStatementGutterStartIndex, type ExecutableStatementRangeCache, type StatementGutterStartIndex } from "@/lib/sql/executableStatementRangeCache";
import type { SqlParameterOptions } from "@/lib/sql/sqlParameters";
import type { QueryEditorProps } from "./queryEditorTypes";
import type { QueryEditorCodeMirrorRuntime } from "./queryEditorCodeMirrorRuntime";
import { createSqlStatementAnalysisWorker } from "@/lib/sql/sqlStatementAnalysisWorker";
import { shouldUseQueryEditorLargeDocumentModeForSize } from "@/lib/editor/queryEditorLargeDocument";
import type { FoldRange } from "@/lib/editor/codemirrorSqlBlockFolding";
class FoldRangeMarker extends RangeValue {
startSide = 1;
endSide = 1;
}
const foldRangeMarker = new FoldRangeMarker();
interface QueryEditorStatementBoundariesOptions {
props: Readonly<QueryEditorProps>;
@@ -11,6 +22,7 @@ interface QueryEditorStatementBoundariesOptions {
sqlStatementParameterOptions: () => SqlParameterOptions;
cache: { value: ExecutableStatementRangeCache | null };
runtime: QueryEditorCodeMirrorRuntime;
driverProfile?: () => string | undefined;
}
export function useQueryEditorStatementBoundaries(options: QueryEditorStatementBoundariesOptions) {
@@ -18,14 +30,18 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
// Lenient statement-boundary view shared by the run-statement gutter and the
// current-statement frame. Re-parsing the whole document on every keystroke is
// the dominant typing cost on large scripts, so while typing we only shift the
// known start positions / frame range through the ChangeSet (O(statements)) and
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
// known positions through RangeSet mapping and rebuild after typing pauses,
// off-thread for large documents. Paths that must stay exact (gutter
// click-to-execute, execution picker) keep using the full on-demand parse.
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
const analysisWorker = createSqlStatementAnalysisWorker();
let trackedView: EditorViewType | null = null;
let pendingAnalysis: { doc: EditorState["doc"]; generation: number; result: Promise<ExecutableStatementRangeCache | null> } | null = null;
let statementBoundariesView: {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
folds: RangeSet<FoldRangeMarker>;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
@@ -35,20 +51,65 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
cache.value = executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions());
function cancelStatementBoundariesRefresh() {
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = null;
}
function clearStatementBoundaries() {
cancelStatementBoundariesRefresh();
analysisWorker.dispose();
pendingAnalysis = null;
statementBoundariesGeneration += 1;
cache.value = null;
statementBoundariesView = null;
}
function installStatementBoundaries(state: EditorState, result: ExecutableStatementRangeCache, folds?: Map<number, FoldRange>) {
cache.value = result;
statementBoundariesView = {
doc: state.doc,
startsIndex: statementGutterStartIndexForCache(cache.value),
startsIndex: statementGutterStartIndexForCache(result),
folds: folds
? RangeSet.of(
[...folds.values()].map((range) => foldRangeMarker.range(range.from, range.to)),
true,
)
: RangeSet.empty,
frameRange: null,
fresh: true,
generation: statementBoundariesGeneration,
};
}
function ensureStatementCache(state: EditorState): Promise<ExecutableStatementRangeCache | null> {
if (pendingAnalysis?.doc === state.doc && pendingAnalysis.generation === statementBoundariesGeneration) return pendingAnalysis.result;
const generation = statementBoundariesGeneration;
const result = analysisWorker.analyze({ sql: state.doc.toString(), databaseType: props.databaseType, parameterOptions: sqlStatementParameterOptions(), includeFolds: true, syntaxDialect: props.syntaxDialect ?? props.dialect, driverProfile: options.driverProfile?.() }).then((analysis) => {
const currentView = view.value ?? trackedView;
if (!analysis || generation !== statementBoundariesGeneration || currentView?.state.doc !== state.doc) return null;
const resolved = { ...analysis, doc: state.doc };
installStatementBoundaries(state, resolved, analysis.folds);
const refreshEffect = codeMirrorRuntime.statementBoundariesRefreshEffect;
if (refreshEffect) currentView.dispatch({ effects: refreshEffect.of(null) });
return resolved;
});
pendingAnalysis = { doc: state.doc, generation, result };
return result;
}
function refreshStatementBoundaries(state: EditorState) {
if (shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) {
void ensureStatementCache(state);
} else {
installStatementBoundaries(state, executableStatementRangeCacheForDoc(cache.value, state.doc, props.databaseType, sqlStatementParameterOptions()));
}
}
interface StatementBoundariesView {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
folds: RangeSet<FoldRangeMarker>;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
@@ -60,6 +121,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
// reference current through ChangeSet mapping, so this stays cheap.
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
statementBoundariesView = { doc: state.doc, startsIndex: { starts: RangeSet.empty, executableLineStarts: RangeSet.empty }, folds: RangeSet.empty, frameRange: null, fresh: false, generation: statementBoundariesGeneration };
refreshStatementBoundaries(state);
return statementBoundariesView!;
}
@@ -82,8 +144,14 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
function createTrackingPlugin(ViewPlugin: typeof ViewPluginType) {
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
class {
constructor(currentView: EditorViewType) {
trackedView = currentView;
}
update(update: import("@codemirror/view").ViewUpdate) {
if (!update.docChanged) return;
analysisWorker.cancel();
pendingAnalysis = null;
const boundaries = statementBoundariesView;
// No consumer (run gutter off + statement frame off) ever initialized
// the view — nothing to maintain and no refresh to schedule.
@@ -92,6 +160,7 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
statementBoundariesView = {
doc: update.state.doc,
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
folds: boundaries.folds.map(update.changes),
frameRange: boundaries.frameRange
? {
from: update.changes.mapPos(boundaries.frameRange.from, 1),
@@ -106,19 +175,32 @@ export function useQueryEditorStatementBoundaries(options: QueryEditorStatementB
}
destroy() {
if (statementBoundariesRefreshTimer !== null) {
clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = null;
}
clearStatementBoundaries();
trackedView = null;
}
},
);
return statementBoundariesTrackingPlugin;
}
return {
foldRangeForState(state: EditorState, lineStart: number): FoldRange | null | undefined {
if (!shouldUseQueryEditorLargeDocumentModeForSize(state.doc.length, state.doc.lines)) return undefined;
const lineEnd = state.doc.lineAt(lineStart).to;
let range: FoldRange | null = null;
statementBoundariesForState(state).folds.between(lineEnd, lineEnd, (from, to) => {
if (from !== lineEnd) return;
range = { from, to };
return false;
});
return range;
},
statementBoundariesForState,
createTrackingPlugin,
ensureStatementCache,
clear: clearStatementBoundaries,
invalidate() {
analysisWorker.cancel();
pendingAnalysis = null;
statementBoundariesGeneration += 1;
},
};
@@ -20,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Download, Square, CheckSquare, Search, X, Loader2, Wrench } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
import { isQueryTimeoutErrorMessage } from "@/lib/sql/queryError";
import type { SqlInsertDialect } from "@/lib/export/sqlInsertMode";
const { t } = useI18n();
const { toast } = useToast();
@@ -64,6 +65,7 @@ const POSTGRES_ALL_SCHEMAS = "__DBX_ALL_SCHEMAS__";
// Options
const includeStructure = ref(true);
const includeData = ref(true);
const insertDialect = ref<SqlInsertDialect>("source");
const includeObjects = ref(true);
const includeCreateDatabase = ref(false);
const dropTableIfExists = ref(false);
@@ -396,6 +398,7 @@ async function startExport() {
selectedTables: !isPostgresAllSchemas.value && (includeStructure.value || includeData.value) ? buildSelectedTablesPayload(tables.value, selectedTables.value) : undefined,
includeStructure: includeStructure.value,
includeData: includeData.value,
insertDialect: insertDialect.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
@@ -538,6 +541,7 @@ async function startAllDatabasesExport() {
filePath,
includeStructure: includeStructure.value,
includeData: includeData.value,
insertDialect: insertDialect.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
@@ -659,6 +663,7 @@ function resetState() {
databaseFilter.value = "";
includeStructure.value = true;
includeData.value = true;
insertDialect.value = "source";
includeObjects.value = true;
includeCreateDatabase.value = false;
dropTableIfExists.value = false;
@@ -947,6 +952,16 @@ watch(
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeData") }}
</div>
<div class="space-y-1">
<button type="button" class="flex items-center gap-2 text-left text-xs" :class="includeData ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'" :disabled="!includeData" @click="insertDialect = insertDialect === 'standard' ? 'source' : 'standard'">
<CheckSquare v-if="includeData && insertDialect === 'standard'" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.standardSqlInsert") }}
</button>
<p class="pl-5 text-[11px] text-muted-foreground">
{{ t("databaseExport.standardSqlInsertDescription") }}
</p>
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeObjects = !includeObjects">
<CheckSquare v-if="includeObjects" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { Loader2, Check, CheckCircle2, XCircle, AlertCircle, X, FileDown, Database, DatabaseBackup, FileCode2, ArrowRightLeft, Layers3, GitCompareArrows, ChevronRight, FolderOpen, Copy } from "@lucide/vue";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { dataTransferFailureCopyText, sqlFileFailureCopyText } from "@/components/export/failureDetailCopyText";
import SqlFileProgressIndicator from "@/components/sql-file/SqlFileProgressIndicator.vue";
@@ -108,6 +108,7 @@ const taskTitle = (task: ExportTask) => {
}
if (task.kind === "sql-file") return t("exportProgress.sqlFileTitle", { name: task.tableName });
if (task.kind === "data-transfer") return t("exportProgress.dataTransferTitle", { name: task.tableName });
if (task.kind === "data-generation") return t("exportProgress.dataGenerationTitle", { name: task.tableName });
if (task.kind === "multi-db-execution") return t("exportProgress.multiDbExecutionTitle", { name: task.tableName });
if (task.kind === "schema-diff") return t("exportProgress.schemaDiffTitle", { name: task.tableName });
if (task.kind === "data-compare") return t("exportProgress.dataCompareTitle", { name: task.tableName });
@@ -187,6 +188,16 @@ const rowsText = (task: ExportTask) => {
const durationText = t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - (task.startedAt ?? finishedAt)) });
return task.currentTable ? `${tableText} · ${task.currentTable} · ${rowText} · ${durationText}` : `${tableText} · ${durationText}`;
}
if (task.kind === "data-generation") {
const tableText = task.totalTables
? t("exportProgress.tablesCount", {
current: (task.tableIndex ?? 0).toLocaleString(),
total: task.totalTables.toLocaleString(),
})
: "";
const rowText = task.totalRows ? `${task.rowsExported.toLocaleString()} / ${task.totalRows.toLocaleString()} ${t("exportProgress.rowsShort")}` : `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
return [tableText, task.currentTable, rowText].filter(Boolean).join(" · ");
}
if (task.kind === "multi-db-execution") {
return t("exportProgress.multiDbTargets", {
completed: (task.multiDbCompleted ?? 0).toLocaleString(),
@@ -232,6 +243,7 @@ const statusIcon = (task: ExportTask) => {
if (task.kind === "data-dictionary") return FileDown;
if (task.kind === "sql-file") return FileCode2;
if (task.kind === "data-transfer") return ArrowRightLeft;
if (task.kind === "data-generation") return Database;
if (task.kind === "multi-db-execution") return Layers3;
if (task.kind === "schema-diff" || task.kind === "data-compare") return GitCompareArrows;
}
@@ -1,19 +1,29 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
import type { SqlExportColumnSelection, SqlExportOptions, SqlInsertMode } from "@/lib/export/sqlInsertMode";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{ allowSplit?: boolean }>();
const props = defineProps<{ allowSplit?: boolean; columns?: SqlExportColumnSelection[] }>();
const selected = ref<SqlInsertMode>("batch");
const selectedColumnIndexes = ref(props.columns?.map((column) => column.sourceIndex) ?? []);
const splitSqlOutput = ref(false);
const splitSqlPartMaxMb = ref(100);
const MIN_SPLIT_SQL_PART_MB = 1;
const MAX_SPLIT_SQL_PART_MB = 4096;
let outcomeEmitted = false;
const columnSelectionEnabled = computed(() => props.columns !== undefined);
const selectedColumnSet = computed(() => new Set(selectedColumnIndexes.value));
const selectedColumns = computed(() => props.columns?.filter((column) => selectedColumnSet.value.has(column.sourceIndex)) ?? []);
const canConfirm = computed(() => !columnSelectionEnabled.value || selectedColumns.value.length > 0);
const duplicateColumnNames = computed(() => {
const counts = new Map<string, number>();
for (const column of props.columns ?? []) counts.set(column.name, (counts.get(column.name) ?? 0) + 1);
return new Set([...counts].flatMap(([name, count]) => (count > 1 ? [name] : [])));
});
const emit = defineEmits<{
confirm: [options: SqlExportOptions];
@@ -27,14 +37,24 @@ function normalizedSplitSqlPartMaxMb(): number {
}
function onConfirm() {
if (!canConfirm.value) return;
outcomeEmitted = true;
open.value = false;
emit("confirm", {
insertMode: selected.value,
splitMaxMb: props.allowSplit && splitSqlOutput.value ? normalizedSplitSqlPartMaxMb() : undefined,
...(columnSelectionEnabled.value ? { selectedColumns: selectedColumns.value } : {}),
});
}
function selectAllColumns() {
selectedColumnIndexes.value = props.columns?.map((column) => column.sourceIndex) ?? [];
}
function clearColumns() {
selectedColumnIndexes.value = [];
}
function onCancel() {
if (outcomeEmitted) return;
outcomeEmitted = true;
@@ -69,6 +89,27 @@ function onOpenChange(value: boolean) {
<span class="mt-1 block text-xs text-muted-foreground">{{ t("grid.sqlInsertModeSingleDescription") }}</span>
</span>
</label>
<div v-if="columnSelectionEnabled" class="space-y-2 rounded-md border p-3" data-sql-export-columns>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium">{{ t("databaseExport.sqlColumnSelection") }}</p>
<p class="text-xs text-muted-foreground">{{ t("databaseExport.selectedColumns", { selected: selectedColumns.length, total: props.columns?.length ?? 0 }) }}</p>
</div>
<div class="flex gap-2">
<Button type="button" variant="ghost" size="sm" data-sql-export-select-all @click="selectAllColumns">{{ t("databaseExport.selectAllColumns") }}</Button>
<Button type="button" variant="ghost" size="sm" data-sql-export-clear @click="clearColumns">{{ t("databaseExport.clearColumns") }}</Button>
</div>
</div>
<div class="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
<label v-for="column in props.columns" :key="column.sourceIndex" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm hover:bg-accent/50">
<input v-model="selectedColumnIndexes" type="checkbox" :value="column.sourceIndex" class="h-4 w-4" :data-sql-export-column="column.sourceIndex" />
<span class="truncate"
>{{ column.name }}<span v-if="duplicateColumnNames.has(column.name)" class="text-muted-foreground"> #{{ column.nameOccurrence + 1 }}</span></span
>
</label>
</div>
<p v-if="!canConfirm" role="alert" class="text-xs text-destructive" data-sql-export-column-error>{{ t("databaseExport.selectAtLeastOneColumn") }}</p>
</div>
<div v-if="props.allowSplit" class="space-y-2 rounded-md border p-3">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input v-model="splitSqlOutput" type="checkbox" class="h-4 w-4" data-sql-split-output />
@@ -85,7 +126,7 @@ function onOpenChange(value: boolean) {
</div>
<DialogFooter>
<Button variant="outline" @click="onCancel">{{ t("common.cancel") }}</Button>
<Button data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
<Button :disabled="!canConfirm" data-sql-insert-mode-confirm @click="onConfirm">{{ t("common.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -112,6 +112,31 @@ describe("data dictionary background task", () => {
});
});
describe("data generation background task", () => {
it("shows global progress and opens the resumable task", async () => {
const tracker = useExportTracker();
const onOpen = vi.fn();
const task = tracker.addDataGenerationTask("generate-task", "app.users", 1_000, 2, onOpen);
tracker.updateDataGenerationTask(task.exportId, {
status: "Running",
tableIndex: 1,
totalTables: 2,
currentTable: "users",
rowsGenerated: 250,
totalRows: 1_000,
});
await mountPopover();
expect(document.body.textContent).toContain("Data generation: app.users");
expect(document.body.textContent).toContain("1 / 2 tables · users · 250 / 1,000 rows");
const openButton = document.body.querySelector<HTMLButtonElement>('button[title="Open task"]');
expect(openButton).not.toBeNull();
openButton?.click();
expect(onOpen).toHaveBeenCalledOnce();
});
});
describe("SQL file byte progress", () => {
it("keeps legacy progress indeterminate instead of using successful statements as the total", async () => {
const tracker = useExportTracker();
@@ -3,6 +3,8 @@
import { createApp, h, nextTick, type App, defineComponent } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { sqlExportColumnChoices } from "@/lib/export/sqlExportColumns";
import type { SqlExportColumnSelection } from "@/lib/export/sqlInsertMode";
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
@@ -37,13 +39,13 @@ afterEach(() => {
i18n.global.locale.value = "en";
});
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false) {
async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit = false, columns?: SqlExportColumnSelection[]) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(
defineComponent({
setup() {
return () => h(SqlInsertModeDialog, { open: true, allowSplit, onConfirm, onCancel });
return () => h(SqlInsertModeDialog, { open: true, allowSplit, columns, onConfirm, onCancel });
},
}),
);
@@ -54,6 +56,37 @@ async function mountDialog(onConfirm = () => {}, onCancel = () => {}, allowSplit
}
describe("SqlInsertModeDialog", () => {
it("defaults to every column and allows deselecting one duplicate independently", async () => {
const onConfirm = vi.fn();
const choices = sqlExportColumnChoices(["id", "name", "id"]);
await mountDialog(onConfirm, () => {}, false, choices);
expect([...document.querySelectorAll<HTMLInputElement>("[data-sql-export-column]")].every((input) => input.checked)).toBe(true);
expect(document.body.textContent).toContain("#1");
expect(document.body.textContent).toContain("#2");
document.querySelector<HTMLInputElement>('[data-sql-export-column="0"]')?.click();
await nextTick();
document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]")?.click();
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices.slice(1) });
});
it("blocks an empty selection and restores every column with select all", async () => {
const onConfirm = vi.fn();
const choices = sqlExportColumnChoices(["id", "name"]);
await mountDialog(onConfirm, () => {}, true, choices);
document.querySelector<HTMLButtonElement>("[data-sql-export-clear]")?.click();
await nextTick();
const confirm = document.querySelector<HTMLButtonElement>("[data-sql-insert-mode-confirm]");
expect(confirm?.disabled).toBe(true);
expect(document.querySelector("[data-sql-export-column-error]")).not.toBeNull();
confirm?.click();
expect(onConfirm).not.toHaveBeenCalled();
document.querySelector<HTMLButtonElement>("[data-sql-export-select-all]")?.click();
await nextTick();
expect(confirm?.disabled).toBe(false);
confirm?.click();
expect(onConfirm).toHaveBeenCalledWith({ insertMode: "batch", splitMaxMb: undefined, selectedColumns: choices });
});
it("defaults to batch mode and emits the selected single-row mode", async () => {
const onConfirm = vi.fn();
await mountDialog(onConfirm);
@@ -1,39 +1,25 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted, watch, type ComponentPublicInstance } from "vue";
import { reactive, ref, computed, watch, type ComponentPublicInstance } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import * as api from "@/lib/backend/api";
import type { ColumnGenerateConfig, GenerateResult, TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
import {
createTableGenerateState,
defaultGeneratorParams,
displayGeneratedValue,
findGeneratorKey,
formatGeneratedRowValues,
formatGeneratedValue,
generateInsertBatches,
generateTableData,
generateTableRowsChunk,
splitValueRowsByByteBudget,
supportsGeneratedMultiRowValues,
UniqueValueGenerationError,
} from "@/lib/dataGrid/dataGenerate";
import { errorMessage, isQueryCanceledError, summarizeBatchResults } from "@/lib/dataGrid/generateInsertAccounting";
import type { ColumnGenerateConfig, TableGenerateConfig } from "@/lib/dataGrid/dataGenerate";
import { defaultGeneratorParams, displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generateTableData, supportsGeneratedMultiRowValues, UniqueValueGenerationError } from "@/lib/dataGrid/dataGenerate";
import { qualifiedTableName, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { uniqueConstraintColumns } from "@/lib/table/uniqueConstraintColumns";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
import type { ColumnInfo, QueryResult, TableInfo } from "@/types/database";
import type { ColumnInfo, TableInfo } from "@/types/database";
import { cancelDataGenerateSession, getDataGenerateSession, startDataGenerateSession, type DataGenerateTarget } from "@/composables/useDataGenerateSession";
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent, DialogContent, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Database, Table, Columns, Loader2, Save, Upload, Settings, ChevronRight, X, AlertCircle, ArrowUp, ArrowDown } from "@lucide/vue";
import { Database, Table, Columns, Loader2, Save, Upload, Settings, ChevronRight, X, AlertCircle, ArrowUp, ArrowDown, Minimize2 } from "@lucide/vue";
import { ScrollArea } from "@/components/ui/scroll-area";
import { uuid } from "@/lib/common/utils";
const { t } = useI18n();
const store = useConnectionStore();
@@ -45,6 +31,7 @@ const props = defineProps<{
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
sessionId?: string | null;
}>();
// Left tree state
@@ -72,15 +59,7 @@ const panelColumnName = ref<string | null>(null);
// Step state: config -> preview
const currentStep = ref<"config" | "preview" | "result">("config");
interface GeneratedTableResult extends GenerateResult {
tableName: string;
schema: string;
/** Rows that will actually be inserted. `rows` only holds a preview sample. */
targetRowCount: number;
isSample: boolean;
/** Column config after auto-increment start values have been resolved. */
resolvedColumns: ColumnGenerateConfig[];
}
type GeneratedTableResult = DataGenerateTarget;
const generatedResults = ref<GeneratedTableResult[]>([]);
const generationError = ref("");
@@ -92,12 +71,6 @@ const generationError = ref("");
const PREVIEW_SAMPLE_ROWS = 50;
const MAX_ROW_COUNT = 100_000_000;
const DEFAULT_BATCH_ROWS = 1000;
/**
* Conservative per-statement budget. The backend clamps MySQL batches at 4MB
* and derives a `max_allowed_packet` margin, so staying near 1MB keeps the
* common 4MB / 16MB / 64MB server settings safe without extra round trips.
*/
const MAX_BATCH_BYTES = 1024 * 1024;
const LARGE_ROW_COUNT_HINT = 100_000;
function normalizeRowCount(value: number): number {
@@ -432,6 +405,7 @@ const currentPreview = computed<GeneratedTableResult>(
generatedResults.value[previewTableIndex.value] ?? {
tableName: "",
schema: "",
database: "",
columns: [],
rows: [],
sql: "",
@@ -475,6 +449,8 @@ function buildSampleResult(cfg: TableGenerateConfig, columns: ColumnGenerateConf
return {
tableName: cfg.tableName,
schema: cfg.schema,
database: cfg.database,
tableType: cfg.tableType,
targetRowCount,
isSample: targetRowCount > sampleCount,
resolvedColumns: columns,
@@ -557,17 +533,10 @@ function copySampleSql() {
void navigator.clipboard.writeText(allSql);
}
const executing = ref(false);
interface TableResult {
table: string;
total: number;
ok: number;
err: number;
error?: string;
cancelled?: boolean;
}
const executeResults = ref<TableResult[]>([]);
const activeSessionId = ref<string | null>(props.sessionId ?? null);
const activeSession = computed(() => getDataGenerateSession(activeSessionId.value));
const executing = computed(() => activeSession.value?.status === "running" || activeSession.value?.status === "cancelling");
const executeResults = computed(() => activeSession.value?.results ?? []);
const generateOptions = reactive({
continueOnError: false,
@@ -589,17 +558,7 @@ watch(
const optionsDialogOpen = ref(false);
interface InsertProgress {
tableName: string;
tableIndex: number;
tableCount: number;
insertedRows: number;
totalRows: number;
elapsedMs: number;
}
const insertProgress = ref<InsertProgress | null>(null);
const insertCancelled = ref(false);
let activeExecutionId: string | null = null;
const insertProgress = computed(() => activeSession.value?.progress ?? null);
const insertPercent = computed(() => {
const p = insertProgress.value;
@@ -608,9 +567,8 @@ const insertPercent = computed(() => {
});
function cancelInsert() {
if (!executing.value) return;
insertCancelled.value = true;
if (activeExecutionId) void api.cancelQuery(activeExecutionId);
if (!executing.value || !activeSessionId.value) return;
void cancelDataGenerateSession(activeSessionId.value);
}
function normalizeTimeoutSecs(value: number): number {
@@ -643,119 +601,6 @@ function allSqlStatements(): string[] {
return generatedResults.value.flatMap((r) => sqlStatementsForTable(r));
}
/**
* Streams one table: generate a chunk, insert it, drop the references, repeat.
*
* Peak memory stays proportional to `batchRows` instead of the requested row
* count, and each awaited round trip gives the main thread a chance to paint
* the progress bar.
*/
async function streamInsertTable(cid: string, db: string, r: GeneratedTableResult, executionId: string, onRows: (insertedRows: number) => void): Promise<{ ok: number; attempted: number; error: string; cancelled: boolean }> {
const cfg = configs[tableKey(r.schema, r.tableName)];
const genCfg: TableGenerateConfig = {
tableName: r.tableName,
schema: r.schema,
database: props.prefillDatabase ?? cfg?.database ?? "",
tableType: cfg?.tableType,
rowCount: r.targetRowCount,
columns: r.resolvedColumns,
};
const state = createTableGenerateState(genCfg, dbType.value);
const schema = r.schema || props.prefillSchema;
const forceSingleRow = !generateOptions.extendedInsert;
const timeoutSecs = normalizeTimeoutSecs(generateOptions.timeoutSecs);
const batchRows = Math.max(1, Math.floor(generateOptions.batchRows) || DEFAULT_BATCH_ROWS);
let ok = 0;
let attempted = 0;
let lastError = "";
let cancelled = false;
const run = (sql: string): Promise<QueryResult[]> =>
api.executeMultiWithProgress(cid, db, sql, () => {}, schema, {
timeoutSecs,
useTransaction: generateOptions.useTransaction,
continueOnError: generateOptions.continueOnError,
executionId,
});
/**
* Executes one statement batch and folds the outcome into the running totals.
* Returns false when the caller has to stop the table.
*
* The returned per-statement results are inspected instead of trusting the
* `await`: several backend paths report a failed statement inside the result
* array while still resolving, so counting rows on the await alone would
* report failed batches as inserted.
*/
const executeBatch = async (statements: string[], rowsPerStatement: number[]): Promise<boolean> => {
const expectedRows = rowsPerStatement.reduce((sum, rows) => sum + rows, 0);
attempted += expectedRows;
try {
const results = await run(statements.join("\n"));
// A row-carrying batch that comes back without per-statement results is
// unconfirmed; counting it would over-report inserts.
if (expectedRows > 0 && (!results || results.length === 0)) {
if (!lastError) lastError = "Backend returned no result for the batch";
return generateOptions.continueOnError;
}
const outcome = summarizeBatchResults(results, rowsPerStatement);
ok += outcome.insertedRows;
if (!outcome.failed) return true;
if (!lastError) lastError = outcome.error ?? "Statement failed";
console.error("[startInsert] SQL error:", lastError);
return generateOptions.continueOnError;
} catch (e: unknown) {
// Cancelling a transaction or a single-statement round trip surfaces as
// an error, so it has to be told apart from a real failure.
if (insertCancelled.value || isQueryCanceledError(e)) {
cancelled = true;
return false;
}
const msg = errorMessage(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
return generateOptions.continueOnError;
}
};
const finish = () => ({ ok, attempted, error: cancelled ? "" : lastError, cancelled });
if (generateOptions.truncate) {
const targetTable = qualifiedTableName({ databaseType: dbType.value, schema: r.schema, tableName: r.tableName, database: props.prefillDatabase });
// TRUNCATE carries no generated rows, so it only gates the rest of the run.
const truncateOk = await executeBatch([`TRUNCATE TABLE ${targetTable};`], [0]);
if (!truncateOk) return finish();
}
while (state.nextIndex < r.targetRowCount && !insertCancelled.value) {
let valueRows: string[];
try {
const chunkRows = generateTableRowsChunk(genCfg, state, batchRows);
if (chunkRows.length === 0) break;
valueRows = chunkRows.map((row) => formatGeneratedRowValues(genCfg, dbType.value, state, row));
} catch (e: unknown) {
// Generation failures (unique space exhausted at row 500k, say) must not
// discard the rows that earlier chunks already inserted.
if (!lastError) lastError = generationErrorMessage(e);
return finish();
}
for (const group of splitValueRowsByByteBudget(state, valueRows, MAX_BATCH_BYTES)) {
const { statements, rowsPerStatement } = generateInsertBatches(dbType.value, state, group, forceSingleRow);
if (statements.length === 0) continue;
const keepGoing = await executeBatch(statements, rowsPerStatement);
onRows(ok);
if (!keepGoing) return finish();
}
onRows(ok);
// Yield so the progress bar repaints between chunks.
await new Promise((resolve) => setTimeout(resolve, 0));
}
if (insertCancelled.value) cancelled = true;
return finish();
}
async function startInsert() {
if (executing.value || generationError.value) return;
const cid = props.prefillConnectionId;
@@ -771,67 +616,46 @@ async function startInsert() {
sql: guardSql,
source: t("production.sourceDataGenerate"),
execute: async () => {
executing.value = true;
insertCancelled.value = false;
const executionId = uuid();
activeExecutionId = executionId;
const startedAt = performance.now();
const perTable: TableResult[] = [];
let stopAll = false;
try {
for (let ti = 0; ti < targets.length && !stopAll; ti++) {
const r = targets[ti];
insertProgress.value = {
tableName: r.tableName,
tableIndex: ti + 1,
tableCount: targets.length,
insertedRows: 0,
totalRows: r.targetRowCount,
elapsedMs: 0,
};
let outcome: { ok: number; attempted: number; error: string; cancelled: boolean };
let insertedSoFar = 0;
try {
outcome = await streamInsertTable(cid, db, r, executionId, (insertedRows) => {
insertedSoFar = insertedRows;
if (insertProgress.value) {
insertProgress.value = { ...insertProgress.value, insertedRows, elapsedMs: Math.round(performance.now() - startedAt) };
}
});
} catch (e: unknown) {
// Safety net: even an unexpected throw must not hide the rows that
// earlier batches already inserted.
const wasCancelled = insertCancelled.value || isQueryCanceledError(e);
outcome = { ok: insertedSoFar, attempted: insertedSoFar, error: wasCancelled ? "" : errorMessage(e), cancelled: wasCancelled };
}
const failedRows = Math.max(0, Math.max(outcome.attempted, outcome.ok) - outcome.ok);
perTable.push({
table: r.tableName,
total: r.targetRowCount,
ok: outcome.ok,
err: failedRows,
error: outcome.error || undefined,
cancelled: outcome.cancelled || undefined,
});
if (outcome.ok > 0) {
store.invalidateMetadataCache(cid, db, r.schema || props.prefillSchema || undefined, r.tableName);
}
if ((outcome.error && !generateOptions.continueOnError) || outcome.cancelled) stopAll = true;
}
} finally {
activeExecutionId = null;
}
executeResults.value = perTable;
currentStep.value = "result";
const session = startDataGenerateSession(
{
connectionId: cid,
database: db,
prefillSchema: props.prefillSchema,
databaseType: dbType.value,
label: targets.length === 1 ? `${db}.${targets[0].tableName}` : `${db} (${targets.length})`,
targets,
options: {
continueOnError: generateOptions.continueOnError,
truncate: generateOptions.truncate,
useTransaction: generateOptions.useTransaction,
extendedInsert: generateOptions.extendedInsert,
timeoutSecs: normalizeTimeoutSecs(generateOptions.timeoutSecs),
batchRows: Math.max(1, Math.floor(generateOptions.batchRows) || DEFAULT_BATCH_ROWS),
},
},
{
invalidateMetadataCache: (connectionId, database, schema, table) => store.invalidateMetadataCache(connectionId, database, schema, table),
formatGenerationError: generationErrorMessage,
},
);
activeSessionId.value = session.id;
applyDataGenerateSession(session);
return true;
},
});
} finally {
executing.value = false;
insertProgress.value = null;
} catch (error) {
generationError.value = generationErrorMessage(error);
}
}
function applyDataGenerateSession(session = activeSession.value) {
if (!session) return;
generatedResults.value = session.config.targets;
generationError.value = session.error ?? "";
previewTableIndex.value = Math.min(previewTableIndex.value, Math.max(0, generatedResults.value.length - 1));
currentStep.value = session.status === "running" || session.status === "cancelling" ? "preview" : "result";
}
const orderDialogOpen = ref(false);
const tableOrder = ref<string[]>([]);
@@ -892,15 +716,29 @@ watch(
{ deep: true, flush: "post" },
);
onMounted(() => {
void loadSchemas();
});
watch(open, (val) => {
if (val) {
watch(
[() => open.value, () => props.sessionId],
([isOpen, sessionId]) => {
if (!isOpen) return;
const session = getDataGenerateSession(sessionId);
if (session) {
activeSessionId.value = session.id;
applyDataGenerateSession(session);
return;
}
activeSessionId.value = null;
void loadSchemas();
}
});
},
{ immediate: true },
);
watch(
() => {
const session = getDataGenerateSession(activeSessionId.value);
return session ? `${session.id}:${session.version}` : "";
},
() => applyDataGenerateSession(),
);
interface GenerateProfileJson {
version: 1;
@@ -1296,8 +1134,9 @@ async function onFileSelected(event: Event) {
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" @click="open = false">
<X class="mr-1 h-3 w-3" />
{{ t("dangerDialog.cancel") }}
<Minimize2 v-if="executing" class="mr-1 h-3 w-3" />
<X v-else class="mr-1 h-3 w-3" />
{{ executing ? t("exportProgress.minimize") : t("dangerDialog.cancel") }}
</Button>
<template v-if="currentStep === 'config'">
<Button variant="default" size="sm" class="h-7 text-xs" :disabled="!hasSelectedTables" @click="doGenerate">
+275 -59
View File
@@ -166,8 +166,11 @@ import {
downloadBinaryCellPayload,
formatBinaryCellByteSize,
binaryCellUtf8Text,
hasUnsafeOpaqueAggregateStatePredicate,
isBlobCellColumnType,
isBinaryCellColumnType,
isOpaqueAggregateStateColumnType,
mergeOpaqueReadonlyColumnIndexes,
openBinaryCellFile,
parseBinaryCellBytes,
retainBinaryCellDownloadMenuForHover,
@@ -250,6 +253,8 @@ import {
buildColumnValueFilterCondition,
buildColumnValuesFilterCondition,
combineWhereInputs,
formatFilterRawValue,
formatFilterRawValues,
filterModeHasCompleteValue,
filterModeIsSupportedForDatabase,
filterModeNeedsValue,
@@ -326,6 +331,8 @@ import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifec
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
import { createDataGridFilterConditionCache, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import { DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT, useDataGridDistinctValueLoader } from "@/composables/useDataGridDistinctValueLoader";
import { dataGridDistinctValueKey, dataGridNullSuggestionFilterMode, toggleAllDataGridDistinctValueOptions, type DataGridDistinctValueSuggestionState, type DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import { cloneDataGridStructuredFilterRules, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState, type DataGridCachedServerColumnFilter, type DataGridStructuredFilterCacheState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { createDataGridSearchScopeKey } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
@@ -374,7 +381,7 @@ import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnForma
import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders";
import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters";
import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues";
import { useSalesforceSaveConfirmation } from "@/composables/useSalesforceSaveConfirmation";
import { useDataGridSaveConfirmation } from "@/composables/useDataGridSaveConfirmation";
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue"));
@@ -642,6 +649,10 @@ let preservedSelectionOnNextResult: {
selection: PersistedDataGridSelection;
sourceResult: QueryResult;
} | null = null;
let preservedTransposeRecordOnNextResult: {
selection: PersistedDataGridSelection;
sourceResult: QueryResult;
} | null = null;
let preservedDetailsOnNextResult: {
sideCell?: PersistedDataGridSelection;
sideCellHasPendingDraft?: boolean;
@@ -1114,8 +1125,6 @@ const conditionHistoryScope = computed(() => ({
tableName: props.tableMeta?.tableName,
}));
type LocalFilterMode = "local" | "server";
type LocalFilterOption = DataGridLocalFilterOption;
type LocalColumnFilterDraft = {
columnIndex: number;
values: Set<string>;
@@ -1161,11 +1170,6 @@ let localFilterResizeStartWidth = LOCAL_FILTER_POPOVER_DEFAULT_WIDTH;
let localFilterResizeStartOffsetX = 0;
let localFilterResizeStartLeft = 0;
let localFilterResizeStartRight = 0;
const serverFilterLoading = ref(false);
const serverFilterError = ref("");
const serverFilterOptions = ref<LocalFilterOption[]>([]);
const serverFilterLimited = ref(false);
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
const serverColumnFilters = ref<Record<number, DataGridCachedServerColumnFilter>>({});
let getGridNewRows: () => readonly (readonly CellValue[])[] = () => [];
let getGridRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[] = (row) => row;
@@ -1269,6 +1273,34 @@ const filterBuilderOpen = filterBuilder.open;
const filterBuilderColumnSearch = filterBuilder.columnSearch;
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
const filterValueSuggestionRuleId = ref<string>();
const filterValueSuggestionTarget = ref<DataGridDistinctValueSuggestionTarget>();
const filterValueSuggestionSearch = ref("");
const filterValueSuggestionDraftValues = ref(new Map<string, CellValue>());
const filterValueSuggestionLoader = useDataGridDistinctValueLoader({
scopeIdentity: structuredFilterScopeKey,
getConnectionId: () => props.connectionId,
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
getSchema: () => props.schema,
getDatabaseType: () => resolvedDatabaseType.value,
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
waitForTableMeta,
formatValue: (value, columnIndex) => formatCellCached(value, columnIndex),
keyForValue: dataGridDistinctValueKey,
});
const filterValueSuggestionState = computed<DataGridDistinctValueSuggestionState>(() => ({
ruleId: filterValueSuggestionRuleId.value,
target: filterValueSuggestionTarget.value,
search: filterValueSuggestionSearch.value,
options: filterValueSuggestionLoader.options.value,
loading: filterValueSuggestionLoader.loading.value,
error: filterValueSuggestionLoader.error.value,
limited: filterValueSuggestionLoader.limited.value,
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
selectedKeys: new Set(filterValueSuggestionDraftValues.value.keys()),
}));
// Structured filter rules are restored asynchronously. A tab-switch snapshot's
// probe includes the applied condition, so restoring before this hydration
// settles would reject an otherwise valid snapshot and never retry it.
@@ -1301,11 +1333,6 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
localFilterOpenColumn,
localFilterSearch,
localFilterDraft,
serverFilterLoading,
serverFilterError,
serverFilterOptions,
serverFilterLimited,
serverFilterValueByKey,
serverColumnFilters,
},
getResult: () => props.result,
@@ -1313,6 +1340,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
getConnectionId: () => props.connectionId,
getSchema: () => props.schema,
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
scopeIdentity: structuredFilterScopeKey,
resolvedDatabaseType,
canUseWhereSearch,
canUseServerColumnFilter,
@@ -1321,6 +1349,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
whereFilterInput,
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
getNewRows: () => getGridNewRows(),
getRowData: (row, sourceIndex) => getGridRowData(row, sourceIndex),
formatValue: formatCellCached,
@@ -1352,6 +1381,10 @@ const {
toggleLocalFilterSort,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
serverFilterLoading,
serverFilterError,
serverFilterLimited,
resetDistinctValueCache,
openLocalFilter,
closeLocalFilter,
toggleLocalFilterValue,
@@ -1525,6 +1558,89 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
);
}
function closeFilterValueSuggestions() {
filterValueSuggestionRuleId.value = undefined;
filterValueSuggestionTarget.value = undefined;
filterValueSuggestionSearch.value = "";
filterValueSuggestionDraftValues.value = new Map();
filterValueSuggestionLoader.reset();
}
function filterValueSuggestionRule(): StructuredFilterRule | undefined {
return structuredFilterRules.value.find((rule) => rule.id === filterValueSuggestionRuleId.value);
}
function filterValueSuggestionColumnIndex(columnName: string): number {
const exact = props.result.columns.indexOf(columnName);
if (exact >= 0) return exact;
const normalized = columnName.toLowerCase();
return props.result.columns.findIndex((column) => column.toLowerCase() === normalized);
}
function filterValueSuggestionRequest(searchValue = filterValueSuggestionSearch.value) {
const rule = filterValueSuggestionRule();
if (!rule) return undefined;
const columnIndex = filterValueSuggestionColumnIndex(rule.columnName);
if (columnIndex < 0) return undefined;
return {
columnIndex,
columnName: rule.columnName,
searchValue,
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
includeCounts: true,
};
}
async function openFilterValueSuggestions(ruleId: string, target: DataGridDistinctValueSuggestionTarget) {
const rule = structuredFilterRules.value.find((item) => item.id === ruleId);
if (!rule || rule.disabled || !rule.columnName || !filterModeNeedsValue(rule.mode)) return;
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName)?.columnInfo;
const currentValues = filterModeUsesList(rule.mode) ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : [];
filterValueSuggestionRuleId.value = ruleId;
filterValueSuggestionTarget.value = target;
filterValueSuggestionSearch.value = "";
filterValueSuggestionDraftValues.value = new Map(currentValues.filter((value) => value === null || typeof value !== "object").map((value): [string, CellValue] => [dataGridDistinctValueKey(value, columnInfo), value]));
filterValueSuggestionLoader.reset();
const request = filterValueSuggestionRequest();
if (request) await filterValueSuggestionLoader.load(request);
}
function updateFilterValueSuggestionSearch(value: string) {
filterValueSuggestionSearch.value = value;
const request = filterValueSuggestionRequest(value);
if (request) filterValueSuggestionLoader.schedule(request);
}
function selectFilterValueSuggestion(option: DataGridLocalFilterOption) {
const rule = filterValueSuggestionRule();
const target = filterValueSuggestionTarget.value;
if (!rule || !target) return;
if (option.value === null) {
filterBuilder.updateRule(rule.id, { mode: dataGridNullSuggestionFilterMode(rule.mode), rawValue: "", rawEndValue: "" });
} else {
filterBuilder.updateRule(rule.id, target === "end" ? { rawEndValue: formatFilterRawValue(option.value) } : { rawValue: formatFilterRawValue(option.value) });
}
closeFilterValueSuggestions();
}
function toggleFilterValueSuggestion(option: DataGridLocalFilterOption) {
const next = new Map(filterValueSuggestionDraftValues.value);
if (next.has(option.key)) next.delete(option.key);
else next.set(option.key, option.value);
filterValueSuggestionDraftValues.value = next;
}
function toggleAllFilterValueSuggestions() {
filterValueSuggestionDraftValues.value = toggleAllDataGridDistinctValueOptions(filterValueSuggestionDraftValues.value, filterValueSuggestionLoader.options.value);
}
function applyFilterValueSuggestions() {
const rule = filterValueSuggestionRule();
if (!rule || !filterModeUsesList(rule.mode)) return;
filterBuilder.updateRule(rule.id, { rawValue: formatFilterRawValues([...filterValueSuggestionDraftValues.value.values()]) });
closeFilterValueSuggestions();
}
function persistStructuredFilterState() {
saveDataGridStructuredFilterState(structuredFilterCacheKey.value, {
scopeKey: structuredFilterScopeKey.value,
@@ -1584,10 +1700,12 @@ function addStructuredFilterRule() {
}
function removeStructuredFilterRule(ruleId: string) {
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
filterBuilder.removeRule(ruleId);
}
function updateStructuredFilterRule(ruleId: string, patch: Partial<StructuredFilterRule>) {
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
filterBuilder.updateRule(ruleId, patch);
}
@@ -1600,6 +1718,7 @@ function updateTextFilterPanelHeight(height: number) {
}
function resetStructuredFilters() {
closeFilterValueSuggestions();
filterBuilder.reset();
}
@@ -1707,6 +1826,7 @@ function copyFilterSqlPreview() {
watch([structuredFilterCacheKey, structuredFilterScopeKey], loadStructuredFilterStateForScope, { immediate: true });
watch(filterEditorView, (view) => {
closeFilterValueSuggestions();
filterBuilderOpen.value = (view === "conditions" || view === "text") && settingsStore.editorSettings.dataGridKeepFilterEditorExpanded;
if (view === "conditions" || view === "text") ensureStructuredFilterRule();
});
@@ -2266,7 +2386,7 @@ function scrollToColumnIndex(columnIndex: number) {
// --- Column resize composable ---
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "fill");
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "content");
const tableFontFamily = computed(() => settingsStore.editorSettings.tableFontFamily);
const columnWidthCacheKey = computed(() => props.columnWidthCacheKey?.trim() || props.cacheKey?.trim() || undefined);
const columnStructureSignature = computed(() => createDataGridColumnStructureSignature(props.result.columns, props.result.column_types));
@@ -3274,6 +3394,7 @@ const resultSourceColumns = computed(() => props.result.columns.map((column, ind
const canEditExistingRows = computed(
() => !!props.customSaveHandler || (canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []) && hasCompleteTdengineRowIdentity(props.databaseType, props.tableMeta?.primaryKeys ?? [], resultSourceColumns.value)),
);
const canUpdateExistingRows = computed(() => canEditExistingRows.value && props.customSaveHandler?.canUpdate !== false);
const customReadonlyColumns = computed(() => new Set((props.customSaveHandler?.readonlyColumns ?? []).map((column) => column.toLowerCase())));
const hasDataGridSaveTarget = computed(() => !!props.tableMeta || !!props.customSaveHandler);
const hasDataGridInsertTarget = computed(() => {
@@ -3813,22 +3934,22 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap<number, Readon
return true;
}
// Salesforce applies one REST call per record with no transaction, so every grid
// save is reviewed here first. The identity lines are advisory only — Salesforce
// enforces the real object/field permissions, and a failed identity lookup just
// omits the line rather than blocking the write.
// Non-transactional or specialized mutations can require a final review of the
// exact statements after changes have been staged. Salesforce and guarded
// InfluxDB 1.x deletion share the promise-backed dialog state below.
const {
open: salesforceSaveConfirmOpen,
updates: salesforceSaveConfirmUpdates,
inserts: salesforceSaveConfirmInserts,
deletes: salesforceSaveConfirmDeletes,
total: salesforceSaveConfirmTotal,
targetLabel: salesforceSaveConfirmTarget,
statements: salesforceSaveConfirmStatements,
request: requestSalesforceSaveConfirmation,
confirm: confirmSalesforceSave,
} = useSalesforceSaveConfirmation();
open: saveConfirmOpen,
updates: saveConfirmUpdates,
inserts: saveConfirmInserts,
deletes: saveConfirmDeletes,
total: saveConfirmTotal,
targetLabel: saveConfirmTarget,
statements: saveConfirmStatements,
request: requestDataGridSaveConfirmation,
confirm: confirmDataGridSave,
} = useDataGridSaveConfirmation();
const isSalesforceGrid = computed(() => resolvedDatabaseType.value === "salesforce");
const isInfluxDbV1DeleteGrid = computed(() => props.customSaveHandler?.confirmation === "influxdb-v1-delete");
const salesforceIdentity = computed(() => (isSalesforceGrid.value && props.connectionId ? connectionStore.salesforceCurrentUser(props.connectionId) : null));
const salesforceIdentityLabel = computed(() => {
const identity = salesforceIdentity.value;
@@ -3837,9 +3958,9 @@ const salesforceIdentityLabel = computed(() => {
});
const salesforceSaveConfirmSummary = computed(() => {
const parts: string[] = [];
if (salesforceSaveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: salesforceSaveConfirmUpdates.value }));
if (salesforceSaveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: salesforceSaveConfirmInserts.value }));
if (salesforceSaveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: salesforceSaveConfirmDeletes.value }));
if (saveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: saveConfirmUpdates.value }));
if (saveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: saveConfirmInserts.value }));
if (saveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: saveConfirmDeletes.value }));
return parts.join(" · ");
});
// The save dialog names the profile alongside the user: writability comes from
@@ -3849,7 +3970,7 @@ const salesforceIdentityProfile = computed(() => {
return profileName ? t("grid.salesforceSaveProfile", { name: profileName }) : t("toolbar.salesforceIdentityUnknownProfile");
});
const salesforceSaveConfirmDetails = computed(() => {
const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: salesforceSaveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })];
const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: saveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })];
if (salesforceIdentity.value) {
const identity = { user: salesforceIdentityLabel.value, profile: salesforceIdentityProfile.value };
if (salesforceIdentity.value.isAdmin === true) lines.push(t("grid.salesforceSaveAdminIdentity", identity));
@@ -3858,9 +3979,13 @@ const salesforceSaveConfirmDetails = computed(() => {
}
return lines.filter((line) => !!line).join("\n");
});
const salesforceSaveConfirmSql = computed(() => salesforceSaveConfirmStatements.value.join("\n"));
watch(salesforceSaveConfirmOpen, (isOpen) => {
if (!isOpen || !props.connectionId) return;
const saveConfirmSql = computed(() => saveConfirmStatements.value.join("\n"));
const saveConfirmTitle = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirmTitle") : t("grid.salesforceSaveConfirmTitle")));
const saveConfirmMessage = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirmMessage", { count: saveConfirmDeletes.value }) : t("grid.salesforceSaveConfirmMessage", { count: saveConfirmTotal.value })));
const saveConfirmDetails = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteTarget", { measurement: saveConfirmTarget.value || "—" }) : salesforceSaveConfirmDetails.value));
const saveConfirmLabel = computed(() => (isInfluxDbV1DeleteGrid.value ? t("grid.influxDeleteConfirm") : t("grid.salesforceSaveConfirm")));
watch(saveConfirmOpen, (isOpen) => {
if (!isOpen || !isSalesforceGrid.value || !props.connectionId) return;
void connectionStore.loadSalesforceCurrentUser(props.connectionId);
});
@@ -3873,7 +3998,7 @@ const editor = useDataGridEditor({
tableMeta: computed(() => props.tableMeta),
sourceColumns: computed(() => props.sourceColumns),
joinedWriteTargets: computed(() => props.joinedWriteTargets),
readonlyColumnIndexes: computed(() => (props.readonlyColumnIndexes ? new Set(props.readonlyColumnIndexes) : undefined)),
readonlyColumnIndexes: computed(() => mergeOpaqueReadonlyColumnIndexes(props.readonlyColumnIndexes, allColumnTypes.value)),
canEditExistingRows,
onExecuteSql: computed(() => props.onExecuteSql),
customSaveHandler: computed(() => props.customSaveHandler),
@@ -3888,8 +4013,7 @@ const editor = useDataGridEditor({
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
// Only Salesforce opts in: its writes cannot be rolled back once issued.
confirmSaveRequest: computed(() => (isSalesforceGrid.value ? requestSalesforceSaveConfirmation : undefined)),
confirmSaveRequest: computed(() => (isSalesforceGrid.value || isInfluxDbV1DeleteGrid.value ? requestDataGridSaveConfirmation : undefined)),
includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName),
initialEditColumn: firstVisibleColumnIndex,
cellEditorText: cellEditorTextForValue,
@@ -4083,7 +4207,9 @@ function canEditRowItem(item: RowItem | undefined): boolean {
}
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false;
if (!canEditRowItem(item) || !item || !canEditColumn(columnIndex)) return false;
if (isOpaqueAggregateStateColumnType(allColumnTypes.value[columnIndex])) return false;
if (!item.isNew && !item.isDraft && !canUpdateExistingRows.value) return false;
if (isSavingNewRow(item)) return false;
const column = props.result.columns[columnIndex] ?? "";
if (customReadonlyColumns.value.has(column.toLowerCase())) return false;
@@ -4346,7 +4472,8 @@ function isDecimalColumnType(dataType: string): boolean {
function canDeleteRowItem(item: RowItem | undefined): boolean {
if (!item) return false;
return canDeleteGridRowItem({
if (!item.isNew && canUseKeylessRowPredicate(props.databaseType, props.tableMeta?.primaryKeys ?? []) && hasUnsafeOpaqueAggregateStatePredicate(allColumnTypes.value, item.data)) return false;
const canDelete = canDeleteGridRowItem({
editable: !!props.editable && canDeleteRows.value,
isDraft: !!item.isDraft,
isDeleted: item.isDeleted,
@@ -4354,6 +4481,8 @@ function canDeleteRowItem(item: RowItem | undefined): boolean {
canEditExistingRows: canEditExistingRows.value && canDeleteExistingRows.value,
isSavingNewRow: isSavingNewRow(item),
});
if (!canDelete || item.isNew || !props.customSaveHandler?.canDeleteRow) return canDelete;
return item.sourceIndex !== undefined && props.customSaveHandler.canDeleteRow(item.sourceIndex, item.data);
}
function resetInfiniteScrollState() {
@@ -5322,6 +5451,20 @@ function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) {
});
}
function restoreTransposeRecordAfterRefresh(snapshot: PersistedDataGridSelection) {
const restored = restoreDataGridSelection({
snapshot,
columns: props.result.columns,
sourceColumns: props.sourceColumns,
rows: props.result.rows,
visibleColumnIndexes: visibleColumnIndexes.value,
displayItems: displayItems.value,
});
if (restored?.kind !== "rows") return;
transposeRowIndex.value = restored.scrollRowIndex;
nextTick(() => scrollTransposeRecordIntoView(restored.scrollRowIndex));
}
/** Bounded settling envelope for a replayed tab-switch viewport. */
const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;
let viewSnapshotRestoreFrame = 0;
@@ -5743,7 +5886,11 @@ function affectedRowIds(): number[] {
}
function deletableRowIds(rowIds: number[]): number[] {
return rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
const eligible = rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
// A custom row-safety predicate is an all-or-nothing contract. Never silently
// drop an unsafe row from a multi-row request while deleting its neighbors.
if (props.customSaveHandler?.canDeleteRow && eligible.length !== rowIds.length) return [];
return eligible;
}
function exportSelectedRowsCsv() {
@@ -6281,6 +6428,14 @@ function applyColumnSort(column: string, columnIndex: number, direction: "asc" |
toast(t("grid.largeValueLocalSortUnavailable"), 5000);
return;
}
if (showTranspose.value) {
const selection = captureCurrentSelectionForRefresh();
preservedSelectionOnNextResult = selection ? { selection, sourceResult: props.result } : null;
const activeRecord = transposeRowIndex.value === null ? undefined : displayItemAt(transposeRowIndex.value);
const activeRecordSelection = captureRowTargetForRefresh(activeRecord?.id ?? null);
preservedTransposeRecordOnNextResult = activeRecordSelection ? { selection: activeRecordSelection, sourceResult: props.result } : null;
preserveTransposeOnNextResult.value = true;
}
if (mode === "database" && (infiniteScrollEnabled.value || loadAllRowsActive.value)) {
resetInfiniteScrollState();
} else {
@@ -6496,11 +6651,11 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string
}
function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string {
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
if (props.mongoCollectionGrid) {
const documentGridText = mongoDocumentGridDisplayText(value);
const documentGridText = mongoDocumentGridDisplayText(value, formatter);
if (documentGridText !== undefined) return documentGridText;
}
const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex];
if (formatter?.kind === "foreign-key-display" && columnIndex !== undefined) {
const display = formatForeignKeyCellDisplay(value, columnIndex);
return limitDisplay ? limitDataGridCellDisplay(display, resolvedDatabaseType.value === "sqlserver" ? SQLSERVER_DATA_GRID_CELL_DISPLAY_MAX_LENGTH : undefined) : display;
@@ -8370,7 +8525,7 @@ function selectedRangeTargetsOnlyDraftRow(): boolean {
return displayItemAt(range.startRow)?.isDraft === true;
}
const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canEditExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value);
const replaceAvailable = computed(() => !!props.editable && hasDataGridSaveTarget.value && canUpdateExistingRows.value && !resolvedConnectionConfig.value?.read_only && !isConditionalUpdateActive.value);
const replaceResolving = ref(false);
const replaceBusy = computed(() => replaceResolving.value || isSaving.value || gridSurfaceBusy.value || props.loading === true);
@@ -10134,6 +10289,8 @@ watch(
// check has to run before the markers are consumed below.
const inPlaceRefreshPending = preservedSelectionOnNextResult !== null || preservedViewportAnchorOnNextResult !== null || preservedDetailsOnNextResult !== null || preserveTransposeOnNextResult.value;
preservedSelectionOnNextResult = null;
const transposeRecordSnapshot = preservedTransposeRecordOnNextResult?.selection;
preservedTransposeRecordOnNextResult = null;
const viewportAnchorSnapshot = preservedViewportAnchorOnNextResult?.anchor;
preservedViewportAnchorOnNextResult = null;
const detailsSnapshot = preservedDetailsOnNextResult;
@@ -10163,6 +10320,8 @@ watch(
}
return;
}
resetDistinctValueCache();
filterValueSuggestionLoader.reset({ clearCache: true });
// A non-append result replaces the whole data set, so a running "load all" is over.
loadAllRowsActive.value = false;
// The replacement also invalidates the all-loaded marker: a filter change or
@@ -10195,6 +10354,7 @@ watch(
}
exitTransaction();
if (selectionSnapshot) restoreSelectionAfterRefresh(selectionSnapshot);
if (transposeRecordSnapshot) restoreTransposeRecordAfterRefresh(transposeRecordSnapshot);
if (detailsSnapshot) restoreDetailsAfterRefresh(detailsSnapshot);
if (viewportAnchorSnapshot) restoreViewportAnchorAfterRefresh(viewportAnchorSnapshot);
},
@@ -11918,6 +12078,8 @@ function exportSubmenu(): ContextMenuItem {
const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
const row = contextRowItem.value;
const rowLabels = rowActionLabels();
const rowIdsForDelete = isMultiRow.value ? affectedRowIds() : row ? [row.id] : [];
const deletableContextRowIds = deletableRowIds(rowIdsForDelete);
const hasEditableSelection = selectionHasEditableCells();
const selectedColumnCount = selectedVisibleColumnIndexes().length;
const gridSnapshotContext = contextHeaderColumn.value && hasColumnSelection.value ? "columns" : contextCell.value?.col === -1 && affectedRowIds().length > 0 ? "rows" : contextCell.value && hasCellSelection.value && selectedCellMatrix.value ? "cells" : null;
@@ -12087,13 +12249,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
hasRow: !!row,
canClone: !!row && canInsertRows.value && !row.isDraft,
deleted: !!row?.isDeleted,
canDelete: !!row && canDeleteRowItem(row),
canDelete: deletableContextRowIds.length > 0,
labels: rowLabels,
icons: { clone: CopyPlus, restore: Undo2, delete: Trash2 },
actions: {
clone: () => void (isMultiRow.value ? cloneRows(affectedRowIds()) : row && cloneRow(row.id)),
restore: () => (isMultiRow.value ? restoreRows(affectedRowIds()) : row && restoreRow(row.id)),
delete: () => (isMultiRow.value ? requestDeleteRows(deletableRowIds(affectedRowIds())) : row && requestDeleteRow(row.id)),
delete: () => {
if (deletableContextRowIds.length > 0) requestDeleteRows(deletableContextRowIds);
},
},
}),
[exportSubmenu()],
@@ -12232,6 +12396,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:filtered-columns="filteredFilterBuilderColumnOptions"
:mode-options="filterModeOptions"
:column-search="filterBuilderColumnSearch"
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
:apply-where="applyWhereFilter"
:apply-order-by="applyOrderBySearch"
:clear-order-by="clearOrderByInput"
@@ -12247,6 +12412,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
@clear-local-filter="clearLocalFilter"
@open-value-suggestions="openFilterValueSuggestions"
@close-value-suggestions="closeFilterValueSuggestions"
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
@select-value-suggestion="selectFilterValueSuggestion"
@toggle-value-suggestion="toggleFilterValueSuggestion"
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
@apply-value-suggestions="applyFilterValueSuggestions"
/>
</template>
@@ -12397,6 +12569,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@add-rule="addStructuredFilterRule"
@apply="applyStructuredFilters"
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
@apply-only="applyOnlyStructuredFilter"
@reset="resetStructuredFilters"
@clear="clearAllFilters"
@@ -12404,6 +12577,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@remove-rule="removeStructuredFilterRule"
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
@open-value-suggestions="openFilterValueSuggestions"
@close-value-suggestions="closeFilterValueSuggestions"
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
@select-value-suggestion="selectFilterValueSuggestion"
@toggle-value-suggestion="toggleFilterValueSuggestion"
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
@apply-value-suggestions="applyFilterValueSuggestions"
/>
<DataGridTextFilterWorkbench
v-if="canUseWhereSearch && filterEditorView === 'text' && filterBuilderOpen"
@@ -12415,6 +12595,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:mode-options="filterModeOptions"
:column-search="filterBuilderColumnSearch"
:disabled="!canUseWhereSearch"
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
@update:height="updateTextFilterPanelHeight"
@update:column-search="filterBuilderColumnSearch = $event"
@ensure-rule="ensureStructuredFilterRule"
@@ -12428,6 +12609,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
@remove-rule="removeStructuredFilterRule"
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
@open-value-suggestions="openFilterValueSuggestions"
@close-value-suggestions="closeFilterValueSuggestions"
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
@select-value-suggestion="selectFilterValueSuggestion"
@toggle-value-suggestion="toggleFilterValueSuggestion"
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
@apply-value-suggestions="applyFilterValueSuggestions"
/>
<!-- Truncation warning banner -->
<div v-if="showTruncationWarning" class="shrink-0 px-3 py-1 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
@@ -12517,6 +12705,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div
v-for="recordIndex in activeTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
data-grid-transpose-record-header
:data-grid-transpose-record-index="recordIndex"
class="shrink-0 border-r border-border px-2 py-1.5 text-left tabular-nums relative"
:class="{
'transpose-record-header-selected text-primary font-semibold': transposeRecordUsesFramedHeader(recordIndex),
@@ -12547,6 +12737,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<LightTooltip :text="transposeFieldTitle(item)" side="right" :side-offset="6" :delay="250" :open-on-focus="false" surface="popover">
<div
data-native-clipboard
:data-grid-transpose-column-index="visibleColumnIndexes[index]"
class="sticky left-0 z-10 flex shrink-0 flex-col items-start justify-center overflow-hidden border-r border-border bg-background px-3 py-0"
:class="{
'ring-2 ring-inset ring-primary': highlightedColumnIndex === visibleColumnIndexes[index],
@@ -12555,11 +12746,43 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
}"
:style="{ width: `${transposePinnedWidth}px` }"
>
<span class="flex min-w-0 items-center gap-1 overflow-hidden">
<span class="flex w-full min-w-0 items-center gap-1 overflow-hidden pr-5">
<KeyRound v-if="transposeColumnIndexKind(item.column) === 'primary'" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass('primary')" :title="transposeColumnIndexText('primary')" />
<Hash v-else-if="transposeColumnIndexKind(item.column)" data-grid-transpose-index-indicator class="h-3 w-3 shrink-0" :class="columnIndexColorClass(transposeColumnIndexKind(item.column)!)" :title="transposeColumnIndexText(transposeColumnIndexKind(item.column)!)" />
<span class="min-w-0 flex-1 truncate font-medium leading-4">{{ item.column }}</span>
</span>
<LightDropdownMenu
v-if="headerColumnSortable(visibleColumnIndexes[index])"
:items="sortMenuItems(item.column, visibleColumnIndexes[index])"
:open="headerSortMenuOpenColumn === visibleColumnIndexes[index]"
:selected-value="selectedSortMenuValue(item.column, visibleColumnIndexes[index])"
check-position="none"
align="end"
content-class="w-max min-w-28 p-0.5"
item-class="gap-1 rounded-none px-1.5 py-0.5 text-xs"
item-icon-class="h-3 w-3"
:match-trigger-width="false"
@update:open="(value: boolean) => (headerSortMenuOpenColumn = value ? visibleColumnIndexes[index] : null)"
@select="(value: string) => selectHeaderSort(value, item.column, visibleColumnIndexes[index])"
>
<template #trigger="{ open, toggle }">
<button
data-grid-transpose-sort
type="button"
class="absolute right-1 top-1 flex h-4 w-4 shrink-0 items-center justify-center rounded"
:class="columnIsSorted(item.column, visibleColumnIndexes[index]) ? 'bg-primary text-primary-foreground opacity-100 shadow-sm hover:bg-primary/90' : 'text-muted-foreground opacity-80 hover:bg-accent hover:text-foreground'"
:title="t('grid.sort')"
:aria-label="`${t('grid.sort')}: ${item.column}`"
:aria-expanded="open"
@mousedown.stop
@click.stop="toggle"
>
<ArrowUp v-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'asc'" class="h-3 w-3 shrink-0" />
<ArrowDown v-else-if="columnIsSorted(item.column, visibleColumnIndexes[index]) && sortDir === 'desc'" class="h-3 w-3 shrink-0" />
<ArrowUpDown v-else class="h-3 w-3 shrink-0" />
</button>
</template>
</LightDropdownMenu>
<template v-if="showTransposeFieldMetadata && showColumnTypesInHeader && item.type">
<span data-grid-transpose-type-line class="h-3 min-w-0 truncate text-[10px] font-normal leading-3 select-none" :class="typeColorClass(item.type)" :title="item.type">
{{ item.type }}
@@ -12593,6 +12816,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
<div
v-for="cell in item.values"
:key="`${item.id}:${cell.recordIndex}`"
data-grid-transpose-cell
:data-grid-transpose-record-index="cell.recordIndex"
class="relative flex shrink-0 items-center border-r border-border/70 px-2 py-0"
:class="[
transposeCellTextColorClass(cell.recordIndex, cell.valueIndex),
@@ -14289,18 +14514,9 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:close-on-confirm="false"
@confirm="confirmDropAllMongoIndexes"
/>
<!-- Salesforce applies each record with its own REST call and cannot roll back, so the
save is reviewed here first. Cancel (or closing) denies it and keeps the edits staged. -->
<DangerConfirmDialog
v-model:open="salesforceSaveConfirmOpen"
:title="t('grid.salesforceSaveConfirmTitle')"
:message="t('grid.salesforceSaveConfirmMessage', { count: salesforceSaveConfirmTotal })"
:details-text="salesforceSaveConfirmDetails"
:sql="salesforceSaveConfirmSql"
:confirm-label="t('grid.salesforceSaveConfirm')"
:close-on-confirm="false"
@confirm="confirmSalesforceSave"
/>
<!-- Specialized non-transactional saves are reviewed here first. Cancel (or closing)
denies the request and keeps the pending rows staged. -->
<DangerConfirmDialog v-model:open="saveConfirmOpen" :title="saveConfirmTitle" :message="saveConfirmMessage" :details-text="saveConfirmDetails" :sql="saveConfirmSql" :confirm-label="saveConfirmLabel" :close-on-confirm="false" @confirm="confirmDataGridSave" />
<ImagePreviewDialog v-if="imagePreviewMounted" v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
<component v-if="previewDialogOpen && previewDialogConfig" :is="previewDialogConfig.component" v-model:open="previewDialogOpen" v-bind="previewDialogConfig.props" />
<ExportProgressDialog
@@ -5,7 +5,7 @@ import { useSettingsStore, type DataGridColumnWidthMode } from "@/stores/setting
const { t } = useI18n();
const settingsStore = useSettingsStore();
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "fill");
const columnWidthMode = computed(() => settingsStore.editorSettings.dataGridColumnWidthMode ?? "content");
function setColumnWidthMode(value: DataGridColumnWidthMode) {
settingsStore.updateEditorSettings({ dataGridColumnWidthMode: value });
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from "vue";
import { Check, Database, Loader2, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
const props = defineProps<{
open: boolean;
search: string;
options: DataGridLocalFilterOption[];
loading: boolean;
error: string;
limited: boolean;
limit: number;
multiple?: boolean;
selectedKeys?: Set<string>;
disabled?: boolean;
compact?: boolean;
}>();
const emit = defineEmits<{
"update:open": [open: boolean];
"update:search": [search: string];
select: [option: DataGridLocalFilterOption];
toggle: [option: DataGridLocalFilterOption];
toggleAll: [];
apply: [];
}>();
const { t } = useI18n();
const allOptionsSelected = computed(() => props.options.length > 0 && props.options.every((option) => props.selectedKeys?.has(option.key)));
</script>
<template>
<Popover :open="props.open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="outline" size="icon" class="shrink-0" :class="props.compact ? 'h-6 w-6 rounded-none border-0 bg-transparent shadow-none' : 'h-7 w-7'" :disabled="props.disabled" :title="t('grid.databaseValueFilter')" :aria-label="t('grid.databaseValueFilter')">
<Database class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" side="bottom" class="w-80 gap-0 overflow-hidden p-0" @keydown.stop>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
:value="props.search"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.searchValues')"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="grid border-b bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground" :class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'">
<button
v-if="props.multiple"
type="button"
data-distinct-value-select-all
role="checkbox"
class="flex h-4 w-4 items-center justify-center rounded border"
:class="allOptionsSelected ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'"
:disabled="!props.options.length"
:aria-checked="allOptionsSelected"
@click="emit('toggleAll')"
>
<Check v-if="allOptionsSelected" class="h-3 w-3 stroke-[3]" />
</button>
<span>{{ t("grid.value") }}</span>
<span class="text-right">{{ t("grid.count") }}</span>
</div>
<div v-if="props.loading || props.error || props.limited" class="flex items-center gap-1.5 border-b px-2 py-1 text-[11px] text-muted-foreground">
<Loader2 v-if="props.loading" class="h-3 w-3 animate-spin" />
<span class="min-w-0 truncate">
<template v-if="props.loading">{{ t("grid.loadingValues") }}</template>
<template v-else-if="props.error">{{ props.error }}</template>
<template v-else>{{ t("grid.serverValuesLimited", { count: props.limit }) }}</template>
</span>
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button
v-for="option in props.options"
:key="option.key"
type="button"
class="grid w-full items-center px-2 py-1 text-left text-xs hover:bg-accent"
:class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'"
@click="props.multiple ? emit('toggle', option) : emit('select', option)"
>
<span v-if="props.multiple" class="flex h-4 w-4 items-center justify-center rounded border" :class="props.selectedKeys?.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
<Check v-if="props.selectedKeys?.has(option.key)" class="h-3 w-3 stroke-[3]" />
</span>
<span class="truncate font-mono">{{ option.label }}</span>
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ option.count ?? "" }}</span>
</button>
<div v-if="!props.options.length && !props.loading && !props.error" class="px-2 py-6 text-center text-xs text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
</div>
<div v-if="props.multiple" class="flex justify-end gap-2 border-t bg-muted/40 px-2 py-1.5">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="emit('update:open', false)">
{{ t("dangerDialog.cancel") }}
</Button>
<Button size="sm" class="h-7 px-2 text-xs" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
</div>
</PopoverContent>
</Popover>
</template>
@@ -6,11 +6,14 @@ import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
import { filterModeNeedsValue, filterModeUsesList, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { resolveDataGridFilterRuleDropPlacement } from "@/lib/dataGrid/dataGridFilterRuleDrag";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
const { t } = useI18n();
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
@@ -38,6 +41,7 @@ const props = withDefaults(
showHeader?: boolean;
showFooter?: boolean;
layout?: "popover" | "panel" | "text";
valueSuggestions?: DataGridDistinctValueSuggestionState;
}>(),
{ showHeader: true, showFooter: true, layout: "popover" },
);
@@ -51,6 +55,13 @@ const emit = defineEmits<{
move: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
"update:columnSearch": [value: string];
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
closeValueSuggestions: [];
updateValueSuggestionSearch: [value: string];
selectValueSuggestion: [option: DataGridLocalFilterOption];
toggleValueSuggestion: [option: DataGridLocalFilterOption];
toggleAllValueSuggestions: [];
applyValueSuggestions: [];
}>();
const columnSearchInputs = new Map<string, HTMLInputElement>();
const filterRuleElements = new Map<string, HTMLElement>();
@@ -95,6 +106,19 @@ function usesExpandedLayout(mode: DataGridContextFilterMode) {
return filterModeUsesList(mode) || filterModeUsesRange(mode);
}
function canSuggestValues(rule: DataGridStructuredFilterRule) {
return !!props.valueSuggestions && !!rule.columnName && !rule.disabled && filterModeNeedsValue(rule.mode);
}
function valueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget) {
return props.valueSuggestions?.ruleId === rule.id && props.valueSuggestions.target === target;
}
function updateValueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget, open: boolean) {
if (open) emit("openValueSuggestions", rule.id, target);
else if (valueSuggestionsOpen(rule, target)) emit("closeValueSuggestions");
}
function currentLocalDateKey() {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
@@ -621,6 +645,20 @@ function blurValueRule(id: string) {
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
compact
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
<Input
:model-value="rule.rawEndValue"
@@ -630,34 +668,82 @@ function blurValueRule(id: string) {
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
compact
:open="valueSuggestionsOpen(rule, 'end')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
</template>
<template v-else-if="filterModeUsesList(rule.mode)">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
compact
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
:multiple="true"
:selected-keys="props.valueSuggestions.selectedKeys"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@toggle="emit('toggleValueSuggestion', $event)"
@toggle-all="emit('toggleAllValueSuggestions')"
@apply="emit('applyValueSuggestions')"
/>
</template>
<template v-else-if="filterModeNeedsValue(rule.mode)">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderTextValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
compact
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
</template>
<Input
v-else-if="filterModeUsesList(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
/>
<Input
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderTextValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<span v-else class="px-1 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</span>
</div>
<div v-else-if="filterModeUsesRange(rule.mode)" class="col-start-2 col-span-3 flex gap-1.5">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-7 text-xs"
class="h-7 min-w-0 flex-1 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@@ -667,9 +753,22 @@ function blurValueRule(id: string) {
@compositionstart="startImeComposition(`value-start:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
<Input
:model-value="rule.rawEndValue"
class="h-7 text-xs"
class="h-7 min-w-0 flex-1 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@@ -679,33 +778,78 @@ function blurValueRule(id: string) {
@compositionstart="startImeComposition(`value-end:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
:open="valueSuggestionsOpen(rule, 'end')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
</div>
<div v-else-if="filterModeUsesList(rule.mode)" class="col-start-2 col-span-3 flex items-start gap-1.5">
<textarea
data-filter-value-editor
:value="rule.rawValue"
rows="2"
class="min-h-12 min-w-0 flex-1 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@keydown.ctrl.enter.prevent="emit('apply')"
@keydown.meta.enter.prevent="emit('apply')"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
:multiple="true"
:selected-keys="props.valueSuggestions.selectedKeys"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@toggle="emit('toggleValueSuggestion', $event)"
@toggle-all="emit('toggleAllValueSuggestions')"
@apply="emit('applyValueSuggestions')"
/>
</div>
<div v-else-if="filterModeNeedsValue(rule.mode)" class="col-start-4 flex min-w-0 gap-1.5">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-7 min-w-0 flex-1 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@compositionend="endImeComposition(`value:${rule.id}`)"
@compositionstart="startImeComposition(`value:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<DataGridDistinctValuePopover
v-if="canSuggestValues(rule) && props.valueSuggestions"
:open="valueSuggestionsOpen(rule, 'value')"
:search="props.valueSuggestions.search"
:options="props.valueSuggestions.options"
:loading="props.valueSuggestions.loading"
:error="props.valueSuggestions.error"
:limited="props.valueSuggestions.limited"
:limit="props.valueSuggestions.limit"
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
@update:search="emit('updateValueSuggestionSearch', $event)"
@select="emit('selectValueSuggestion', $event)"
/>
</div>
<textarea
v-else-if="filterModeUsesList(rule.mode)"
data-filter-value-editor
:value="rule.rawValue"
rows="2"
class="col-start-2 col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@keydown.ctrl.enter.prevent="emit('apply')"
@keydown.meta.enter.prevent="emit('apply')"
/>
<Input
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@compositionend="endImeComposition(`value:${rule.id}`)"
@compositionstart="startImeComposition(`value:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<div v-else class="flex h-7 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div v-if="props.layout !== 'text' && shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-start-2 col-span-3 -mt-0.5' : 'col-start-4 row-start-2 -mt-0.5'">
{{ t("grid.filterBuilderValueShortcutHint") }}
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
const props = defineProps<{
sqlPreview: string;
@@ -16,6 +18,7 @@ const props = defineProps<{
columnSearch: string;
applyOnlyBusy?: boolean;
disabled?: boolean;
valueSuggestions?: DataGridDistinctValueSuggestionState;
}>();
const emit = defineEmits<{
@@ -30,6 +33,13 @@ const emit = defineEmits<{
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
closeValueSuggestions: [];
updateValueSuggestionSearch: [value: string];
selectValueSuggestion: [option: DataGridLocalFilterOption];
toggleValueSuggestion: [option: DataGridLocalFilterOption];
toggleAllValueSuggestions: [];
applyValueSuggestions: [];
}>();
const { t } = useI18n();
@@ -64,6 +74,7 @@ watch(
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="disabled"
:value-suggestions="valueSuggestions"
layout="panel"
:show-header="false"
:show-footer="false"
@@ -76,6 +87,13 @@ watch(
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@update:column-search="emit('update:columnSearch', $event)"
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
@close-value-suggestions="emit('closeValueSuggestions')"
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
@select-value-suggestion="emit('selectValueSuggestion', $event)"
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
@apply-value-suggestions="emit('applyValueSuggestions')"
/>
</div>
@@ -10,6 +10,8 @@ import type { DataGridConditionColumnOption } from "@/composables/useDataGridCon
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { DataGridFilterEditorView } from "@/stores/settingsStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
@@ -42,6 +44,7 @@ const props = defineProps<{
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
applyOnlyBusy?: boolean;
valueSuggestions?: DataGridDistinctValueSuggestionState;
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
clearOrderBy: () => void | Promise<void>;
@@ -62,6 +65,13 @@ const emit = defineEmits<{
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
clearLocalFilter: [columnIndex?: number];
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
closeValueSuggestions: [];
updateValueSuggestionSearch: [value: string];
selectValueSuggestion: [option: DataGridLocalFilterOption];
toggleValueSuggestion: [option: DataGridLocalFilterOption];
toggleAllValueSuggestions: [];
applyValueSuggestions: [];
}>();
const { t } = useI18n();
@@ -151,6 +161,11 @@ async function handleFilterButtonClick() {
await openPendingFirstEmptyRuleColumnSearch();
}
function updateQuickFilterBuilderOpen(open: boolean) {
emit("update:filterBuilderOpen", open);
if (!open) emit("closeValueSuggestions");
}
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
onUnmounted(onResizeEnd);
@@ -160,7 +175,7 @@ onUnmounted(onResizeEnd);
<div ref="containerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': leadingBorder }" :style="wherePaneStyle">
<template v-if="filterEditorView === 'quick'">
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
<Popover :open="filterBuilderOpen" @update:open="updateQuickFilterBuilderOpen">
<PopoverTrigger as-child>
<button
type="button"
@@ -212,6 +227,7 @@ onUnmounted(onResizeEnd);
:column-search="columnSearch"
:disabled="!canUseWhereSearch"
:show-header="false"
:value-suggestions="valueSuggestions"
@add="emit('addRule')"
@apply-only="emit('applyOnly', $event)"
@apply="emit('applyFilters')"
@@ -221,6 +237,13 @@ onUnmounted(onResizeEnd);
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="updateRule"
@update:column-search="emit('update:columnSearch', $event)"
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
@close-value-suggestions="emit('closeValueSuggestions')"
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
@select-value-suggestion="emit('selectValueSuggestion', $event)"
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
@apply-value-suggestions="emit('applyValueSuggestions')"
/>
</PopoverContent>
</Popover>
@@ -7,6 +7,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN } from "@/lib/dataGrid/dataGridTextFilterPanel";
const props = defineProps<{
@@ -19,6 +21,7 @@ const props = defineProps<{
columnSearch: string;
applyOnlyBusy?: boolean;
disabled?: boolean;
valueSuggestions?: DataGridDistinctValueSuggestionState;
}>();
const emit = defineEmits<{
@@ -34,6 +37,13 @@ const emit = defineEmits<{
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
closeValueSuggestions: [];
updateValueSuggestionSearch: [value: string];
selectValueSuggestion: [option: DataGridLocalFilterOption];
toggleValueSuggestion: [option: DataGridLocalFilterOption];
toggleAllValueSuggestions: [];
applyValueSuggestions: [];
}>();
const { t } = useI18n();
@@ -108,6 +118,7 @@ function addRuleFromBlankArea(event: KeyboardEvent) {
onMounted(() => emit("ensureRule"));
onBeforeUnmount(() => {
emit("closeValueSuggestions");
stopResizeListeners();
restoreDocumentInteraction();
});
@@ -153,6 +164,7 @@ watch(
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="disabled"
:value-suggestions="valueSuggestions"
layout="text"
:show-header="false"
:show-footer="false"
@@ -165,6 +177,13 @@ watch(
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@update:column-search="emit('update:columnSearch', $event)"
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
@close-value-suggestions="emit('closeValueSuggestions')"
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
@select-value-suggestion="emit('selectValueSuggestion', $event)"
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
@apply-value-suggestions="emit('applyValueSuggestions')"
/>
</div>
@@ -19,16 +19,16 @@ describe("DataGridBusyOverlay", () => {
it("shows the elapsed seconds without a stop action by default", () => {
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 12_340 });
expect(hostText(mounted.root)).toContain("12.34s");
expect(hostText(mounted.root)).toContain("12.3s");
expect(findCancelButton(mounted.root)).toHaveLength(0);
});
it("formats long waits and guards non-finite elapsed values", async () => {
const mounted = mountComponent(DataGridBusyOverlay, { elapsedMs: 312_450 });
expect(hostText(mounted.root)).toContain("312.45s");
expect(hostText(mounted.root)).toContain("312.4s");
await mounted.setProps({ elapsedMs: Number.NaN });
expect(hostText(mounted.root)).toContain("0.00s");
expect(hostText(mounted.root)).toContain("0.0s");
});
it("stops the running load from the elapsed pill", () => {
@@ -70,7 +70,7 @@ describe("DataGridBusyOverlay", () => {
expect(hostText(mounted.root)).toContain("grid.pageJumpLoading");
expect(hostText(mounted.root)).toContain("grid.pageJumpProgress");
expect(hostText(mounted.root)).toContain("45.00s");
expect(hostText(mounted.root)).toContain("45.0s");
const progressbar = findOne(mounted.root, (node) => node.props.role === "progressbar");
expect(progressbar.props["aria-valuenow"]).toBe(3);
expect(progressbar.props["aria-valuemax"]).toBe(12);
@@ -103,6 +103,7 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
import DataGrid from "@/components/grid/DataGrid.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
@@ -113,6 +114,14 @@ import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
const cellDetailPanelSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailPanel.vue", "utf8");
const cellDetailDialogSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailDialog.vue", "utf8");
const binaryTextPreviewSource = readFileSync("apps/desktop/src/components/grid/DataGridCellDetailTextPreview.vue", "utf8");
const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
describe("DataGrid opaque aggregate-state safety", () => {
it("blocks cell edits and keyless row deletion through the centralized gates", () => {
expect(dataGridSource).toMatch(/function canEditCellItem[\s\S]*isOpaqueAggregateStateColumnType\(allColumnTypes\.value\[columnIndex\]\)/);
expect(dataGridSource).toMatch(/function canDeleteRowItem[\s\S]*canUseKeylessRowPredicate[\s\S]*hasUnsafeOpaqueAggregateStatePredicate\(allColumnTypes\.value, item\.data\)/);
});
});
function detail(patch: Partial<DataGridCellDetail> = {}): DataGridCellDetail {
return {
@@ -1133,6 +1142,50 @@ describe("DataGridQueryControls", () => {
expect(popoverContent.props["collision-padding"]).toBe(8);
});
it("forwards database value suggestions in the quick filter popover", () => {
const openValueSuggestions = vi.fn();
const closeValueSuggestions = vi.fn();
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
orderByInput: "",
columns: ["status"],
conditionColumns: ["status"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
filteredColumns: ["status"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
onOpenValueSuggestions: openValueSuggestions,
onCloseValueSuggestions: closeValueSuggestions,
});
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
const openHandler = suggestionPopover.props["onUpdate:open"];
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
const quickFilterPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === true);
const closeHandler = quickFilterPopover.props["onUpdate:open"];
for (const callback of Array.isArray(closeHandler) ? closeHandler : [closeHandler]) callback(false);
expect(closeValueSuggestions).toHaveBeenCalledOnce();
});
it("keeps filter actions available in the popover", () => {
const addRule = vi.fn();
const clearFilters = vi.fn();
@@ -1244,7 +1297,121 @@ describe("DataGridQueryControls", () => {
});
});
describe("DataGridDistinctValuePopover", () => {
it("renders counts and distinct loading, error, and limit states", async () => {
const mounted = mountComponent(DataGridDistinctValuePopover, {
open: true,
search: "",
options: [{ key: "str:open", label: "open", count: 12, value: "open" }],
loading: true,
error: "",
limited: false,
limit: 1000,
});
expect(hostText(mounted.root)).toContain("grid.loadingValues");
expect(hostText(mounted.root)).toContain("open");
expect(hostText(mounted.root)).toContain("12");
await mounted.setProps({ loading: false, error: "query failed", options: [] });
expect(hostText(mounted.root)).toContain("query failed");
expect(hostText(mounted.root)).not.toContain("grid.noSearchResults");
await mounted.setProps({ error: "", limited: true });
expect(hostText(mounted.root)).toContain("grid.serverValuesLimited");
});
it("selects all displayed candidates and matches the database-value filter checkbox state", async () => {
const toggleAll = vi.fn();
const options = [
{ key: "str:open", label: "open", count: 12, value: "open" },
{ key: "str:closed", label: "closed", count: 4, value: "closed" },
];
const mounted = mountComponent(DataGridDistinctValuePopover, {
open: true,
search: "",
options,
loading: false,
error: "",
limited: false,
limit: 1000,
multiple: true,
selectedKeys: new Set<string>(),
onToggleAll: toggleAll,
});
const findSelectAll = () => findOne(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "");
expect(findSelectAll().props["aria-checked"]).toBe(false);
dispatch(findSelectAll(), "click");
expect(toggleAll).toHaveBeenCalledOnce();
await mounted.setProps({ selectedKeys: new Set(["str:open"]) });
expect(findSelectAll().props["aria-checked"]).toBe(false);
await mounted.setProps({ selectedKeys: new Set(options.map((option) => option.key)) });
expect(findSelectAll().props["aria-checked"]).toBe(true);
});
});
describe("DataGridFilterWorkbench", () => {
it("shows database value suggestions only for enabled value rules in the conditions panel", async () => {
const openSuggestions = vi.fn();
const valueSuggestions = {
ruleId: undefined,
target: undefined,
search: "",
options: [],
loading: false,
error: "",
limited: false,
limit: 1000,
selectedKeys: new Set<string>(),
};
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "",
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["status"],
filteredColumns: ["status"],
modeOptions: [
{ value: "equals", labelKey: "equals" },
{ value: "is-null", labelKey: "is-null" },
],
columnSearch: "",
valueSuggestions,
onOpenValueSuggestions: openSuggestions,
});
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
const openHandler = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover").props["onUpdate:open"];
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
expect(openSuggestions).toHaveBeenCalledWith("r1", "value");
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND", disabled: true }] });
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "is-null", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(2);
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
});
it("exposes value suggestions in the reusable popover builder when provided", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["status"],
filteredColumns: ["status"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
layout: "popover",
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
});
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
});
it("scrolls to the newest rule when a condition is added", async () => {
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
@@ -1370,6 +1537,57 @@ describe("DataGridFilterWorkbench", () => {
});
describe("DataGridTextFilterWorkbench", () => {
it("shows and forwards database value suggestions in the text filter panel", async () => {
const openValueSuggestions = vi.fn();
const closeValueSuggestions = vi.fn();
const valueSuggestions = {
ruleId: undefined,
target: undefined,
search: "",
options: [],
loading: false,
error: "",
limited: false,
limit: 1000,
selectedKeys: new Set<string>(),
};
const mounted = mountComponent(DataGridTextFilterWorkbench, {
height: 168,
sqlPreview: "",
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["status"],
filteredColumns: ["status"],
modeOptions: [
{ value: "equals", labelKey: "equals" },
{ value: "between", labelKey: "between" },
{ value: "in", labelKey: "in" },
],
columnSearch: "",
valueSuggestions,
onOpenValueSuggestions: openValueSuggestions,
onCloseValueSuggestions: closeValueSuggestions,
});
const suggestionButtons = () => findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter");
expect(suggestionButtons()).toHaveLength(1);
expect(String(suggestionButtons()[0].props.class)).toContain("h-6");
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
const openHandler = suggestionPopover.props["onUpdate:open"];
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
expect(suggestionButtons()).toHaveLength(2);
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
expect(suggestionButtons()).toHaveLength(1);
expect(findAll(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "")).toHaveLength(1);
mounted.unmount();
expect(closeValueSuggestions).toHaveBeenCalledOnce();
});
it("exposes a persisted keyboard-resizable text filter panel", async () => {
const ensureRule = vi.fn();
const updateHeight = vi.fn();
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, ref, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
import { sortDataGridRowIndexes, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue");
return {
RecycleScroller: defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () => h("div", attrs, [slots.before?.(), props.items.map((item, index) => slots.default?.({ item, index }))]);
},
}),
};
});
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120, 120]),
totalWidth: ref(240),
columnVars: ref({ "--total-w": "240px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function menuAction(label: string): HTMLButtonElement {
const button = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')].find((candidate) => candidate.textContent?.trim() === label);
if (!button) throw new Error(`Sort menu action not found: ${label}`);
return button;
}
function transposeField(host: HTMLElement, columnIndex: number): HTMLElement {
const field = host.querySelector<HTMLElement>(`[data-grid-transpose-column-index="${columnIndex}"]`);
if (!field) throw new Error(`Transpose field not found: ${columnIndex}`);
return field.closest<HTMLElement>(".data-grid-transpose-row")!;
}
function transposeValues(host: HTMLElement, columnIndex: number): string[] {
return [...transposeField(host, columnIndex).querySelectorAll<HTMLElement>("[data-grid-transpose-cell]")].map((cell) => cell.textContent?.trim() ?? "");
}
function transposeRecordHeader(host: HTMLElement, recordIndex: number): HTMLElement {
const header = host.querySelector<HTMLElement>(`[data-grid-transpose-record-header][data-grid-transpose-record-index="${recordIndex}"]`);
if (!header) throw new Error(`Transpose record header not found: ${recordIndex}`);
return header;
}
function mountGrid() {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({ dataGridRenderMode: "dom", dataGridMultiRowTranspose: true });
const originalRows: QueryResult["rows"] = [
["b", "10"],
["a", null],
["c", "2"],
];
const result = ref<QueryResult>(
markRaw({
columns: ["name", "score"],
column_types: ["varchar", "bigint"],
rows: originalRows,
affected_rows: 0,
execution_time_ms: 0,
}),
);
const sortColumn = ref<string>();
const sortColumnIndex = ref<number>();
const sortDirection = ref<DataGridSortDirection>();
const sortMode = ref<DataGridSortMode>();
const onSort = vi.fn((column: string, columnIndex: number, direction: DataGridSortDirection | null, _whereInput?: string, mode: DataGridSortMode = "database") => {
const indexes = direction ? sortDataGridRowIndexes(originalRows, columnIndex, direction, result.value.column_types?.[columnIndex]) : originalRows.map((_, index) => index);
result.value = markRaw({ ...result.value, rows: indexes.map((index) => originalRows[index]!) });
sortColumn.value = direction ? column : undefined;
sortColumnIndex.value = direction ? columnIndex : undefined;
sortDirection.value = direction ?? undefined;
sortMode.value = direction ? mode : undefined;
});
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () =>
h(
TooltipProvider,
{ delayDuration: 0 },
{
default: () =>
h(DataGrid, {
result: result.value,
databaseType: "mysql",
context: "table-data",
sortColumn: sortColumn.value,
sortColumnIndex: sortColumnIndex.value,
sortDirection: sortDirection.value,
sortMode: sortMode.value,
tableMeta: {
tableName: "scores",
columns: [
{ name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: true, extra: null },
{ name: "score", data_type: "bigint", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
],
primaryKeys: ["name"],
},
onSort,
}),
},
);
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.mount(host);
mountedApps.push({ app, host });
return { host, onSort };
}
async function openTranspose(host: HTMLElement) {
const firstRowNumber = host.querySelector<HTMLElement>(".data-grid-row-number");
if (!firstRowNumber) throw new Error("Row number not found");
firstRowNumber.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true, button: 0 }));
firstRowNumber.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, button: 0 }));
await settle();
}
async function selectTransposeSort(host: HTMLElement, columnIndex: number, labelKey: string) {
const trigger = transposeField(host, columnIndex).querySelector<HTMLButtonElement>("[data-grid-transpose-sort]");
if (!trigger) throw new Error(`Transpose sort trigger not found: ${columnIndex}`);
trigger.click();
await settle();
menuAction(i18n.global.t(labelKey)).click();
await settle();
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
describe("DataGrid transpose sorting", () => {
it("uses the shared local/database sort actions and keeps the active record and row selection", async () => {
const { host, onSort } = mountGrid();
await settle();
await openTranspose(host);
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
const nextRecordButton = host.querySelector(".lucide-chevron-right")?.closest<HTMLButtonElement>("button");
if (!nextRecordButton) throw new Error("Next transpose record button not found");
nextRecordButton.click();
await settle();
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.sortCurrentPageAscending");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "asc", undefined, "local");
expect(transposeValues(host, 1)).toEqual(["2", "10", "NULL"]);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.sortDatabaseDescending");
expect(onSort).toHaveBeenLastCalledWith("score", 1, "desc", undefined, "database");
expect(transposeValues(host, 1)).toEqual(["10", "2", "NULL"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 2).classList.contains("transpose-record-header-active")).toBe(true);
await selectTransposeSort(host, 1, "grid.clearSort");
expect(onSort).toHaveBeenLastCalledWith("score", 1, null, undefined, "database");
expect(transposeValues(host, 1)).toEqual(["10", "NULL", "2"]);
expect(transposeRecordHeader(host, 0).classList.contains("transpose-record-header-selected")).toBe(true);
expect(transposeRecordHeader(host, 1).classList.contains("transpose-record-header-active")).toBe(true);
expect(host.querySelector(".transpose-grid-scroller")).not.toBeNull();
});
});
+43 -47
View File
@@ -48,6 +48,7 @@ import { useTabUiState } from "@/lib/tabs/tabUiState";
import { detectKvValueFormat, validateKvValue, type KvValueFormat } from "@/lib/kv/kvValueFormat";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { copyToClipboard } from "@/lib/common/clipboard";
import { saveTextFile } from "@/lib/export/saveTextFile";
interface KvKeyBrowserLabels {
prefixPlaceholder: string;
@@ -933,7 +934,7 @@ async function refreshLazyParent(parentPath: string) {
}
}
async function loadSelectedKey(input: string | KvKeyRoute) {
async function loadSelectedKey(input: string | KvKeyRoute): Promise<boolean> {
const route = routeFromKey(input);
const key = route.key;
const keyIdentity = routeIdentity(route);
@@ -950,7 +951,7 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
detailError.value = "";
try {
const result = await props.api.get(connectionId, key, route.keyBytes ? { keyBytes: route.keyBytes } : undefined);
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return;
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return false;
const lazyNode = lazyTreeState.nodeByKey.get(key);
if (lazyNode) {
lazyNode.hasValue = result.found;
@@ -958,16 +959,18 @@ async function loadSelectedKey(input: string | KvKeyRoute) {
}
if (!result.found) {
removeExpiredSelectedKey(key);
return;
return false;
}
selectedValue.value = result;
selectedKeyIdentity.value = result.keyIdentity ?? selectedKeyIdentity.value;
selectedRouteKeyBytes.value = result.keyBytes ?? selectedRouteKeyBytes.value;
startMetadataRefresh(key);
startKeyListRefresh();
return true;
} catch (error) {
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return;
if (requestId !== detailRequestId || selectedKey.value !== key || connectionId !== props.connectionId) return false;
detailError.value = formatError(error);
return false;
} finally {
if (requestId === detailRequestId && connectionId === props.connectionId) detailLoading.value = false;
}
@@ -1487,54 +1490,47 @@ function watchSelectedKey() {
});
}
function downloadText(filename: string, content: string, type = "application/json") {
const url = URL.createObjectURL(new Blob([content], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function exportSelectedKey() {
if (!selectedKey.value || !selectedValue.value?.found) return;
downloadText(
`${selectedKey.value.split("/").filter(Boolean).pop() || props.exportFallbackName}${props.exportFileExtension}`,
JSON.stringify(
{
format: props.exportFormat,
version: 1,
exportedAt: new Date().toISOString(),
prefix: selectedKey.value,
scopeKind: "key",
entries: [
{
key: selectedKeyBytes.value ?? { encoding: "utf8", data: selectedKey.value },
value: selectedValue.value.value,
metadata: selectedValue.value.metadata,
formatHint: detectKvValueFormat(selectedTextValue.value, selectedValueIsBase64.value ? "base64" : "utf8"),
},
],
},
null,
2,
),
async function exportSelectedKey(): Promise<boolean> {
if (!selectedKey.value || !selectedValue.value?.found) return false;
const filename = `${selectedKey.value.split("/").filter(Boolean).pop() || props.exportFallbackName}${props.exportFileExtension}`;
const content = JSON.stringify(
{
format: props.exportFormat,
version: 1,
exportedAt: new Date().toISOString(),
prefix: selectedKey.value,
scopeKind: "key",
entries: [
{
key: selectedKeyBytes.value ?? { encoding: "utf8", data: selectedKey.value },
value: selectedValue.value.value,
metadata: selectedValue.value.metadata,
formatHint: detectKvValueFormat(selectedTextValue.value, selectedValueIsBase64.value ? "base64" : "utf8"),
},
],
},
null,
2,
);
return saveTextFile(content, filename, "DBX KV Bundle", "json", { operation: "kv-key-export" });
}
async function exportNode(node: BrowserTreeNode) {
const request: KvExportScopeRequest = {
path: nodePath(node),
kind: nodeIsExpandable(node) ? "prefix" : "key",
keyBytes: node.kind === "lazy" ? null : node.keyBytes,
};
if (props.api.exportScope) {
await props.api.exportScope(props.connectionId, request);
return;
try {
const request: KvExportScopeRequest = {
path: nodePath(node),
kind: nodeIsExpandable(node) ? "prefix" : "key",
keyBytes: node.kind === "lazy" ? null : node.keyBytes,
};
if (props.api.exportScope) {
await props.api.exportScope(props.connectionId, request);
return;
}
if (!nodeHasValue(node)) return;
if (await loadSelectedKey(routeFromNode(node))) await exportSelectedKey();
} catch (error) {
toast(formatError(error), 4000);
}
if (!nodeHasValue(node)) return;
await loadSelectedKey(routeFromNode(node));
exportSelectedKey();
}
async function openCloneDialog() {
@@ -235,6 +235,7 @@ watch(
/>
<DataTransferDialog
v-model:open="dialogs.showTransferDialog.value"
:task-id="dialogs.transferTaskId.value"
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
:prefill-database="dialogs.transferPrefillDatabase.value"
:prefill-catalog="dialogs.transferPrefillCatalog.value"
@@ -308,6 +309,7 @@ watch(
<DataGenerateDialog
v-if="dialogs.showTableDataGenerateDialog.value"
v-model:open="dialogs.showTableDataGenerateDialog.value"
:session-id="dialogs.tableDataGenerateSessionId.value"
:prefill-connection-id="dialogs.tableDataGeneratePrefillConnectionId.value"
:prefill-database="dialogs.tableDataGeneratePrefillDatabase.value"
:prefill-schema="dialogs.tableDataGeneratePrefillSchema.value"
@@ -2,10 +2,10 @@
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree, Pin, PinOff } from "@lucide/vue";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree, Pin, PinOff, CalendarClock, Waypoints } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightDropdown, { type LightDropdownItem } from "@/components/ui/LightDropdown.vue";
import WindowControls from "@/components/layout/WindowControls.vue";
import ExportProgressPopover from "@/components/export/ExportProgressPopover.vue";
import ToolbarUpdateIcon from "@/components/layout/ToolbarUpdateIcon.vue";
@@ -73,6 +73,8 @@ const emit = defineEmits<{
"open-sql-file": [];
"open-schema-diff": [];
"open-data-compare": [];
"open-backups": [];
"open-mcp-settings": [];
}>();
const { t } = useI18n();
@@ -429,11 +431,12 @@ onBeforeUnmount(() => {
// ──────────── Left-side "More" items ────────────
const moreItems = computed(() => {
const items: Array<{ value: string; label: string; icon: any; action: () => void; disabled: boolean }> = [];
type ToolbarMenuItem = LightDropdownItem & { action: () => void };
// Hidden left-side items go into "More"
if (!toolbarItems.value.dataTransfer) {
function buildToolbarMenuItems(includeVisiblePrimaryItems: boolean): ToolbarMenuItem[] {
const items: ToolbarMenuItem[] = [];
if (includeVisiblePrimaryItems || !toolbarItems.value.dataTransfer) {
items.push({
value: "transfer",
label: t("transfer.dataTransfer"),
@@ -442,20 +445,18 @@ const moreItems = computed(() => {
disabled: !props.hasConnections,
});
}
if (!toolbarItems.value.driverManager) {
if (includeVisiblePrimaryItems || !toolbarItems.value.driverManager) {
items.push({
value: "driver-store",
label: t("toolbar.driverManager"),
label: props.agentDriverUpdateCount > 0 ? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})` : t("toolbar.driverManager"),
icon: Package,
action: () => emit("open-driver-store"),
disabled: false,
});
}
if (!toolbarItems.value.pluginCenter) {
if (includeVisiblePrimaryItems || !toolbarItems.value.pluginCenter) {
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
}
// "More" menu items (individually toggleable)
if (toolbarItems.value.sqlFile) {
items.push({
value: "sql-file",
@@ -483,52 +484,39 @@ const moreItems = computed(() => {
disabled: !props.hasConnections,
});
}
items.push({
value: "database-backups",
label: t("databaseBackup.title"),
icon: CalendarClock,
action: () => emit("open-backups"),
disabled: false,
});
items.push({
value: "mcp-settings",
label: t("settings.openMcpSettings"),
icon: Waypoints,
action: () => emit("open-mcp-settings"),
disabled: false,
});
// Append overflowed right-side items at the end
for (const ri of overflowRightMenuItems.value) {
for (const item of overflowRightMenuItems.value) {
items.push({
value: `right-${ri.value}`,
label: ri.label,
icon: ri.icon,
action: ri.action,
disabled: ri.disabled,
value: `right-${item.value}`,
label: item.label,
icon: item.icon,
action: item.action,
disabled: item.disabled,
});
}
return items;
});
}
const moreItems = computed(() => buildToolbarMenuItems(false));
const showMoreDropdown = computed(() => moreItems.value.length > 0);
const collapsedItems = computed(() => {
const items: Array<{ value: string; label: string; icon: any; action: () => void; disabled: boolean }> = [];
if (toolbarItems.value.dataTransfer) {
items.push({
value: "transfer",
label: t("transfer.dataTransfer"),
icon: ArrowLeftRight,
action: () => emit("open-transfer"),
disabled: !props.hasConnections,
});
}
if (toolbarItems.value.driverManager) {
items.push({
value: "driver-store",
label: props.agentDriverUpdateCount > 0 ? `${t("toolbar.driverManager")} (${props.agentDriverUpdateCount})` : t("toolbar.driverManager"),
icon: Package,
action: () => emit("open-driver-store"),
disabled: false,
});
}
if (toolbarItems.value.pluginCenter) {
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
}
// Always include moreItems (may contain hidden left-side items + overflowed right items)
if (moreItems.value.length > 0) {
items.push(...moreItems.value);
}
return items;
});
const collapsedItems = computed(() => buildToolbarMenuItems(true));
function runMoreItem(value: string) {
const item = moreItems.value.find((i) => i.value === value);
@@ -96,6 +96,7 @@ function preloadDataGridComponent() {
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisQueryConsoleOutput = defineAsyncComponent(() => import("@/components/redis/RedisQueryConsoleOutput.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
@@ -176,6 +177,7 @@ import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { queryResultExportBaseName } from "@/lib/export/saveTextFile";
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview } from "@/lib/mongo/mongoQueryResultEditing";
import { buildInfluxDbV1DeleteStatements, canDeleteInfluxDbV1Row, resolveInfluxDbV1DeleteTarget } from "@/lib/influxdb/influxDbV1Delete";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { useTabScroll } from "@/composables/useTabScroll";
@@ -186,7 +188,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { QueryMessage, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -613,6 +615,9 @@ const hasTabularResult = computed(() => {
if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true;
return visibleResultItems.value.length > 0;
});
const redisConsoleResults = computed(() => (props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : []));
const canShowRedisConsoleOutput = computed(() => activeEffectiveDatabaseType.value === "redis" && (props.activeTab.isExecuting || redisConsoleResults.value.some((result) => result.execution_error === true || typeof result.redis_console_output === "string")));
const redisResultViewMode = computed<RedisResultViewMode>(() => (activeEffectiveDatabaseType.value === "redis" ? (props.activeTab.uiState?.redisResultViewMode ?? "grid") : "grid"));
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
// A batch can attach server messages to more than one statement result (for
@@ -626,7 +631,7 @@ const resultMessages = computed<QueryMessage[]>(() => {
});
const resultMessageCount = computed(() => resultMessages.value.length);
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || (redisResultViewMode.value === "console" && canShowRedisConsoleOutput.value) || !props.activeTab.result || !hasTabularResult.value);
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
@@ -635,6 +640,11 @@ function updateStandaloneResultToolbarDimensions() {
standaloneResultToolbarViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
}
function setRedisResultViewMode(mode: RedisResultViewMode) {
if (activeEffectiveDatabaseType.value !== "redis") return;
queryStore.updateTabUiState(props.activeTab.id, { redisResultViewMode: mode });
}
function observeStandaloneResultToolbar() {
standaloneResultToolbarResizeObserver?.disconnect();
standaloneResultToolbarResizeObserver = undefined;
@@ -706,6 +716,45 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
return { save, preview, applySavedChanges, canInsert: false, canDelete: true, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
});
const influxDbV1DeleteSaveHandler = computed<CustomSaveHandler | undefined>(() => {
const tab = props.activeTab;
const result = tab.result;
const tableMeta = activeTableMeta.value;
const connection = connectionStore.getConfig(tab.connectionId) ?? props.activeConnection;
const database = activeDataTabExecutionDatabase.value;
if (tab.mode !== "data" || tab.tableMetaPending || !result || !tableMeta || !tab.connectionId || !database.trim()) return undefined;
const target = resolveInfluxDbV1DeleteTarget({
connection,
measurement: tableMeta.tableName,
tableColumns: tableMeta.columns,
resultColumns: result.columns,
});
if (!target) return undefined;
const preview: NonNullable<CustomSaveHandler["preview"]> = async (changes) => buildInfluxDbV1DeleteStatements(target, changes);
const save: CustomSaveHandler["save"] = async (changes) => {
const statements = buildInfluxDbV1DeleteStatements(target, changes);
if (statements.length === 0) throw new Error("InfluxDB deletion requires at least one guarded statement.");
for (const statement of statements) {
await api.executeQuery(tab.connectionId, database, statement);
}
};
return {
save,
preview,
canInsert: false,
canUpdate: false,
canDelete: true,
canDeleteRow: (_sourceIndex, row) => canDeleteInfluxDbV1Row(target, row),
confirmation: "influxdb-v1-delete",
reloadOnFailure: true,
supportsInsert: false,
readonlyColumns: [...result.columns],
targetLabel: target.measurement,
};
});
const resultsPaneOpen = ref(false);
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
// In editor-only mode the results pane is never mounted in this splitpanes,
@@ -1817,7 +1866,7 @@ defineExpose({
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
</template>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
<Popover v-if="activeOutputView === 'result' && redisResultViewMode === 'grid' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
<Wrench class="h-4 w-4" />
@@ -2096,9 +2145,12 @@ defineExpose({
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:can-show-redis-console="canShowRedisConsoleOutput"
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<QueryResultToolbarActions
class="ml-auto"
@@ -2250,6 +2302,8 @@ defineExpose({
</div>
</div>
<RedisQueryConsoleOutput v-else-if="activeOutputView === 'result' && redisResultViewMode === 'console' && canShowRedisConsoleOutput" :result="activeTab.result" :results="activeTab.results" :loading="activeTab.isExecuting" />
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="resultMessages" />
<template v-else>
@@ -2337,9 +2391,12 @@ defineExpose({
:can-show-summary="hasExecutionSummary"
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
:can-show-messages="canShowMessagesOutput"
:can-show-redis-console="canShowRedisConsoleOutput"
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="compact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<template v-if="activeElasticsearchRawBody">
<div class="mx-1 h-4 w-px bg-border" />
@@ -2731,7 +2788,8 @@ defineExpose({
:initial-order-by-input="activeTab.orderByInput"
:sql="activeTab.sql"
:loading="activeTab.isExecuting"
:editable="!activeTab.tableMetaPending && isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType)"
:editable="!activeTab.tableMetaPending && (isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType) || !!influxDbV1DeleteSaveHandler)"
:custom-save-handler="influxDbV1DeleteSaveHandler"
context="table-data"
:initial-where-input="activeTab.whereInput"
:database-type="activeEffectiveDatabaseType"
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { BarChart3, ListChecks, MessageSquareText } from "@lucide/vue";
import { BarChart3, ListChecks, MessageSquareText, SquareTerminal } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { RedisResultViewMode } from "@/types/database";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
@@ -15,32 +16,65 @@ const props = withDefaults(
canShowSummary: boolean;
canShowChart: boolean;
canShowMessages: boolean;
canShowRedisConsole?: boolean;
resultMode?: RedisResultViewMode;
messageCount?: number;
compact?: boolean;
}>(),
{ compact: false, messageCount: 0 },
{ compact: false, messageCount: 0, canShowRedisConsole: false, resultMode: "grid" },
);
const emit = defineEmits<{
selectView: [view: PrimaryResultView];
selectResultMode: [mode: RedisResultViewMode];
}>();
const { t } = useI18n();
const messagesTooltip = computed(() => (props.messageCount > 0 ? `${t("tabs.messages")} (${props.messageCount})` : t("tabs.messages")));
const effectiveResultMode = computed<RedisResultViewMode>(() => (props.canShowRedisConsole ? props.resultMode : "grid"));
function selectView(view: PrimaryResultView) {
if (props.activeView === view) return;
emit("selectView", view);
}
function selectResultMode(mode: RedisResultViewMode) {
if (props.activeView === "result" && effectiveResultMode.value === mode) return;
if (props.canShowRedisConsole && props.resultMode !== mode) emit("selectResultMode", mode);
if (props.activeView !== "result") emit("selectView", "result");
}
</script>
<template>
<div data-query-result-view-switcher class="flex shrink-0 items-center gap-1 px-1">
<Button size="sm" :variant="activeView === 'result' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :disabled="!canShowResult" :aria-pressed="activeView === 'result'" @click="selectView('result')">
<Button
size="sm"
:variant="activeView === 'result' && effectiveResultMode === 'grid' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 px-2 text-xs leading-none"
:disabled="!canShowResult"
:aria-pressed="activeView === 'result' && effectiveResultMode === 'grid'"
@click="canShowRedisConsole ? selectResultMode('grid') : selectView('result')"
>
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
</Button>
<LightTooltip v-if="canShowRedisConsole" :text="t('redis.commandLine')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
:variant="activeView === 'result' && effectiveResultMode === 'console' ? 'secondary' : 'ghost'"
class="h-5 shrink-0 text-xs leading-none"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('redis.commandLine')"
:aria-label="t('redis.commandLine')"
:aria-pressed="activeView === 'result' && effectiveResultMode === 'console'"
@click="selectResultMode('console')"
>
<SquareTerminal class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("redis.commandLine") }}</span>
</Button>
</LightTooltip>
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
size="sm"
@@ -15,7 +15,7 @@ import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { translateBackendError } from "@/i18n/backend-errors";
import { copyToClipboard } from "@/lib/common/clipboard";
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
@@ -256,7 +256,7 @@ async function openFile(path: string) {
if (!isTauriRuntime()) return;
try {
const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb));
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
const target = resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema);
} catch (e: any) {
if (isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)) {
@@ -271,7 +271,7 @@ async function openFile(path: string) {
// Open the App-level SQL file execution dialog with this file pre-selected so
// the user can review its statements and pick a connection/database before run.
function executeFile(path: string) {
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
const target = resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId));
connectionStore.sqlFileSource = {
connectionId: target.connectionId,
database: target.database,
@@ -2,10 +2,12 @@
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDownWideNarrow, ChevronsDownUp, Download, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Library, Loader2, LocateFixed, Pencil, Play, Search, Trash2, Upload, X } from "@lucide/vue";
import { ArrowDownWideNarrow, ArrowRightLeft, ChevronsDownUp, Database, Download, FilePlus, FileText, FolderCog, FolderClosed, FolderOpen, FolderPlus, Layers, Library, Loader2, LocateFixed, Pencil, Play, Search, Trash2, Upload, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SearchableSelect } from "@/components/ui/searchable-select";
import CustomContextMenu, { type ContextMenuItem as CtxMenuItem } from "@/components/ui/CustomContextMenu.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import HelpTooltip from "@/components/ui/tooltip/HelpTooltip.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { useToast } from "@/composables/useToast";
@@ -20,7 +22,7 @@ import { externalSqlEditorMaxBytes } from "@/lib/sql/sqlFileOpen";
import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlBatchErrorMessage, savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
@@ -28,6 +30,9 @@ import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/l
import { uniqueSavedSqlExportFileName, exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { catalogDatabaseOptionsKey, normalizedQueryTabCatalog, queryCatalogSelectorVisible, useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { formatDatabaseLabel } from "@/lib/database/defaultDatabase";
import { sqlExecutionTargetCapabilities, targetDefaultDatabase, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities";
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
const { t } = useI18n();
@@ -36,6 +41,7 @@ const savedSqlStore = useSavedSqlStore();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOptions, loadingCatalogOptions, loadCatalogOptions, catalogDatabaseOptions, loadingCatalogDatabaseOptions, loadCatalogDatabaseOptions } = useDatabaseOptions();
const emit = defineEmits<{
close: [];
@@ -516,6 +522,140 @@ const allSelectableItems = computed(() => {
const hasSelection = computed(() => selectedFileIds.value.size > 0 || selectedFolderIds.value.size > 0);
const selectedCount = computed(() => selectedFileIds.value.size + selectedFolderIds.value.size);
const showChangeTargetDialog = ref(false);
const changeTargetFileIds = ref<string[]>([]);
const changeTargetConnectionId = ref("");
const changeTargetCatalog = ref("");
const changeTargetDatabase = ref("");
const changingTarget = ref(false);
const changeTargetConnections = computed(() => connectionStore.connections.filter((connection) => !!sqlExecutionTargetCapabilities(connection)));
const changeTargetConnection = computed(() => changeTargetConnections.value.find((connection) => connection.id === changeTargetConnectionId.value));
const changeTargetCapabilities = computed(() => sqlExecutionTargetCapabilities(changeTargetConnection.value));
const changeTargetCatalogs = computed(() => catalogOptions.value[changeTargetConnectionId.value] ?? []);
const normalizedChangeTargetCatalog = computed(() => normalizedQueryTabCatalog(changeTargetCatalogs.value, changeTargetCatalog.value));
const showChangeTargetCatalog = computed(() => {
if (!changeTargetCapabilities.value?.supportsCatalog) return false;
return changeTargetCatalogs.value.length === 0 || queryCatalogSelectorVisible(changeTargetCatalogs.value);
});
const changeTargetCatalogNames = computed(() => {
const names = changeTargetCatalogs.value.map((catalog) => catalog.name);
return changeTargetCatalog.value && !names.includes(changeTargetCatalog.value) ? [changeTargetCatalog.value, ...names] : names;
});
const changeTargetDatabaseCacheKey = computed(() => (normalizedChangeTargetCatalog.value ? catalogDatabaseOptionsKey(changeTargetConnectionId.value, normalizedChangeTargetCatalog.value) : changeTargetConnectionId.value));
const changeTargetDatabaseNames = computed(() => {
const names = normalizedChangeTargetCatalog.value ? (catalogDatabaseOptions.value[changeTargetDatabaseCacheKey.value] ?? []) : (databaseOptions.value[changeTargetConnectionId.value] ?? []);
return changeTargetDatabase.value && !names.includes(changeTargetDatabase.value) ? [changeTargetDatabase.value, ...names] : names;
});
const changeTargetDatabaseLoading = computed(() => (normalizedChangeTargetCatalog.value ? (loadingCatalogDatabaseOptions.value[changeTargetDatabaseCacheKey.value] ?? false) : (loadingDatabaseOptions.value[changeTargetConnectionId.value] ?? false)));
const showChangeTargetDatabase = computed(() => !!changeTargetCapabilities.value?.supportsDatabase && !targetUsesConnectionOnlyScope(changeTargetConnection.value));
const canConfirmChangeTarget = computed(() => {
if (changingTarget.value || changeTargetFileIds.value.length === 0 || !changeTargetConnection.value || !changeTargetCapabilities.value) return false;
return !changeTargetCapabilities.value.databaseRequired || !!changeTargetDatabase.value;
});
function resetChangeTargetConnection(connectionId: string) {
const connection = changeTargetConnections.value.find((candidate) => candidate.id === connectionId);
changeTargetConnectionId.value = connection?.id ?? "";
changeTargetCatalog.value = "";
changeTargetDatabase.value = connection && !targetUsesConnectionOnlyScope(connection) ? targetDefaultDatabase(connection) : "";
}
function openChangeTarget(fileIds: readonly string[]) {
const selectedFiles = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
if (selectedFiles.length === 0) return;
changeTargetFileIds.value = selectedFiles.map((file) => file.id);
const first = selectedFiles[0]!;
const commonConnectionId = selectedFiles.every((file) => file.connectionId === first.connectionId) && changeTargetConnections.value.some((connection) => connection.id === first.connectionId) ? first.connectionId : "";
const fallbackConnectionId = [connectionStore.activeConnectionId, changeTargetConnections.value[0]?.id].find((id) => !!id && changeTargetConnections.value.some((connection) => connection.id === id)) ?? "";
resetChangeTargetConnection(commonConnectionId || fallbackConnectionId);
if (commonConnectionId) {
if (selectedFiles.every((file) => (file.catalog || "") === (first.catalog || ""))) changeTargetCatalog.value = first.catalog || "";
if (selectedFiles.every((file) => file.database === first.database)) changeTargetDatabase.value = first.database;
}
showChangeTargetDialog.value = true;
}
function cancelChangeTarget() {
if (changingTarget.value) return;
showChangeTargetDialog.value = false;
changeTargetFileIds.value = [];
}
async function loadChangeTargetCatalogs() {
if (!changeTargetConnection.value || !changeTargetCapabilities.value?.supportsCatalog) return;
try {
await loadCatalogOptions(changeTargetConnection.value.id);
} catch (error) {
toast(t("sqlLibrary.changeTargetLoadFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
}
}
async function loadChangeTargetDatabases() {
const connection = changeTargetConnection.value;
if (!connection || targetUsesConnectionOnlyScope(connection)) return;
try {
if (normalizedChangeTargetCatalog.value) await loadCatalogDatabaseOptions(connection.id, normalizedChangeTargetCatalog.value);
else await loadDatabaseOptions(connection.id);
} catch (error) {
toast(t("sqlLibrary.changeTargetLoadFailed", { message: savedSqlErrorMessage(error, t) }), 5000);
}
}
function selectChangeTargetCatalog(catalog: string) {
if (changeTargetCatalog.value === catalog) return;
changeTargetCatalog.value = catalog;
changeTargetDatabase.value = "";
}
function changeTargetDatabaseLabel(database: string) {
return formatDatabaseLabel(changeTargetConnection.value, database, {
defaultDatabase: t("editor.defaultDatabase"),
noDatabase: t("editor.noDatabase"),
});
}
async function executeChangeTarget() {
if (!canConfirmChangeTarget.value) return;
const connection = changeTargetConnection.value;
if (!connection || !connectionStore.getConfig(connection.id)) {
cancelChangeTarget();
return;
}
changingTarget.value = true;
try {
const result = await savedSqlStore.updateFilesExecutionTarget(changeTargetFileIds.value, {
connectionId: connection.id,
database: targetUsesConnectionOnlyScope(connection) ? "" : changeTargetDatabase.value,
catalog: changeTargetCapabilities.value?.supportsCatalog ? normalizedChangeTargetCatalog.value : undefined,
});
showChangeTargetDialog.value = false;
changeTargetFileIds.value = [];
if (result.failures.length === 0) {
if (result.succeeded.length === 0) return;
clearSelection();
toast(t("sqlLibrary.changeTargetSuccess", { count: result.succeeded.length }), 2500);
return;
}
selectedFileIds.value = new Set(result.failures.map((failure) => failure.fileId));
selectedFolderIds.value = new Set();
const message = savedSqlBatchErrorMessage(result.failures, t);
if (result.succeeded.length > 0) {
toast(t("sqlLibrary.changeTargetPartial", { count: result.succeeded.length, failed: result.failures.length, message }), 8000);
} else {
toast(t("sqlLibrary.changeTargetFailed", { message }), 8000);
}
} catch (error) {
toast(t("sqlLibrary.changeTargetFailed", { message: savedSqlErrorMessage(error, t) }), 8000);
} finally {
changingTarget.value = false;
}
}
function clearSelection() {
selectedFileIds.value = new Set();
@@ -860,6 +1000,12 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
if (hasSelection.value) {
const selectedFiles = Array.from(selectedFileIds.value);
return [
{
label: t("sqlLibrary.changeTarget", { count: selectedFiles.length }),
action: () => openChangeTarget(selectedFiles),
icon: ArrowRightLeft,
visible: selectedFiles.length > 0,
},
{
label: t("sqlLibrary.moveSelectedToFolder", { count: selectedFiles.length }),
icon: FolderClosed,
@@ -904,6 +1050,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
icon: Play,
disabled: !hasCurrentSavedSqlExecutionTarget.value,
},
{ label: t("sqlLibrary.changeTarget", { count: 1 }), action: () => openChangeTarget([target.id]), icon: ArrowRightLeft },
{ label: t("sqlLibrary.exportFile"), action: () => exportSingleFile(target), icon: Upload },
{ label: t("sqlLibrary.moveToFolder"), icon: FolderClosed, children: folderMoveMenuItems([target.id]) },
{ label: "", separator: true },
@@ -1479,6 +1626,81 @@ function showDropInside(targetId: string) {
</DialogContent>
</Dialog>
<Dialog :open="showChangeTargetDialog" @update:open="(open) => (open ? (showChangeTargetDialog = true) : cancelChangeTarget())">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("sqlLibrary.changeTargetTitle") }}</DialogTitle>
<DialogDescription>{{ t("sqlLibrary.changeTargetDescription", { count: changeTargetFileIds.length }) }}</DialogDescription>
</DialogHeader>
<div v-if="changeTargetConnections.length" class="space-y-4 py-2">
<div class="space-y-1.5">
<label class="text-sm font-medium">{{ t("sqlLibrary.targetConnection") }}</label>
<ConnectionTreeSelect
:model-value="changeTargetConnectionId"
:connections="changeTargetConnections"
:layout="connectionStore.sidebarLayout"
:placeholder="t('editor.selectConnection')"
:search-placeholder="t('editor.searchConnection')"
:empty-text="t('grid.noSearchResults')"
:disabled="changingTarget"
trigger-class="h-8 w-full max-w-none justify-between border border-input bg-background px-2.5 text-sm"
list-class="w-[410px] max-w-[calc(100vw-2rem)]"
@update:model-value="resetChangeTargetConnection"
/>
</div>
<div v-if="showChangeTargetCatalog" class="space-y-1.5">
<label class="text-sm font-medium">{{ t("sqlLibrary.targetCatalog") }}</label>
<SearchableSelect
:model-value="changeTargetCatalog"
:options="changeTargetCatalogNames"
:placeholder="t('editor.selectCatalog')"
:search-placeholder="t('editor.searchCatalog')"
:empty-text="t('grid.noSearchResults')"
:loading-text="t('common.loading')"
:loading="loadingCatalogOptions[changeTargetConnectionId] || false"
:disabled="changingTarget"
@update:model-value="selectChangeTargetCatalog"
@update:open="(open: boolean) => open && loadChangeTargetCatalogs()"
>
<template #trigger-label="{ label, loading }">
<Layers class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
</template>
</SearchableSelect>
</div>
<div v-if="showChangeTargetDatabase" class="space-y-1.5">
<label class="text-sm font-medium">{{ t("sqlLibrary.targetDatabase") }}</label>
<SearchableSelect
:model-value="changeTargetDatabase"
:options="changeTargetDatabaseNames"
:placeholder="t('editor.selectDatabase')"
:search-placeholder="t('editor.searchDatabase')"
:empty-text="t('grid.noSearchResults')"
:loading-text="t('common.loading')"
:loading="changeTargetDatabaseLoading"
:disabled="changingTarget || !changeTargetConnection"
:display-name="changeTargetDatabaseLabel"
@update:model-value="(database) => (changeTargetDatabase = database)"
@update:open="(open: boolean) => open && loadChangeTargetDatabases()"
>
<template #trigger-label="{ label, loading }">
<Database class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
</template>
</SearchableSelect>
</div>
</div>
<p v-else class="py-4 text-sm text-muted-foreground">{{ t("sqlLibrary.noTargetConnection") }}</p>
<DialogFooter>
<Button variant="outline" size="sm" :disabled="changingTarget" @click="cancelChangeTarget">{{ t("dangerDialog.cancel") }}</Button>
<Button size="sm" :disabled="!canConfirmChangeTarget" @click="executeChangeTarget">
<Loader2 v-if="changingTarget" class="mr-1.5 h-3.5 w-3.5 animate-spin" />
{{ t("sqlLibrary.changeTargetConfirm") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Batch Delete Confirmation Dialog -->
<Dialog :open="showBatchDeleteConfirm" @update:open="(open) => (showBatchDeleteConfirm = open)">
<DialogContent>
@@ -50,7 +50,12 @@ vi.mock("@/components/ui/tooltip", () => ({
TooltipContent: { name: "TooltipContentStub", template: `<span><slot /></span>` },
}));
vi.mock("@/components/ui/LightDropdown.vue", () => ({
default: { name: "LightDropdownStub", template: `<div />` },
default: {
name: "LightDropdownStub",
props: ["items"],
emits: ["update:modelValue"],
template: `<div data-light-dropdown><button v-for="item in items" :key="item.value" :data-toolbar-menu-item="item.value" :disabled="item.disabled" @click="$emit('update:modelValue', item.value)">{{ item.label }}</button></div>`,
},
}));
vi.mock("@/components/layout/WindowControls.vue", () => ({
default: { name: "WindowControlsStub", template: `<div />` },
@@ -215,4 +220,30 @@ describe("always-on-top button visibility", () => {
unmount();
});
it("exposes backup and MCP shortcuts from the more menu", async () => {
const openBackups = vi.fn();
const openMcpSettings = vi.fn();
const { host, unmount } = mount(AppToolbar, {
...toolbarProps,
onOpenBackups: openBackups,
onOpenMcpSettings: openMcpSettings,
});
await settled();
await nextTick();
const backupButton = host.querySelector<HTMLButtonElement>('[data-toolbar-menu-item="database-backups"]');
const mcpButton = host.querySelector<HTMLButtonElement>('[data-toolbar-menu-item="mcp-settings"]');
expect(backupButton).not.toBeNull();
expect(mcpButton).not.toBeNull();
backupButton?.click();
mcpButton?.click();
expect(openBackups).toHaveBeenCalledOnce();
expect(openMcpSettings).toHaveBeenCalledOnce();
unmount();
});
});
@@ -0,0 +1,192 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ColumnInfo, ConnectionConfig, QueryResult, QueryTab } from "@/types/database";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import { INFLUXDB_V1_FIELD_COLUMN_EXTRA, INFLUXDB_V1_TAG_COLUMN_EXTRA, INFLUXDB_V1_TIME_COLUMN_EXTRA } from "@/lib/influxdb/influxDbV1Delete";
const mocks = vi.hoisted(() => ({ executeQuery: vi.fn(), handler: undefined as unknown }));
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/grid/DataGridColumnLayoutPopover.vue", () => ({ default: { render: () => null } }));
vi.mock("@/lib/backend/api", async (importOriginal) => ({ ...(await importOriginal<typeof import("@/lib/backend/api")>()), executeQuery: mocks.executeQuery }));
vi.mock("@/components/grid/DataGrid.vue", () => ({
__esModule: true,
default: defineComponent({
name: "DataGridStub",
props: {
editable: Boolean,
customSaveHandler: Object,
result: Object,
},
setup(props) {
return () => {
const handler = props.customSaveHandler as CustomSaveHandler | undefined;
mocks.handler = handler;
const result = props.result as QueryResult;
return h("div", {
"data-test": "data-grid",
"data-editable": String(props.editable),
"data-delete-handler": handler?.confirmation ?? "none",
"data-can-update": String(handler?.canUpdate ?? false),
"data-reload-on-failure": String(handler?.reloadOnFailure ?? false),
"data-readonly-columns": handler?.readonlyColumns?.join(",") ?? "",
"data-first-row-deletable": String(handler?.canDeleteRow?.(0, result.rows[0] ?? []) ?? false),
});
};
},
}),
}));
import ContentArea from "../ContentArea.vue";
import { useConnectionStore } from "@/stores/connectionStore";
const mounted: Array<{ app: App<Element>; host: HTMLElement }> = [];
function column(name: string, extra: string, primary = false): ColumnInfo {
return {
name,
data_type: extra === INFLUXDB_V1_TIME_COLUMN_EXTRA ? "timestamp" : "string",
is_nullable: extra !== INFLUXDB_V1_TIME_COLUMN_EXTRA,
column_default: null,
is_primary_key: primary,
extra,
};
}
function connection(db_type: ConnectionConfig["db_type"], version?: string): ConnectionConfig {
return {
id: `connection-${db_type}-${version ?? "legacy"}`,
name: "Influx test",
db_type,
host: "localhost",
port: 8086,
username: "",
password: "",
database: "metrics",
external_config: version === undefined ? undefined : { version },
};
}
function dataTab(connectionId: string, options: { tagValue?: string | null; metadataRole?: string } = {}): QueryTab {
const columns = [column("time", INFLUXDB_V1_TIME_COLUMN_EXTRA, true), column("host", options.metadataRole ?? INFLUXDB_V1_TAG_COLUMN_EXTRA, true), column("usage", INFLUXDB_V1_FIELD_COLUMN_EXTRA)];
return {
id: "data-cpu",
title: "cpu",
connectionId,
database: "metrics",
mode: "data",
sql: "cpu",
isExecuting: false,
result: {
columns: ["time", "host", "usage"],
rows: [["2026-09-27T10:11:12Z", options.tagValue === undefined ? "web01" : options.tagValue, 42]],
affected_rows: 1,
execution_time_ms: 1,
},
tableMeta: { tableName: "cpu", columns, primaryKeys: ["time", "host"] },
};
}
async function mount(connectionConfig: ConnectionConfig, tab = dataTab(connectionConfig.id)) {
const pinia = createPinia();
setActivePinia(pinia);
useConnectionStore().connections = [connectionConfig];
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup: () => () =>
h(ContentArea, {
activeTab: tab,
activeConnection: connectionConfig,
activeOutputView: "result",
executableSql: "",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
}),
}),
);
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mounted.push({ app, host });
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
await nextTick();
const grid = host.querySelector<HTMLElement>('[data-test="data-grid"]');
expect(grid).not.toBeNull();
return grid!;
}
afterEach(() => {
for (const { app, host } of mounted.splice(0)) {
app.unmount();
host.remove();
}
mocks.executeQuery.mockReset();
mocks.handler = undefined;
localStorage.clear();
});
describe("ContentArea InfluxDB 1.x delete gating", () => {
it("enables only the delete handler while keeping every value column read-only", async () => {
const grid = await mount(connection("influxdb", "1"));
expect(grid.dataset.editable).toBe("true");
expect(grid.dataset.deleteHandler).toBe("influxdb-v1-delete");
expect(grid.dataset.canUpdate).toBe("false");
expect(grid.dataset.reloadOnFailure).toBe("true");
expect(grid.dataset.readonlyColumns).toBe("time,host,usage");
expect(grid.dataset.firstRowDeletable).toBe("true");
});
it("executes the guarded preview verbatim and propagates backend failures", async () => {
const config = connection("influxdb", "1");
await mount(config);
const handler = mocks.handler as CustomSaveHandler;
const changes = {
dirtyRows: new Map(),
newRows: [],
newRowMeta: [],
deletedRows: new Set([0]),
columns: ["time", "host", "usage"],
rows: [["2026-09-27T10:11:12Z", "web01", 42]],
};
const statements = await handler.preview!(changes);
mocks.executeQuery.mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 });
await handler.save(changes);
expect(mocks.executeQuery).toHaveBeenCalledWith(config.id, "metrics", statements[0]);
mocks.executeQuery.mockRejectedValueOnce(new Error("InfluxDB rejected the delete"));
await expect(handler.save(changes)).rejects.toThrow("InfluxDB rejected the delete");
});
it("keeps a row with a missing tag unavailable for deletion", async () => {
const config = connection("influxdb", "1");
const grid = await mount(config, dataTab(config.id, { tagValue: null }));
expect(grid.dataset.deleteHandler).toBe("influxdb-v1-delete");
expect(grid.dataset.firstRowDeletable).toBe("false");
});
it.each([
{ dbType: "influxdb" as const, version: "2", metadataRole: INFLUXDB_V1_TAG_COLUMN_EXTRA, existingEditable: false },
{ dbType: "influxdb3" as const, version: "3", metadataRole: INFLUXDB_V1_TAG_COLUMN_EXTRA, existingEditable: true },
{ dbType: "influxdb" as const, version: "1", metadataRole: "", existingEditable: false },
])("does not add the delete path for an unsupported engine or metadata shape ($dbType/$version)", async ({ dbType, version, metadataRole, existingEditable }) => {
const config = connection(dbType, version);
const grid = await mount(config, dataTab(config.id, { metadataRole }));
expect(grid.dataset.editable).toBe(String(existingEditable));
expect(grid.dataset.deleteHandler).toBe("none");
expect(grid.dataset.firstRowDeletable).toBe("false");
});
});
@@ -42,7 +42,9 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
}));
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { ConnectionConfig } from "@/types/database";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
@@ -84,6 +86,12 @@ function mountBar(
en: {
toolbar: { pluginCenter: "Plugin Center" },
common: { close: "Close" },
connectionGroup: { ungroupedLabel: "Ungrouped" },
tabs: {
tooltipConnection: "Connection:",
tooltipGroup: "Group:",
tooltipDatabase: "Database:",
},
contextMenu: {
splitRight: "Split right",
splitDown: "Split down",
@@ -244,7 +252,10 @@ describe("EditorGroupTabBar behavior", () => {
it("keeps the tooltip for a connection-bound plugin tab", async () => {
const store = useQueryStore();
const tabId = store.openPluginWorkbench("com.example.toolbox", "toolbox", { connectionId: "pg-1" });
const connectionStore = useConnectionStore();
connectionStore.connections = [{ id: "plugin-1", name: "SSH server", db_type: "plugin" } as ConnectionConfig];
connectionStore.sidebarLayout = { groups: [], order: [{ type: "connection", id: "plugin-1" }] };
const tabId = store.openPluginWorkbench("com.example.toolbox", "toolbox", { connectionId: "plugin-1" });
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [tabId], tabId, pinia);
await settle();
@@ -255,6 +266,8 @@ describe("EditorGroupTabBar behavior", () => {
expect(tooltip.dataset.open).toBe("true");
expect(host.querySelector("[data-plugin-title-tooltip]")).toBeNull();
expect(host.querySelector<HTMLElement>(".tooltip-content-stub")?.textContent).toContain("SSH server");
expect(host.querySelector<HTMLElement>(".tooltip-content-stub")?.textContent).not.toContain("Database:");
app.unmount();
host.remove();
@@ -0,0 +1,81 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
import type { RedisResultViewMode, TabOutputView } from "@/types/database";
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mounted.splice(0)) {
unmount();
host.remove();
}
});
async function mountSwitcher(canShowRedisConsole: boolean) {
const state = reactive<{ view: TabOutputView; mode?: RedisResultViewMode }>({ view: "result" });
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp(
defineComponent({
setup: () => () =>
h(QueryResultViewSwitcher, {
activeView: state.view,
canShowResult: true,
canShowSummary: false,
canShowChart: false,
canShowMessages: false,
canShowRedisConsole,
resultMode: state.mode,
onSelectView: (view: TabOutputView) => {
state.view = view;
},
onSelectResultMode: (mode: RedisResultViewMode) => {
state.mode = mode;
},
}),
}),
);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, redis: { commandLine: "Command line" } } },
}),
);
app.mount(host);
mounted.push({ unmount: () => app.unmount(), host });
await nextTick();
return { host, state };
}
describe("QueryResultViewSwitcher Redis mode", () => {
it("defaults Redis results to the grid and switches through the existing toolbar contract", async () => {
const { host, state } = await mountSwitcher(true);
const grid = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Table Data"));
const console = host.querySelector<HTMLButtonElement>('button[aria-label="Command line"]');
expect(state.mode).toBeUndefined();
expect(grid?.getAttribute("aria-pressed")).toBe("true");
expect(console?.getAttribute("aria-pressed")).toBe("false");
console?.click();
await nextTick();
expect(state.mode).toBe("console");
expect(state.view).toBe("result");
expect(console?.getAttribute("aria-pressed")).toBe("true");
grid?.click();
await nextTick();
expect(state.mode).toBe("grid");
expect(grid?.getAttribute("aria-pressed")).toBe("true");
});
it("does not expose the Redis-only mode on generic result toolbars", async () => {
const { host } = await mountSwitcher(false);
expect(host.querySelector('button[aria-label="Command line"]')).toBeNull();
});
});
+115 -11
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchanges, mqListTopics, mqUnbind } from "@/lib/backend/api";
import type { MqBindingInfo, MqExchangeInfo, MqExchangeType, MqListPageRequest, NamespaceRef, TopicInfo } from "@/types/mq";
import { mqBind, mqCreateExchange, mqDeleteExchange, mqListBindings, mqListExchangesPage, mqListTopicsPage, mqUnbind } from "@/lib/backend/api";
import { formatError } from "@/lib/backend/errorUtils";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import { isAllVhostsNamespace, resolveMqRowNamespace } from "@/lib/mq/mqConsoleDefaults";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import MqListPagination from "./shared/MqListPagination.vue";
const RABBITMQ_DEFAULT_PAGE_SIZE = 100;
const RABBITMQ_SEARCH_DEBOUNCE_MS = 250;
interface Props {
connectionId: string;
@@ -25,6 +29,12 @@ const loading = ref(false);
const error = ref<string>();
const exchangeSearch = ref("");
const selectedExchange = ref<MqExchangeInfo>();
const exchangePage = ref(1);
const exchangePageSize = ref(RABBITMQ_DEFAULT_PAGE_SIZE);
const exchangeTotalCount = ref(0);
const exchangeHasMore = ref(false);
let exchangeLoadSequence = 0;
let exchangeSearchTimer: ReturnType<typeof setTimeout> | undefined;
const bindings = ref<MqBindingInfo[]>([]);
const bindingsLoading = ref(false);
@@ -58,6 +68,9 @@ const unbindTarget = ref<MqBindingInfo>();
const unbinding = ref(false);
const availableQueues = ref<TopicInfo[]>([]);
const availableQueueTotal = ref(0);
let queueLoadSequence = 0;
let queueSearchTimer: ReturnType<typeof setTimeout> | undefined;
const filteredExchanges = computed(() => {
const query = exchangeSearch.value.trim().toLowerCase();
@@ -92,24 +105,60 @@ async function guardWritable(operation: string): Promise<boolean> {
async function loadExchanges() {
const ns = nsRef();
if (!ns) {
exchangeLoadSequence += 1;
exchanges.value = [];
exchangeTotalCount.value = 0;
exchangeHasMore.value = false;
return;
}
const loadSequence = ++exchangeLoadSequence;
loading.value = true;
error.value = undefined;
try {
exchanges.value = await mqListExchanges(props.connectionId, ns);
const pagination: MqListPageRequest = {
page: exchangePage.value,
pageSize: exchangePageSize.value,
search: exchangeSearch.value.trim() || undefined,
sort: "name",
sortDescending: false,
};
const result = await mqListExchangesPage(props.connectionId, ns, pagination);
if (loadSequence !== exchangeLoadSequence) return;
const lastPage = Math.max(1, Math.ceil(result.totalCount / result.pageSize));
if (result.page > lastPage) {
exchangePage.value = lastPage;
void loadExchanges();
return;
}
exchanges.value = result.items;
exchangePage.value = result.page;
exchangePageSize.value = result.pageSize;
exchangeTotalCount.value = result.totalCount;
exchangeHasMore.value = result.hasMore;
if (selectedExchange.value && !exchanges.value.some((exchange) => exchange.name === selectedExchange.value?.name && exchange.namespace === selectedExchange.value?.namespace)) {
selectedExchange.value = undefined;
bindings.value = [];
}
} catch (e: unknown) {
if (loadSequence !== exchangeLoadSequence) return;
error.value = formatError(e);
} finally {
loading.value = false;
if (loadSequence === exchangeLoadSequence) loading.value = false;
}
}
function changeExchangePage(page: number) {
if (page < 1 || page === exchangePage.value) return;
exchangePage.value = page;
void loadExchanges();
}
function changeExchangePageSize(pageSize: number) {
exchangePageSize.value = pageSize;
exchangePage.value = 1;
void loadExchanges();
}
async function loadBindings(exchange: MqExchangeInfo) {
const ns = nsRefFor(exchange);
if (!ns) return;
@@ -124,12 +173,28 @@ async function loadBindings(exchange: MqExchangeInfo) {
}
}
async function loadQueues() {
async function loadQueues(search = "") {
const ns = nsRefFor(selectedExchange.value);
if (!ns) return;
const loadSequence = ++queueLoadSequence;
try {
availableQueues.value = await mqListTopics(props.connectionId, ns, { includeNonPersistent: false });
const result = await mqListTopicsPage(
props.connectionId,
ns,
{ includeNonPersistent: false },
{
page: 1,
pageSize: RABBITMQ_DEFAULT_PAGE_SIZE,
search: search.trim() || undefined,
sort: "name",
sortDescending: false,
},
);
if (loadSequence !== queueLoadSequence) return;
availableQueues.value = result.items;
availableQueueTotal.value = result.totalCount;
} catch (e: unknown) {
if (loadSequence !== queueLoadSequence) return;
console.warn("[DBX] Failed to load queues for binding dialog:", e);
}
}
@@ -224,7 +289,6 @@ function openBindDialog() {
if (!selectedExchange.value) return;
dialogError.value = undefined;
bindForm.value = { destinationType: "queue", destination: "", routingKey: "", argumentsText: "" };
void loadQueues();
showBindDialog.value = true;
}
@@ -312,20 +376,50 @@ function formatBindingArguments(bindingRow: MqBindingInfo): string {
watch(
() => [props.tenant, props.namespace],
() => {
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
exchangePage.value = 1;
selectedExchange.value = undefined;
bindings.value = [];
loadExchanges();
},
{ immediate: true },
);
watch(exchangeSearch, () => {
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
exchangeSearchTimer = setTimeout(() => {
exchangeSearchTimer = undefined;
exchangePage.value = 1;
void loadExchanges();
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
});
watch(
() => [bindForm.value.destination, bindForm.value.destinationType, showBindDialog.value] as const,
([destination, destinationType, open]) => {
if (queueSearchTimer) clearTimeout(queueSearchTimer);
if (!open || destinationType !== "queue") return;
queueSearchTimer = setTimeout(() => {
queueSearchTimer = undefined;
void loadQueues(destination);
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
},
);
onBeforeUnmount(() => {
exchangeLoadSequence += 1;
queueLoadSequence += 1;
if (exchangeSearchTimer) clearTimeout(exchangeSearchTimer);
if (queueSearchTimer) clearTimeout(queueSearchTimer);
});
</script>
<template>
<div class="exchanges-panel">
<div class="panel-toolbar">
<div class="toolbar-left">
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length" />
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchanges.length }}</span>
<input v-model="exchangeSearch" type="search" class="exchange-search" :placeholder="t('mqExchanges.searchPlaceholder')" :disabled="loading && !exchanges.length && !exchangeSearch" />
<span v-if="exchanges.length" class="exchange-count">{{ filteredExchanges.length }} / {{ exchangeTotalCount }}</span>
</div>
<div class="toolbar-actions">
<button @click="loadExchanges" :disabled="loading || !tenant || !namespace" class="btn-secondary">
@@ -341,7 +435,7 @@ watch(
<div v-else-if="loading && !exchanges.length" class="panel-loading">{{ t("mqExchanges.loading") }}</div>
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ t("mqExchanges.noExchanges") }}</div>
<div v-else-if="!exchanges.length" class="panel-placeholder">{{ exchangeSearch.trim() ? t("mqExchanges.noMatches") : t("mqExchanges.noExchanges") }}</div>
<div v-else-if="!filteredExchanges.length" class="panel-placeholder">{{ t("mqExchanges.noMatches") }}</div>
@@ -382,6 +476,8 @@ watch(
</table>
</div>
<MqListPagination v-if="exchangeTotalCount > exchangePageSize || exchangePage > 1" :page="exchangePage" :page-size="exchangePageSize" :total-count="exchangeTotalCount" :has-more="exchangeHasMore" :loading="loading" @page-change="changeExchangePage" @page-size-change="changeExchangePageSize" />
<!-- Bindings of the selected exchange -->
<div v-if="selectedExchange" class="bindings-section">
<div class="bindings-header">
@@ -495,6 +591,7 @@ watch(
<datalist id="mq-exchange-bind-queues">
<option v-for="queue in availableQueues" :key="queue.name" :value="queue.shortName" />
</datalist>
<small v-if="bindForm.destinationType === 'queue' && availableQueueTotal > availableQueues.length" class="queue-suggestion-count">{{ availableQueues.length }} / {{ availableQueueTotal }}</small>
</div>
<div class="form-group">
<label>{{ t("mqExchanges.routingKey") }}</label>
@@ -591,6 +688,13 @@ watch(
font-size: 12px;
}
.queue-suggestion-count {
display: block;
margin-top: 4px;
color: var(--color-text-tertiary);
font-size: 11px;
}
.panel-placeholder,
.panel-error,
.panel-loading {
+91 -8
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { ref, watch, computed, onBeforeUnmount } from "vue";
import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
import { mqListTopics, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
import type { NamespaceRef, TopicRef, TopicInfo, ListTopicsOpts, MqListPageRequest, MqSystemKind, RocketMqTopicMessageType } from "@/types/mq";
import { mqListTopics, mqListTopicsPage, mqCreateTopic, mqDeleteTopic, mqUpdatePartitions, mqGetClusterInfo } from "@/lib/backend/api";
import type { ClusterInfo } from "@/types/mq";
import RocketMqTopicDialogs, { type RocketMqTopicDialogKind } from "./rocketmq/RocketMqTopicDialogs.vue";
import SendMessagePanel from "./SendMessagePanel.vue";
@@ -17,8 +17,11 @@ import { formatError } from "@/lib/backend/errorUtils";
import { DEFAULT_ROCKETMQ_TOPIC_TYPE_FILTERS, isProtectedRocketMqTopic, isRocketMqBusinessMessageType, matchesRocketMqTypeFilters, resolveRocketMqMessageType, ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES, ROCKETMQ_TOPIC_MESSAGE_TYPES } from "@/lib/mq/rocketmqTopicTypes";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import MqListPagination from "./shared/MqListPagination.vue";
const TOPIC_ROW_HEIGHT = 44;
const RABBITMQ_DEFAULT_PAGE_SIZE = 100;
const RABBITMQ_SEARCH_DEBOUNCE_MS = 250;
type VirtualTopicRow = {
id: string;
@@ -98,6 +101,12 @@ const showNamespaceColumn = computed(() => isRabbitMqCluster.value && isAllVhost
const showRabbitMqSubTabs = computed(() => isRabbitMqCluster.value && props.supportsExchanges === true);
const rabbitMqSubTab = ref<"queues" | "exchanges">("queues");
const rabbitMqMessageSort = ref<"asc" | "desc" | null>(null);
const rabbitMqPage = ref(1);
const rabbitMqPageSize = ref(RABBITMQ_DEFAULT_PAGE_SIZE);
const rabbitMqTotalCount = ref(0);
const rabbitMqHasMore = ref(false);
let topicLoadSequence = 0;
let topicSearchTimer: ReturnType<typeof setTimeout> | undefined;
const rocketMqTopicTypeOptions = ROCKETMQ_TOPIC_MESSAGE_TYPES;
const rocketMqCreatableTopicTypes = ROCKETMQ_CREATABLE_TOPIC_MESSAGE_TYPES;
const rocketMqClusterName = computed(() => clusterInfo.value?.clusterId ?? "-");
@@ -224,6 +233,8 @@ function rabbitMqRatesTitle(): string {
function toggleRabbitMqMessageSort() {
rabbitMqMessageSort.value = rabbitMqMessageSort.value === "desc" ? "asc" : "desc";
rabbitMqPage.value = 1;
void loadTopics();
}
function topicTypeLabel(topic: TopicInfo): string {
@@ -269,9 +280,13 @@ async function guardWritable(operation: string): Promise<boolean> {
async function loadTopics() {
if (!props.tenant || !props.namespace) {
topicLoadSequence += 1;
topics.value = [];
rabbitMqTotalCount.value = 0;
rabbitMqHasMore.value = false;
return;
}
const loadSequence = ++topicLoadSequence;
loading.value = true;
error.value = undefined;
try {
@@ -282,14 +297,53 @@ async function loadTopics() {
const opts: ListTopicsOpts = {
includeNonPersistent: includeNonPersistent.value,
};
topics.value = await mqListTopics(props.connectionId, ns, opts);
if (isRabbitMqCluster.value) {
const pagination: MqListPageRequest = {
page: rabbitMqPage.value,
pageSize: rabbitMqPageSize.value,
search: topicSearch.value.trim() || undefined,
sort: rabbitMqMessageSort.value ? "messagesReady" : "name",
sortDescending: rabbitMqMessageSort.value === "desc",
};
const result = await mqListTopicsPage(props.connectionId, ns, opts, pagination);
if (loadSequence !== topicLoadSequence) return;
const lastPage = Math.max(1, Math.ceil(result.totalCount / result.pageSize));
if (result.page > lastPage) {
rabbitMqPage.value = lastPage;
void loadTopics();
return;
}
topics.value = result.items;
rabbitMqPage.value = result.page;
rabbitMqPageSize.value = result.pageSize;
rabbitMqTotalCount.value = result.totalCount;
rabbitMqHasMore.value = result.hasMore;
} else {
topics.value = await mqListTopics(props.connectionId, ns, opts);
if (loadSequence !== topicLoadSequence) return;
rabbitMqTotalCount.value = topics.value.length;
rabbitMqHasMore.value = false;
}
} catch (e: unknown) {
if (loadSequence !== topicLoadSequence) return;
error.value = formatError(e);
} finally {
loading.value = false;
if (loadSequence === topicLoadSequence) loading.value = false;
}
}
function changeRabbitMqPage(page: number) {
if (page < 1 || page === rabbitMqPage.value) return;
rabbitMqPage.value = page;
void loadTopics();
}
function changeRabbitMqPageSize(pageSize: number) {
rabbitMqPageSize.value = pageSize;
rabbitMqPage.value = 1;
void loadTopics();
}
async function loadClusterInfo() {
if (!isRocketMqCluster.value) return;
try {
@@ -513,6 +567,8 @@ function normalizePartitionInput() {
watch(
() => [props.tenant, props.namespace],
() => {
if (topicSearchTimer) clearTimeout(topicSearchTimer);
rabbitMqPage.value = 1;
selectedTopic.value = undefined;
loadTopics();
if (isRocketMqCluster.value) void loadClusterInfo();
@@ -529,14 +585,30 @@ watch(
);
watch(includeNonPersistent, () => {
rabbitMqPage.value = 1;
loadTopics();
});
watch(topicSearch, () => {
if (!isRabbitMqCluster.value) return;
if (topicSearchTimer) clearTimeout(topicSearchTimer);
topicSearchTimer = setTimeout(() => {
topicSearchTimer = undefined;
rabbitMqPage.value = 1;
void loadTopics();
}, RABBITMQ_SEARCH_DEBOUNCE_MS);
});
watch(newPartitions, () => {
if (dialogError.value === t("mqTopics.partitionMustIncrease") && canSubmitPartitionUpdate.value) {
dialogError.value = undefined;
}
});
onBeforeUnmount(() => {
topicLoadSequence += 1;
if (topicSearchTimer) clearTimeout(topicSearchTimer);
});
</script>
<template>
@@ -552,8 +624,8 @@ watch(newPartitions, () => {
<div class="panel-toolbar">
<div class="toolbar-left">
<h3>{{ t("mqTopics.title") }}</h3>
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length" />
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ typeFilteredTopics.length }} </span>
<input v-model="topicSearch" type="search" class="topic-search" :placeholder="t('mqTopics.searchPlaceholder')" :disabled="loading && !topics.length && !topicSearch" />
<span v-if="topics.length" class="topic-count"> {{ filteredTopics.length }} / {{ isRabbitMqCluster ? rabbitMqTotalCount : typeFilteredTopics.length }} </span>
<label v-if="isKafkaCluster" class="checkbox-label">
<input v-model="includeSystemTopics" type="checkbox" />
{{ t("mqTopics.includeSystemTopics") }}
@@ -584,7 +656,7 @@ watch(newPartitions, () => {
<div v-else-if="loading && !topics.length" class="panel-loading">{{ t("mqTopics.loading") }}</div>
<div v-else-if="!topics.length" class="panel-placeholder">{{ t("mqTopics.noTopics") }}</div>
<div v-else-if="!topics.length" class="panel-placeholder">{{ isRabbitMqCluster && topicSearch.trim() ? t("mqTopics.noMatches") : t("mqTopics.noTopics") }}</div>
<div v-else-if="!filteredTopics.length" class="panel-placeholder">
{{ isRocketMqCluster && userTopicCount === 0 ? t("mqTopics.noUserTopics") : isKafkaCluster && !includeSystemTopics && userTopicCount === 0 ? t("mqTopics.noUserTopics") : t("mqTopics.noMatches") }}
@@ -690,6 +762,17 @@ watch(newPartitions, () => {
</div>
</div>
<MqListPagination
v-if="isRabbitMqCluster && (rabbitMqTotalCount > rabbitMqPageSize || rabbitMqPage > 1)"
:page="rabbitMqPage"
:page-size="rabbitMqPageSize"
:total-count="rabbitMqTotalCount"
:has-more="rabbitMqHasMore"
:loading="loading"
@page-change="changeRabbitMqPage"
@page-size-change="changeRabbitMqPageSize"
/>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
<div class="dialog" @click.stop>
@@ -0,0 +1,127 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
mqBind: vi.fn(),
mqCreateExchange: vi.fn(),
mqDeleteExchange: vi.fn(),
mqListBindings: vi.fn(),
mqListExchangesPage: vi.fn(),
mqListTopicsPage: vi.fn(),
mqUnbind: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string, params?: { page?: number }) => (params?.page ? `${key}:${params.page}` : key) }),
}));
vi.mock("@/lib/backend/api", () => backend);
vi.mock("@/composables/useMqMutationGuard", () => ({
useMqMutationGuard: () => ({ confirmMqWrite: vi.fn().mockResolvedValue(true) }),
}));
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({
default: defineComponent({ setup: () => () => h("div") }),
}));
import ExchangesPanel from "@/components/mq/ExchangesPanel.vue";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
async function mountPanel(namespace = "/") {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(ExchangesPanel, {
connectionId: "rabbit-1",
tenant: "_rabbitmq",
namespace,
});
app.mount(root);
await flushUi();
return root;
}
function exchangeNames(container: ParentNode): string[] {
return [...container.querySelectorAll<HTMLElement>(".exchange-name-cell > span:first-child")].map((item) => item.textContent ?? "");
}
beforeEach(() => {
vi.useRealTimers();
Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListBindings.mockResolvedValue([]);
backend.mqListTopicsPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
backend.mqListExchangesPage.mockResolvedValue({
items: [{ name: "events", type: "topic", durable: true, autoDelete: false, internal: false }],
page: 1,
pageSize: 100,
totalCount: 1,
hasMore: false,
});
});
afterEach(() => {
app?.unmount();
app = null;
root?.remove();
root = null;
vi.useRealTimers();
});
describe("ExchangesPanel RabbitMQ pagination", () => {
it("requests one page at a time and retains all-vhost row identity", async () => {
backend.mqListExchangesPage
.mockResolvedValueOnce({
items: [{ name: "events", namespace: "/", type: "topic", durable: true, autoDelete: false, internal: false }],
page: 1,
pageSize: 100,
totalCount: 101,
hasMore: true,
})
.mockResolvedValueOnce({
items: [{ name: "events", namespace: "/staging", type: "topic", durable: true, autoDelete: false, internal: false }],
page: 2,
pageSize: 100,
totalCount: 101,
hasMore: false,
});
const panel = await mountPanel("*");
expect(exchangeNames(panel)).toEqual(["events"]);
expect(panel.querySelector("tbody td:nth-child(2)")?.textContent?.trim()).toBe("/");
const next = panel.querySelector<HTMLButtonElement>('[data-testid="mq-list-next"]');
if (!next) throw new Error("next page button not found");
next.click();
await flushUi();
expect(panel.querySelector("tbody td:nth-child(2)")?.textContent?.trim()).toBe("/staging");
expect(backend.mqListExchangesPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "*" }, expect.objectContaining({ page: 2, pageSize: 100, sort: "name" }));
});
it("debounces search into the server-side page request", async () => {
vi.useFakeTimers();
const panel = await mountPanel();
backend.mqListExchangesPage.mockClear();
backend.mqListExchangesPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
const search = panel.querySelector<HTMLInputElement>(".exchange-search");
if (!search) throw new Error("exchange search not found");
search.value = "orders";
search.dispatchEvent(new Event("input"));
await nextTick();
expect(backend.mqListExchangesPage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(250);
await flushUi();
expect(backend.mqListExchangesPage).toHaveBeenCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, expect.objectContaining({ page: 1, search: "orders" }));
});
});
+123 -36
View File
@@ -5,6 +5,7 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue";
const backend = vi.hoisted(() => ({
mqListTopics: vi.fn(),
mqListTopicsPage: vi.fn(),
mqCreateTopic: vi.fn(),
mqDeleteTopic: vi.fn(),
mqUpdatePartitions: vi.fn(),
@@ -99,42 +100,64 @@ async function mountRabbitMqAllVhostsPanel() {
return root;
}
async function mountKafkaPanel() {
root = document.createElement("div");
document.body.appendChild(root);
app = createApp(TopicsPanel, {
connectionId: "kafka-1",
tenant: "_flat_mq",
namespace: "_flat_mq",
mqSystemKind: "kafka",
isFlatMqCluster: true,
});
app.mount(root);
await flushUi();
return root;
}
function rowNames(container: ParentNode): string[] {
return [...container.querySelectorAll<HTMLElement>(".topic-name-text")].map((item) => item.textContent ?? "");
}
beforeEach(() => {
vi.useRealTimers();
Object.values(backend).forEach((mock) => mock.mockReset());
backend.mqListTopics.mockResolvedValue([
{
name: "alpha",
shortName: "alpha",
partitioned: false,
persistent: true,
messageCount: 4,
messagesReady: 3,
messagesUnacked: 1,
queueType: "quorum",
state: "running",
autoDelete: true,
consumerCount: 2,
arguments: { "x-queue-type": "quorum", "x-message-ttl": 60000, "x-dead-letter-exchange": "dlx" },
publishRate: 12.5,
deliverRate: 11.8,
ackRate: 11.2,
},
{
name: "beta",
shortName: "beta",
partitioned: false,
persistent: true,
messageCount: 12,
messagesReady: 12,
messagesUnacked: 0,
// No queue type, no arguments, and no message_stats sample:
// type stays unknown, rates render as "-" (never fabricated zeros).
},
]);
backend.mqListTopicsPage.mockResolvedValue({
items: [
{
name: "alpha",
shortName: "alpha",
partitioned: false,
persistent: true,
messageCount: 4,
messagesReady: 3,
messagesUnacked: 1,
queueType: "quorum",
state: "running",
autoDelete: true,
consumerCount: 2,
arguments: { "x-queue-type": "quorum", "x-message-ttl": 60000, "x-dead-letter-exchange": "dlx" },
publishRate: 12.5,
deliverRate: 11.8,
ackRate: 11.2,
},
{
name: "beta",
shortName: "beta",
partitioned: false,
persistent: true,
messageCount: 12,
messagesReady: 12,
messagesUnacked: 0,
// No queue type, no arguments, and no message_stats sample:
// type stays unknown, rates render as "-" (never fabricated zeros).
},
],
page: 1,
pageSize: 100,
totalCount: 2,
hasMore: false,
});
});
afterEach(() => {
@@ -142,6 +165,7 @@ afterEach(() => {
app = null;
root?.remove();
root = null;
vi.useRealTimers();
});
describe("TopicsPanel RabbitMQ queue messages", () => {
@@ -155,11 +179,12 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
if (!sortButton) throw new Error("RabbitMQ message sort button not found");
sortButton.click();
await nextTick();
await flushUi();
expect(rowNames(panel)).toEqual(["beta", "alpha"]);
expect(backend.mqListTopicsPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 1, pageSize: 100, sort: "messagesReady", sortDescending: true }));
sortButton.click();
await nextTick();
await flushUi();
expect(rowNames(panel)).toEqual(["alpha", "beta"]);
});
@@ -203,10 +228,16 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
});
it("keeps per-queue vhosts separate in all-vhosts mode (#5984 regression guard)", async () => {
backend.mqListTopics.mockResolvedValue([
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/", messageCount: 3, messagesReady: 3 },
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/staging", messageCount: 7, messagesReady: 7 },
]);
backend.mqListTopicsPage.mockResolvedValue({
items: [
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/", messageCount: 3, messagesReady: 3 },
{ name: "orders", shortName: "orders", partitioned: false, persistent: true, namespace: "/staging", messageCount: 7, messagesReady: 7 },
],
page: 1,
pageSize: 100,
totalCount: 2,
hasMore: false,
});
const panel = await mountRabbitMqAllVhostsPanel();
// Both rows are named "orders" but must stay distinct per virtual host;
@@ -216,4 +247,60 @@ describe("TopicsPanel RabbitMQ queue messages", () => {
const counts = [...panel.querySelectorAll<HTMLElement>('[data-testid="rabbitmq-message-count"]')].map((item) => item.textContent?.trim());
expect(counts).toEqual(["3", "7"]);
});
it("loads only the requested RabbitMQ page and keeps the total visible", async () => {
backend.mqListTopicsPage
.mockResolvedValueOnce({
items: [{ name: "page-one", shortName: "page-one", partitioned: false, persistent: true }],
page: 1,
pageSize: 100,
totalCount: 101,
hasMore: true,
})
.mockResolvedValueOnce({
items: [{ name: "page-two", shortName: "page-two", partitioned: false, persistent: true }],
page: 2,
pageSize: 100,
totalCount: 101,
hasMore: false,
});
const panel = await mountRabbitMqPanel();
expect(rowNames(panel)).toEqual(["page-one"]);
expect(panel.querySelector(".topic-count")?.textContent?.replace(/\s+/g, " ").trim()).toBe("1 / 101");
const next = panel.querySelector<HTMLButtonElement>('[data-testid="mq-list-next"]');
if (!next) throw new Error("next page button not found");
next.click();
await flushUi();
expect(rowNames(panel)).toEqual(["page-two"]);
expect(backend.mqListTopicsPage).toHaveBeenLastCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 2, pageSize: 100 }));
});
it("debounces queue search into the bounded backend request", async () => {
vi.useFakeTimers();
const panel = await mountRabbitMqPanel();
backend.mqListTopicsPage.mockClear();
backend.mqListTopicsPage.mockResolvedValue({ items: [], page: 1, pageSize: 100, totalCount: 0, hasMore: false });
const search = panel.querySelector<HTMLInputElement>(".topic-search");
if (!search) throw new Error("topic search not found");
search.value = "orders";
search.dispatchEvent(new Event("input"));
await nextTick();
expect(backend.mqListTopicsPage).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(250);
await flushUi();
expect(backend.mqListTopicsPage).toHaveBeenCalledWith("rabbit-1", { tenant: "_rabbitmq", namespace: "/" }, { includeNonPersistent: false }, expect.objectContaining({ page: 1, search: "orders" }));
});
it("keeps non-RabbitMQ providers on the legacy unbounded contract", async () => {
backend.mqListTopics.mockResolvedValue([{ name: "orders", shortName: "orders", partitioned: false, persistent: true }]);
const panel = await mountKafkaPanel();
expect(rowNames(panel)).toEqual(["orders"]);
expect(backend.mqListTopics).toHaveBeenCalledWith("kafka-1", { tenant: "_flat_mq", namespace: "_flat_mq" }, { includeNonPersistent: false });
expect(backend.mqListTopicsPage).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
page: number;
pageSize: number;
totalCount: number;
hasMore: boolean;
loading?: boolean;
}>();
const emit = defineEmits<{
pageChange: [page: number];
pageSizeChange: [pageSize: number];
}>();
const { t } = useI18n();
const pageSizeOptions = [50, 100, 200, 500];
const pageCount = computed(() => Math.max(1, Math.ceil(props.totalCount / props.pageSize)));
const rangeStart = computed(() => (props.totalCount ? (props.page - 1) * props.pageSize + 1 : 0));
const rangeEnd = computed(() => Math.min(props.page * props.pageSize, props.totalCount));
function changePageSize(event: Event) {
const pageSize = Number((event.target as HTMLSelectElement).value);
if (Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize !== props.pageSize) {
emit("pageSizeChange", pageSize);
}
}
</script>
<template>
<div class="mq-list-pagination" data-testid="mq-list-pagination">
<span class="mq-list-range">{{ rangeStart }}–{{ rangeEnd }} / {{ totalCount }}</span>
<label class="mq-list-page-size">
<span>{{ t("grid.rowsPerPage") }}</span>
<select :value="pageSize" :disabled="loading" @change="changePageSize">
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}</option>
</select>
</label>
<button type="button" class="mq-list-page-button" data-testid="mq-list-previous" :aria-label="t('grid.page', { page: Math.max(1, page - 1) })" :disabled="loading || page <= 1" @click="emit('pageChange', page - 1)">‹</button>
<span class="mq-list-page-number">{{ t("grid.page", { page }) }} / {{ pageCount }}</span>
<button type="button" class="mq-list-page-button" data-testid="mq-list-next" :aria-label="t('grid.page', { page: page + 1 })" :disabled="loading || !hasMore" @click="emit('pageChange', page + 1)">›</button>
</div>
</template>
<style scoped>
.mq-list-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 30px;
color: var(--color-text-secondary);
font-size: 12px;
}
.mq-list-range,
.mq-list-page-number {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mq-list-page-size {
display: flex;
align-items: center;
gap: 6px;
}
.mq-list-page-size select,
.mq-list-page-button {
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-4);
background: var(--color-background);
color: var(--color-text);
}
.mq-list-page-size select {
padding: 0 6px;
}
.mq-list-page-button {
min-width: 30px;
padding: 0 8px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.mq-list-page-button:disabled,
.mq-list-page-size select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>
@@ -101,7 +101,7 @@ import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRe
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { showSqlInsertModeDialog, type SqlInsertDialect, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
import {
defaultPasteTableMode,
@@ -2321,14 +2321,14 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json") {
}
}
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql", insertDialect: SqlInsertDialect = "source") {
if (format === "json") {
await exportDataLegacy(row, format);
return;
}
const sqlExportOptions = format === "sql" ? await showSqlInsertModeDialog({ allowSplit: true }) : undefined;
if (format === "sql" && sqlExportOptions === null) return;
await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb);
await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb, insertDialect);
}
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
@@ -2371,7 +2371,7 @@ async function exportDataXlsx(row: ObjectBrowserRow) {
await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number) {
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number, insertDialect: SqlInsertDialect = "source") {
const schema = row.schema || selectedSchema.value;
const splitSqlOutput = format === "sql" && splitMaxMb !== undefined;
@@ -2446,7 +2446,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
tableName: row.name,
filePath,
format,
...(format === "sql" ? { insertMode, splitMaxMb } : {}),
...(format === "sql" ? { insertMode, insertDialect, splitMaxMb } : {}),
csvQuoteMode: settingsStore.editorSettings.csvQuoteMode,
columns,
columnComments: format === "xlsx" ? columnComments : undefined,
@@ -3373,7 +3373,10 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
{ label: "CSV", action: () => exportData(item, "csv") },
{ label: "JSON", action: () => exportData(item, "json") },
];
if (!isVictoriaMetrics.value) formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql") });
if (!isVictoriaMetrics.value) {
formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql", "source") });
formats.push({ label: t("contextMenu.standardSqlInsert"), action: () => exportData(item, "sql", "standard") });
}
formats.push({ label: "XLSX", action: () => exportDataXlsx(item) });
return {
label: t("contextMenu.exportData"),
+100 -3
View File
@@ -1,14 +1,14 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, ref, type App } from "vue";
import { createApp, defineComponent, h, inject, provide, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InstalledPlugin } from "@/types/database";
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn() }));
const mocks = vi.hoisted(() => ({ listPlugins: vi.fn(), invokePlugin: vi.fn(), useConnectionStore: vi.fn() }));
vi.mock("@/lib/backend/api", () => ({ listPlugins: mocks.listPlugins, invokePlugin: mocks.invokePlugin }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [] }) }));
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: mocks.useConnectionStore }));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) }));
vi.mock("@/components/ui/button", () => ({
Button: defineComponent(
@@ -35,6 +35,37 @@ vi.mock("@/components/ui/tooltip", () => ({
),
}));
vi.mock("./PluginIcon.vue", () => ({ default: defineComponent(() => () => h("span")) }));
vi.mock("@/components/icons/ConnectionIcon.vue", () => ({ default: defineComponent(() => () => h("span", { "data-connection-icon-stub": "" })) }));
// DropdownMenu harness: content renders unconditionally and the trigger's
// click forwards `update:open`, so picker tests assert menu content and item
// selection without depending on reka-ui's floating/portal behavior.
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: defineComponent({
props: { open: { type: Boolean, default: false } },
emits: ["update:open"],
setup(_, { slots, emit }) {
provide("dbxDropdownTestSetOpen", (value: boolean) => emit("update:open", value));
return () => h("div", { "data-dropdown-menu-stub": "" }, slots.default?.());
},
}),
DropdownMenuTrigger: defineComponent({
setup(_, { slots }) {
const setOpen = inject<(value: boolean) => void>("dbxDropdownTestSetOpen")!;
return () => h("div", { onClick: () => setOpen(true) }, slots.default?.());
},
}),
DropdownMenuContent: defineComponent(
(_, { slots }) =>
() =>
h("div", { "data-plugin-dock-plus-menu": "" }, slots.default?.()),
),
DropdownMenuItem: defineComponent({
emits: ["select"],
setup(_, { slots, emit }) {
return () => h("div", { "data-dropdown-item-stub": "", onClick: () => emit("select", {}) }, slots.default?.());
},
}),
}));
// Mirrors the real PluginWorkbenchHost, whose title computed dereferences
// props.contribution.label unconditionally — an undefined contribution must
// never reach it, otherwise the whole dock subtree crashes during render.
@@ -70,6 +101,7 @@ describe("PluginBottomDock workbench contribution guard", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.useConnectionStore.mockReturnValue({ connections: [], sidebarLayout: { groups: [], order: [] }, ensureConnected: vi.fn(() => Promise.resolve()) });
root = document.createElement("div");
document.body.appendChild(root);
});
@@ -137,11 +169,76 @@ describe("PluginBottomDock workbench contribution guard", () => {
expect(renderError).toBeUndefined();
expect(mocks.invokePlugin).not.toHaveBeenCalled();
expect(warnSpy.mock.calls.filter((call) => String(call[0]).includes("[DBX][plugin:dock]"))).toEqual([]);
// Without options_action the command doesn't own the picker, so the
// generic replay item stays visible.
expect(root.querySelector("[data-plugin-dock-plus-menu]")?.textContent).toContain("pluginDock.newTerminal");
} finally {
warnSpy.mockRestore();
}
});
it("passes the current UI locale to the options_action fetch", async () => {
// Sidecars need the UI language to localize launch-option labels; the host
// used to call options_action with an empty payload.
const plugin = installedPlugin("sample.panel");
plugin.manifest.contributions!.push({
type: "command",
id: "sample.open",
label: "Open sample",
action: { type: "open-workbench", workbench: "sample.panel", presentation: "panel", options_action: "local/terminal/launch-options" },
});
mocks.listPlugins.mockResolvedValue([plugin]);
mocks.invokePlugin.mockResolvedValue({ entries: [{ label: "Local terminal (auto-detect)", description: "Default shell: /bin/zsh", context: {} }] });
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
await mountDock();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.invokePlugin).toHaveBeenCalledWith("io.dbx.sample", "local/terminal/launch-options", { locale: "en" });
const menu = root.querySelector("[data-plugin-dock-plus-menu]");
expect(menu?.textContent).toContain("Local terminal (auto-detect)");
expect(menu?.textContent).toContain("Default shell: /bin/zsh");
// The plugin owns the picker now: the generic replay item hides.
expect(menu?.textContent).not.toContain("pluginDock.newTerminal");
});
it("renders the + picker connection targets as sidebar-grouped rows and opens them on pick", async () => {
const plugin = installedPlugin("sample.panel");
plugin.manifest.contributions!.push(
{ type: "connection-provider", id: "ssh", label: "SSH", database_type: "plugin", fields: [] },
{
type: "command",
id: "sample.open",
label: "Open sample",
action: { type: "open-workbench", workbench: "sample.panel", presentation: "panel", connection_targets: true },
},
);
mocks.listPlugins.mockResolvedValue([plugin]);
mocks.useConnectionStore.mockReturnValue({
connections: [{ id: "conn-1", name: "my-server", plugin_connection_provider: "ssh" }],
sidebarLayout: {
groups: [{ id: "g1", name: "Prod", collapsed: false }],
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "conn-1" }] }],
},
ensureConnected: vi.fn(() => Promise.resolve()),
});
entryIds.push(dock.addPluginDockEntry({ pluginId: "io.dbx.sample", workbenchContributionId: "sample.panel", kind: "command", commandId: "sample.open", title: "Sample terminal" }));
await mountDock();
await new Promise((resolve) => setTimeout(resolve, 0));
const menu = root.querySelector("[data-plugin-dock-plus-menu]")!;
expect(menu.textContent).toContain("Prod");
expect(menu.textContent).toContain("my-server");
// The flat "connection · name" prefix is gone; rows carry icons instead.
expect(menu.textContent).not.toContain("pluginDock.connectionTerminal");
const row = [...menu.querySelectorAll("[data-dropdown-item-stub]")].find((element) => element.textContent?.includes("my-server")) as HTMLElement;
row.click();
const state = dock.usePluginBottomDock();
const picked = state.entries.value[state.entries.value.length - 1]!;
entryIds.push(picked.id);
expect(picked).toMatchObject({ kind: "connection", context: { connectionId: "conn-1" } });
});
it("degrades to an empty frame instead of crashing when the plugin no longer declares the entry's workbench contribution", async () => {
// Plugin upgrade/downgrade replaced the workbench id the entry was opened with.
mocks.listPlugins.mockResolvedValue([installedPlugin("sample.renamed")]);
@@ -6,10 +6,11 @@
// Each entry owns a host-stable workbenchId; v-show keeps sessions alive while switching tabs.
import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ChevronDown, ChevronUp, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
import { ChevronDown, ChevronRight, ChevronUp, Folder, Maximize2, Minimize2, Plus, X } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -28,6 +29,7 @@ import {
usePluginBottomDock,
type PluginDockEntry,
} from "@/lib/plugins/pluginBottomDock";
import { buildConnectionPickerRows } from "@/lib/connection/connectionPickerTree";
import { useDockResize } from "@/composables/useDockResize";
import { executePluginCommand } from "@/lib/plugins/pluginCommandRegistry";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
@@ -36,7 +38,7 @@ import { useQueryStore } from "@/stores/queryStore";
import * as api from "@/lib/backend/api";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
const { t } = useI18n();
const { t, locale } = useI18n();
// Tab strip interactions (drag reorder + double-click rename).
const dragEntryId = ref<string | null>(null);
const renamingEntryId = ref<string | null>(null);
@@ -179,7 +181,9 @@ async function loadLaunchOptions() {
// options_action is OPTIONAL on open-workbench commands: without it there is
// nothing to fetch — proceeding fired a doomed invokePlugin(undefined) IPC
// round trip and a console.warn on every picker open / entry switch.
const cacheKey = action?.options_action && pluginId && commandId ? `${pluginId}:${commandId}` : "";
// The locale is part of the cache key: a UI language switch must refetch
// instead of repainting labels cached under the previous language.
const cacheKey = action?.options_action && pluginId && commandId ? `${pluginId}:${commandId}:${locale.value}` : "";
if (!cacheKey) {
launchOptionEntries.value = [];
return;
@@ -191,7 +195,10 @@ async function loadLaunchOptions() {
launchOptionEntries.value = cached ?? [];
launchOptionsLoading.value = !cached;
try {
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId!, action!.options_action!, {});
// locale tells the sidecar which UI language to label the entries in —
// the same field the plugin UI webview receives at init. Sidecars that
// don't read it are unaffected (they ignore the extra param).
const result = await api.invokePlugin<{ entries?: Array<{ label: string; description?: string; context?: Record<string, unknown> }> }>(pluginId!, action!.options_action!, { locale: locale.value });
const next = (result?.entries ?? []).map((entry, index) => ({ key: `opt:${index}`, label: entry.label, description: entry.description, context: entry.context }));
launchOptionsCache.set(cacheKey, next);
// A stale response (entry switched while fetching) must not overwrite the
@@ -385,28 +392,76 @@ const plusOpen = ref(false);
// what the entries mean.
const plusFilter = ref("");
const PLUS_FILTER_THRESHOLD = 8;
const plusItemCount = computed(() => 1 + launchOptionEntries.value.length + connectionTargets.value.length);
// A command declaring options_action owns this picker: the generic replay
// item hides (replaying a singleton command would only refocus the live
// panel — multi-open is exactly what the plugin's own options exist for).
const showReplayItem = computed(() => !activeAction.value?.options_action);
const plusItemCount = computed(() => (showReplayItem.value ? 1 : 0) + launchOptionEntries.value.length + connectionTargets.value.length);
const plusQuery = computed(() => plusFilter.value.trim().toLowerCase());
function plusMatches(label: string): boolean {
return !plusQuery.value || label.toLowerCase().includes(plusQuery.value);
}
const visibleLaunchOptions = computed(() => launchOptionEntries.value.filter((option) => plusMatches(option.label)));
const visibleConnectionTargets = computed(() => connectionTargets.value.filter((target) => plusMatches(optionLabel(target))));
function optionLabel(target: { label: string }): string {
return target.label;
// Connection rows reuse the AI assistant's connection tree select logic
// (buildConnectionPickerRows): sidebar groups, per-connection icons, and a
// search that also matches group paths and force-expands its matches — the
// flat label filter below stays for the replay/launch-option items only.
// Collapsed groups are dock-local view state.
const collapsedPickerGroups = ref(new Set<string>());
const connectionById = computed(() => new Map(connectionTargets.value.map((target) => [target.connection.id, target.connection])));
const connectionPickerRows = computed(() =>
buildConnectionPickerRows(
connectionStore.sidebarLayout,
connectionTargets.value.map((target) => target.connection),
collapsedPickerGroups.value,
plusQuery.value,
),
);
function togglePickerGroup(groupId: string) {
if (plusQuery.value) return;
const next = new Set(collapsedPickerGroups.value);
if (!next.delete(groupId)) next.add(groupId);
collapsedPickerGroups.value = next;
}
function setPlusOpen(open: boolean) {
plusOpen.value = open;
const plusHasMatches = computed(() => (showReplayItem.value && plusMatches(t("pluginDock.newTerminal"))) || visibleLaunchOptions.value.length > 0 || connectionPickerRows.value.length > 0);
async function setPlusOpen(open: boolean) {
plusFilter.value = "";
if (open) {
void loadLaunchOptions();
warmActivePluginPanelHtml();
if (!open) {
plusOpen.value = false;
return;
}
// Collapsed dock: the strip hugs the window bottom edge, leaving no room
// below the "+" for a downward picker. Restore the panel body first — the
// picker is about to add a panel entry, which a collapsed frame would hide.
if (collapsed.value) collapsed.value = false;
await nextTick();
measurePlusMenuHeight();
plusOpen.value = true;
void loadLaunchOptions();
warmActivePluginPanelHtml();
}
function closePlusMenu() {
plusOpen.value = false;
}
// Keep the "+" picker opening strictly downward. reka's menu chain silently
// drops the `side-flip` prop (MenuContentImpl forwards side/align/sticky/... to
// PopperContent but not sideFlip), so the flip middleware is always live: any
// bottom overflow makes the menu jump above the trigger. Instead of fighting
// the middleware, cap the menu height to the space remaining under the trigger
// — floating-ui then never sees a bottom overflow, so the menu can only open
// downward, while cross-axis shift keeps it inside the window near the edges.
// Must run before `plusOpen` flips true: the inline cap has to be bound when
// the content mounts, or the first uncapped measurement would still flip it.
const PLUS_MENU_MIN_HEIGHT_PX = 80;
const plusMenuMaxHeight = ref<string>();
function measurePlusMenuHeight() {
const trigger = dockRoot.value?.querySelector<HTMLElement>("[data-plugin-dock-plus-trigger]");
if (!trigger) return;
const below = window.innerHeight - trigger.getBoundingClientRect().bottom - 8;
plusMenuMaxHeight.value = `${Math.max(PLUS_MENU_MIN_HEIGHT_PX, below)}px`;
}
// Hide the first-open html pipeline under the time the user spends reading the
// "+" picker: the read/decode/inline of a multi-megabyte ui build runs while
// the menu is open, so whichever panel the user picks mounts on a warm cache
@@ -489,31 +544,55 @@ onScopeDispose(() => window.removeEventListener("blur", onPlusMenuWindowBlur));
viewport edge instead of running off-screen. -->
<DropdownMenu v-if="activeCommand" :open="plusOpen" @update:open="setPlusOpen">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen">
<Button variant="ghost" size="icon" class="h-7 w-7" :aria-label="t('pluginDock.newTerminal')" :aria-expanded="plusOpen" data-plugin-dock-plus-trigger>
<Plus class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<!-- sideFlip=false pins the menu below the "+": the dock panel body
below it is the intended scroll area, so flipping upward over the
workbench (the default when the panel is short) reads as the menu
"growing from the mouse". Cross-axis shift stays on, so the menu
still hugs the window edge instead of overflowing it; height
follows the popper's available space below the trigger. -->
<DropdownMenuContent align="start" :side-offset="4" :side-flip="false" class="max-h-(--reka-dropdown-menu-content-available-height) w-64" data-plugin-dock-plus-menu>
<!-- Always pinned below the "+" (see measurePlusMenuHeight): the inline
max-height caps the menu to the space under the trigger, so the
flip middleware never sees a bottom overflow. Cross-axis shift
stays on, so the menu still hugs the window edge instead of
overflowing it; the capped list scrolls internally. -->
<DropdownMenuContent align="start" :side-offset="4" class="max-h-(--reka-dropdown-menu-content-available-height) w-64" :style="plusMenuMaxHeight ? { maxHeight: plusMenuMaxHeight } : undefined" data-plugin-dock-plus-menu>
<!-- Generic list filter (appears only for long lists): the host filters
by label without knowing what the entries mean. -->
<input v-if="plusItemCount > PLUS_FILTER_THRESHOLD" v-model="plusFilter" class="mb-1 w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-primary/40" :placeholder="t('pluginDock.filter')" spellcheck="false" @keydown.stop />
<DropdownMenuItem v-if="plusMatches(t('pluginDock.newTerminal'))" @select="onPlusAction('replay')">
<DropdownMenuItem v-if="showReplayItem && plusMatches(t('pluginDock.newTerminal'))" @select="onPlusAction('replay')">
<Plus class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ t("pluginDock.newTerminal") }}</span>
<span class="min-w-0 flex-1 truncate text-sm">{{ t("pluginDock.newTerminal") }}</span>
</DropdownMenuItem>
<DropdownMenuItem v-for="option in visibleLaunchOptions" :key="option.key" @select="onPlusAction(option.key)">
<span class="truncate">{{ option.label }}</span>
<PluginIcon :plugin-id="activeEntry?.pluginId ?? ''" :icon="activeCommand?.icon" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm">{{ option.label }}</span>
<span v-if="option.description" class="block truncate text-xs text-muted-foreground">{{ option.description }}</span>
</span>
</DropdownMenuItem>
<DropdownMenuItem v-for="target in visibleConnectionTargets" :key="target.key" :title="target.connection.name" @select="onPlusAction(target.key)">
<span class="truncate">{{ t("pluginDock.connectionTerminal") }} · {{ target.label }}</span>
</DropdownMenuItem>
<div v-if="plusQuery && !plusMatches(t('pluginDock.newTerminal')) && !visibleLaunchOptions.length && !visibleConnectionTargets.length" class="px-2 py-1.5 text-xs text-muted-foreground">
<DropdownMenuSeparator v-if="visibleLaunchOptions.length && connectionPickerRows.length" />
<template v-for="row in connectionPickerRows" :key="row.key">
<!-- Group headers are plain rows, not menu items: clicking toggles
collapse (a no-op while searching), and reka-ui keyboard
navigation skips them — same contract as the AI assistant's
connection tree select. -->
<div
v-if="row.kind === 'group'"
:title="row.label"
:aria-expanded="!row.collapsed"
:style="{ paddingLeft: `${8 + row.depth * 14}px` }"
class="flex h-6 cursor-default select-none items-center gap-1.5 rounded-md pr-2 text-xs font-medium text-muted-foreground"
@click="togglePickerGroup(row.id)"
>
<ChevronDown v-if="!row.collapsed" class="h-3 w-3 shrink-0" />
<ChevronRight v-else class="h-3 w-3 shrink-0" />
<Folder class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ row.label }}</span>
</div>
<DropdownMenuItem v-else :title="row.label" :style="{ paddingLeft: `${8 + row.depth * 14}px` }" @select="onPlusAction(`conn:${row.id}`)">
<ConnectionIcon :connection="connectionById.get(row.id)" class="h-3.5 w-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate text-sm">{{ row.label }}</span>
</DropdownMenuItem>
</template>
<div v-if="plusQuery && !plusHasMatches" class="px-2 py-1.5 text-xs text-muted-foreground">
{{ t("pluginDock.noMatch") }}
</div>
</DropdownMenuContent>
@@ -438,7 +438,7 @@ describe("PluginConnectionFields", () => {
await mountContribution(dynamic, { sudo_profile: "ghost" }, "io.dbx.ssh");
await flushAsync();
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options");
expect(invokePluginMock).toHaveBeenCalledWith("io.dbx.ssh", "sudo/profiles/options", { locale: "en" });
expect(document.querySelector("input#dyn-connection-sudo_profile")).toBeNull();
const trigger = document.querySelector<HTMLButtonElement>('#dyn-connection-sudo_profile, button[role="combobox"]');
expect(trigger).not.toBeNull();
@@ -28,7 +28,7 @@ const emit = defineEmits<{
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
const { toast } = useToast();
const formValues = computed(() => props.modelValue);
@@ -118,7 +118,9 @@ watch(
const action = field.options_action;
if (!action || !props.pluginId || dynamicOptionsRequested.value.has(field.key)) continue;
dynamicOptionsRequested.value.add(field.key);
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action)
// locale lets the sidecar localize the returned option labels (same
// field the plugin UI webview receives at init); older sidecars ignore it.
invokePlugin<{ options?: PluginFormFieldOption[] }>(props.pluginId, action, { locale: locale.value })
.then((result) => {
const options = Array.isArray(result?.options) ? result.options.filter((option) => option && option.value !== undefined) : [];
dynamicOptions.value = { ...dynamicOptions.value, [field.key]: options };
@@ -16,7 +16,12 @@ vi.mock("vue-virtual-scroller", () => ({
inheritAttrs: false,
props: { items: { type: Array, default: () => [] } },
setup(props, { attrs, slots }) {
return () => h("div", attrs, (props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? []));
return () =>
h(
"div",
attrs,
(props.items as unknown[]).flatMap((item) => slots.default?.({ item }) ?? []),
);
},
}),
}));
@@ -305,14 +305,7 @@ defineExpose({ refresh: () => load(currentUri.value) });
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
<RecycleScroller v-else data-file-list class="plugin-file-list min-h-0 min-w-0 flex-1 overflow-x-hidden text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
<template #default="{ item: entry }">
<button
type="button"
class="plugin-file-row grid h-[46px] w-full min-w-0 items-center gap-3 border-b px-3 text-left hover:bg-muted/50"
:class="selected?.uri === entry.uri ? 'bg-muted' : ''"
:data-file-entry-uri="entry.uri"
@click="selected = entry"
@dblclick="activateEntry(entry)"
>
<button type="button" class="plugin-file-row grid h-[46px] w-full min-w-0 items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" :data-file-entry-uri="entry.uri" @click="selected = entry" @dblclick="activateEntry(entry)">
<span class="flex min-w-0 items-center gap-2">
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
@@ -2026,7 +2026,7 @@ async function runRedisCommand(command: string) {
const entryId = appendCommandHistory({ prompt, command, output: "", error: false });
try {
const result = await api.redisExecuteCommand(props.connectionId, commandDb.value, command, !props.blockDangerousRedisCommands);
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value), error: false });
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value, command), error: false });
// The db this command ran on — capture before nextRedisCommandDb() advances it.
const executedDb = commandDb.value;
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it } from "vitest";
import RedisQueryConsoleOutput from "@/components/redis/RedisQueryConsoleOutput.vue";
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import type { QueryResult } from "@/types/database";
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mounted.splice(0)) {
unmount();
host.remove();
}
});
describe("RedisQueryConsoleOutput", () => {
it("renders every command result and keeps failures visually distinct", async () => {
const results: QueryResult[] = [
{ ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" },
{ ...redisCommandResultToQueryResult(["one", "two"], 1, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" },
{ columns: ["Error"], rows: [["ERR unknown command"]], affected_rows: 0, execution_time_ms: 0, execution_error: true, sourceStatement: "NOPE" },
];
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({ render: () => h(RedisQueryConsoleOutput, { results }) });
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { redis: { commandLine: "Command line", commandEmptyResult: "No output" } } } }));
app.mount(host);
mounted.push({ unmount: () => app.unmount(), host });
await nextTick();
expect([...host.querySelectorAll("div.flex.min-w-0")].map((node) => node.textContent?.trim())).toEqual(["redis>PING", "redis>LRANGE items 0 -1", "redis>NOPE"]);
expect([...host.querySelectorAll("pre")].map((node) => node.textContent)).toEqual(["PONG", '1) "one"\n2) "two"', "(error) ERR unknown command"]);
expect(host.querySelectorAll("pre")[2]?.className).toContain("text-[#ff6b6b]");
});
});
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from "vue";
import { Loader2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
import type { QueryResult } from "@/types/database";
const props = withDefaults(
defineProps<{
result?: QueryResult;
results?: QueryResult[];
loading?: boolean;
}>(),
{ loading: false },
);
const { t } = useI18n();
const entries = computed(() => redisQueryResultsToConsoleEntries(props.results?.length ? props.results : props.result ? [props.result] : []));
</script>
<template>
<div data-redis-query-console role="region" class="dbx-editor-font-family flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200" :aria-label="t('redis.commandLine')">
<div class="min-h-0 flex-1 overflow-auto px-4 py-4">
<div v-for="(entry, index) in entries" :key="`${index}:${entry.command}`" class="mb-4 last:mb-0">
<div v-if="entry.command" class="flex min-w-0 items-start gap-2 whitespace-pre-wrap break-words">
<span class="shrink-0 text-[#d7ba7d]">redis&gt;</span>
<span class="min-w-0 text-slate-200">{{ entry.command }}</span>
</div>
<pre data-native-clipboard class="m-0 whitespace-pre-wrap break-words" :class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'">{{ entry.output }}</pre>
</div>
<div v-if="loading && entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
<Loader2 class="h-4 w-4 animate-spin" />
</div>
<div v-else-if="entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
{{ t("redis.commandEmptyResult") }}
</div>
</div>
</div>
</template>
@@ -87,7 +87,18 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { hasTableVGroupEntries, isTableVGroupContainerNode, isTableVGroupGroupableRowType, resolveTableVGroupScopeFromNode, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupScopeKey, tableVGroupsEnabled } from "@/lib/table/tableVGroup";
import {
hasTableTreeLoadMore,
hasTableVGroupEntries,
isTableVGroupContainerNode,
isTableVGroupGroupableRowType,
resolveTableVGroupScopeFromNode,
selectedTableVGroupMoveTargets,
tableVGroupDestinationRows,
tableVGroupPathForTable,
tableVGroupScopeKey,
tableVGroupsEnabled,
} from "@/lib/table/tableVGroup";
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus";
@@ -883,7 +894,8 @@ async function toggle(requestId = beginNavigationRequest()) {
// particular, schema-level trigger/type groups have no tableName, so they
// must use the generic object loader rather than the table-trigger loader.
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
const needsConfiguredTablePageDrain = node.type === "group-tables" && connectionStore.getConfig(node.connectionId || "")?.sidebar_auto_load_all_tables === true && hasTableTreeLoadMore(node.children ?? []);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node) && !needsConfiguredTablePageDrain) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
@@ -5480,7 +5492,10 @@ function exportDataSubmenu(includeSqlInsert = true): ContextMenuItem {
{ label: "CSV", action: () => exportData("csv") },
{ label: "JSON", action: () => exportData("json") },
];
if (includeSqlInsert) children.push({ label: "SQL INSERT", action: () => exportData("sql") });
if (includeSqlInsert) {
children.push({ label: "SQL INSERT", action: () => exportData("sql", "source") });
children.push({ label: t("contextMenu.standardSqlInsert"), action: () => exportData("sql", "standard") });
}
children.push({ label: "XLSX", action: () => exportDataXlsx() });
return {
label: count > 1 ? t("contextMenu.exportDataMultiple", { count }) : t("contextMenu.exportData"),
@@ -259,6 +259,42 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(connectionStore.loadObjectGroupChildren).not.toHaveBeenCalled();
});
it("routes an opted-in partial table group back through the loader", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [
{ id: "mysql:basic:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" },
{
id: "mysql:basic:__tables:__load_more:1000",
label: "tree.loadMore",
type: "load-more",
connectionId: "mysql",
database: "basic",
loadMore: { parentId: "mysql:basic:__tables", offset: 1000, pageSize: 1000 },
},
],
};
connectionStore.getConfig.mockReturnValue({ db_type: "mysql", name: "connection", sidebar_auto_load_all_tables: true });
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(defineComponent({ setup: () => () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 }) }));
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await vi.waitFor(() => expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, undefined));
expect(group.isExpanded).toBe(true);
});
it("publishes a rendered group collapse and synchronizes the live tree", async () => {
const liveGroup: TreeNode = {
id: "connection:database:__tables",
@@ -23,6 +23,8 @@ const mocks = vi.hoisted(() => ({
inspectSqlFileTables: vi.fn(),
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
changeDialogOpen: undefined as undefined | ((open: boolean) => void),
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }] as Array<{ id: string; name: string; db_type: string; driver_profile: string; database: string }>,
getConfig: vi.fn(),
refreshDatabaseTreeNode: vi.fn(),
requestConfirmation: vi.fn(),
toast: vi.fn(),
@@ -30,6 +32,10 @@ const mocks = vi.hoisted(() => ({
unlisten: vi.fn(),
updateSqlFileTask: vi.fn(),
uuid: vi.fn(),
queryStore: {
tabs: [{ id: "mysql-tab", connectionId: "mysql-1", database: "" }],
activeTabId: "mysql-tab" as string | null,
},
}));
function passthrough(tag: string) {
@@ -65,12 +71,15 @@ vi.mock("@/stores/productionSafetyStore", () => ({
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
connections: [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql" }],
get connections() {
return mocks.connections;
},
ensureConnected: mocks.ensureConnected,
getConfig: () => ({ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }),
getConfig: mocks.getConfig,
refreshDatabaseTreeNode: mocks.refreshDatabaseTreeNode,
}),
}));
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => mocks.queryStore }));
vi.mock("@/lib/backend/api", () => ({
beginManualTransaction: mocks.beginManualTransaction,
commitManualTransaction: mocks.commitManualTransaction,
@@ -126,6 +135,7 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: passthrough("sp
vi.mock("@/components/connection/ConnectionGroupBadge.vue", () => ({ default: passthrough("span") }));
import SqlFileExecutionDialog from "./SqlFileExecutionDialog.vue";
import { rememberExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
@@ -187,6 +197,7 @@ function deferred() {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mocks.desktop = true;
mocks.releaseSqlFilePreview.mockResolvedValue(undefined);
mocks.progressHandler = undefined;
@@ -230,6 +241,8 @@ beforeEach(() => {
{ database: "archive", name: "users" },
]);
mocks.fetchSqlFileTargetOptions.mockResolvedValue([]);
mocks.connections = [{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "" }];
mocks.getConfig.mockImplementation((connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId));
mocks.openFileDialog.mockResolvedValue(["/tmp/first.sql", "/tmp/second.sql"]);
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({
fileName: filePath.split("/").pop()!,
@@ -246,6 +259,8 @@ beforeEach(() => {
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
mocks.requestConfirmation.mockResolvedValue(true);
mocks.uuid.mockReset().mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
mocks.queryStore.tabs = [{ id: "mysql-tab", connectionId: "mysql-1", database: "" }];
mocks.queryStore.activeTabId = "mysql-tab";
});
afterEach(() => {
@@ -604,6 +619,61 @@ describe("SqlFileExecutionDialog retries", () => {
expect(findButton("sqlFile.execute").disabled).toBe(true);
});
it("uses the active SQL tab instead of the first connection when opened from the toolbar", async () => {
mocks.connections = [
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "first" },
{ id: "postgres-1", name: "PostgreSQL", db_type: "postgres", driver_profile: "postgres", database: "analytics" },
];
mocks.queryStore.tabs = [{ id: "postgres-tab", connectionId: "postgres-1", database: "reporting" }];
mocks.queryStore.activeTabId = "postgres-tab";
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app.mount(root);
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("postgres-1"));
expect(mocks.ensureConnected).not.toHaveBeenCalledWith("mysql-1");
});
it("does not select a connection when the active tab is non-SQL", async () => {
mocks.connections = [
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "first" },
{ id: "kafka-1", name: "Kafka", db_type: "mq", driver_profile: "kafka", database: "invalid" },
];
mocks.queryStore.tabs = [{ id: "kafka-tab", connectionId: "kafka-1", database: "invalid" }];
mocks.queryStore.activeTabId = "kafka-tab";
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app.mount(root);
await nextTick();
expect(mocks.ensureConnected).not.toHaveBeenCalled();
expect(mocks.fetchSqlFileTargetOptions).not.toHaveBeenCalled();
});
it("lets a remembered file target override the active toolbar target", async () => {
mocks.connections = [
{ id: "mysql-1", name: "MySQL", db_type: "mysql", driver_profile: "mysql", database: "app" },
{ id: "saved-1", name: "Saved", db_type: "postgres", driver_profile: "postgres", database: "saved_db" },
];
rememberExternalSqlFileTarget("/tmp/saved.sql", { connectionId: "saved-1", database: "saved_db" });
mocks.openFileDialog.mockResolvedValueOnce("/tmp/saved.sql");
root = document.createElement("div");
document.body.append(root);
app = createApp(SqlFileExecutionDialog, { open: true });
app.mount(root);
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("mysql-1"));
mocks.ensureConnected.mockClear();
findButton("sqlFile.browse").click();
await vi.waitFor(() => expect(mocks.ensureConnected).toHaveBeenCalledWith("saved-1"));
});
it("does not restore a completed run's file summary after an early retry failure", async () => {
await mountReadyDialog();
await completeFirstExecution();
@@ -14,14 +14,17 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { supportsSqlFileExecution, supportsTransaction } from "@/lib/database/databaseFeatureSupport";
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { beginManualTransaction, commitManualTransaction, rollbackManualTransaction, cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
import { activeTabExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { isSqlFilePath } from "@/lib/sql/sqlFileOpen";
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
import { parseSqlFilePathInput } from "./sqlFilePathInput";
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
@@ -43,6 +46,7 @@ const props = defineProps<{
}>();
const store = useConnectionStore();
const queryStore = useQueryStore();
const productionSafetyStore = useProductionSafetyStore();
// Tauri = real filesystem paths; Web = browser File.name (no path) + server temp paths.
const isDesktopRuntime = isTauriRuntime();
@@ -92,7 +96,7 @@ async function commitPathInput() {
return;
}
const typed = pathInput.value;
await loadPreviews(paths);
await loadPreviews(paths, true);
// Failed load: keep what was typed so the path can be corrected.
pathInput.value = previews.value.length > 0 ? filePathDisplay.value : typed;
}
@@ -116,6 +120,7 @@ const connectionId = ref("");
const database = ref("");
const databaseOptions = ref<string[]>([]);
const loadingDatabases = ref(false);
const preferredTarget = ref<ExternalSqlFileTarget>();
const continueOnError = ref(false);
const skipRelationalConstraints = ref(false);
const manualTransaction = ref(false);
@@ -169,7 +174,7 @@ function resetPerFileState() {
currentFileName.value = "";
}
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((connection) => supportsSqlFileExecution(connection.db_type)));
// Mirrors the core executor gate (`relational_constraint_bypass_kind` in
// sql_file_import.rs): MySQL-family types use the session-scoped
// FOREIGN_KEY_CHECKS toggle, PostgreSQL-family types use DISABLE/ENABLE
@@ -376,22 +381,29 @@ function statusLabel(status: SqlFileStatus | "idle") {
return t(`sqlFile.status.${status}`);
}
function resolveInitialConnectionId() {
function resolveInitialTarget(): ExternalSqlFileTarget {
if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) {
return props.prefillConnectionId;
return { connectionId: props.prefillConnectionId, database: props.prefillDatabase ?? "" };
}
if (props.prefillFilePath) return "";
return sqlConnections.value[0]?.id ?? "";
if (props.prefillFilePath) return { connectionId: "", database: "" };
return activeTabExternalSqlFileTarget(queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId));
}
function applyTarget(target: ExternalSqlFileTarget) {
preferredTarget.value = target;
connectionId.value = target.connectionId;
database.value = target.database;
}
function chooseDatabase(names: string[], id: string) {
const configDatabase = store.getConfig(id)?.database ?? "";
const preferredDatabase = preferredTarget.value?.connectionId === id ? preferredTarget.value.database : props.prefillDatabase;
if (names.length > 0) {
if (props.prefillDatabase && names.includes(props.prefillDatabase)) return props.prefillDatabase;
if (preferredDatabase && names.includes(preferredDatabase)) return preferredDatabase;
if (configDatabase && names.includes(configDatabase)) return configDatabase;
return names.length === 1 ? names[0] : "";
}
return props.prefillDatabase ?? configDatabase;
return preferredDatabase ?? configDatabase;
}
function resetExecution() {
@@ -410,11 +422,11 @@ function resetExecution() {
}
function resetState() {
const initialTarget = resolveInitialTarget();
previews.value = [];
selectingFile.value = false;
loadingPreview.value = false;
connectionId.value = resolveInitialConnectionId();
database.value = "";
applyTarget(initialTarget);
databaseOptions.value = [];
loadingDatabases.value = false;
continueOnError.value = false;
@@ -469,7 +481,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
return previewWebSqlFile(file);
}
async function loadPreviews(filesOrPaths: Array<string | File>) {
async function loadPreviews(filesOrPaths: Array<string | File>, resolveSelectedFileTarget = false) {
if (executionLocked.value) return;
await releaseManagedPreviews();
loadingPreview.value = true;
@@ -481,6 +493,11 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
nextPreviews.push(await previewSelectedSqlFile(fileOrPath));
}
previews.value = nextPreviews;
if (resolveSelectedFileTarget) {
const firstPath = typeof filesOrPaths[0] === "string" && isSqlFilePath(filesOrPaths[0]) ? filesOrPaths[0] : undefined;
const target = firstPath ? resolveExternalSqlFileTargetForActiveTab(firstPath, queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId)) : activeTabExternalSqlFileTarget(queryStore.tabs, queryStore.activeTabId, (connectionId) => store.getConfig(connectionId));
applyTarget(target);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
} finally {
@@ -515,7 +532,7 @@ async function selectFile() {
});
const paths = Array.isArray(selected) ? selected : selected ? [selected] : [];
if (paths.length > 0) {
await loadPreviews(paths);
await loadPreviews(paths, true);
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
@@ -531,7 +548,7 @@ async function handleFileInputChange(event: Event) {
if (files.length === 0 || executionLocked.value) return;
selectingFile.value = true;
try {
await loadPreviews(files);
await loadPreviews(files, true);
} finally {
selectingFile.value = false;
}
@@ -772,7 +789,7 @@ watch(connectionId, (id) => {
watch(sqlConnections, () => {
if (!open.value || executionLocked.value || selectedConnection.value) return;
connectionId.value = resolveInitialConnectionId();
applyTarget(resolveInitialTarget());
});
watch(
@@ -6637,11 +6637,6 @@ watch(
inset 0 1px 0 color-mix(in oklab, var(--primary) 55%, transparent),
inset 0 -1px 0 color-mix(in oklab, var(--primary) 55%, transparent);
}
/* Inputs are bg-transparent; give them a solid surface on the selected row so fields stay readable. */
.structure-column-search-current :is(input, button, [role="combobox"], [data-slot="select-trigger"]) {
background-color: var(--background);
}
</style>
<style>
@@ -0,0 +1,24 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../TableStructureEditor.vue", import.meta.url), "utf8");
const scopedStyleStart = source.indexOf("<style scoped>");
const scopedStyleEnd = source.indexOf("</style>", scopedStyleStart);
const scopedStyle = source.slice(scopedStyleStart, scopedStyleEnd).replace(/\/\*[\s\S]*?\*\//g, "");
const rules = [...scopedStyle.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((match) => ({
selectors: match[1]!.split(",").map((selector) => selector.trim()),
declarations: match[2]!,
}));
describe("TableStructureEditor selected-row styles", () => {
it("keeps the highlight on cells without repainting descendant controls", () => {
const selectedBackgroundRules = rules.filter(({ selectors, declarations }) => selectors.some((selector) => selector.includes(".structure-column-search-current")) && /background(?:-color)?\s*:/.test(declarations));
expect(selectedBackgroundRules).toHaveLength(1);
expect(selectedBackgroundRules[0]?.selectors).toEqual([".structure-column-search-current > td"]);
expect(selectedBackgroundRules[0]?.declarations).toContain("var(--primary)");
const gridControlRule = rules.find(({ selectors }) => selectors.includes(".structure-edit-grid :deep(.structure-grid-control)"));
expect(gridControlRule?.declarations).toContain("background-color: transparent;");
});
});
@@ -4,7 +4,7 @@ import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { buildTransferObjectSelections } from "./transferSelections";
import { buildTransferObjectSelections, countTransferObjects } from "./transferSelections";
import { createTaskLoadTracker } from "./taskLoadTracker";
import { confirmTransferWithProductionSafety, createTransferSubmission, rebuildUnavailableReason, resolveTransferStrategy, transferStrategyOptions, type TransferStrategy } from "./transferStrategy";
import { Input } from "@/components/ui/input";
@@ -21,6 +21,7 @@ import type { TransferContent, TransferObjectKind, TransferTableNameCase } from
import { crossFamilyTransferableKinds, isSameTransferFamily, transferObjectKindsForDatabase } from "@/lib/database/transferObjectKinds";
import ObjectSelectionTree from "@/components/transfer/ObjectSelectionTree.vue";
import TransferTaskTree from "@/components/transfer/TransferTaskTree.vue";
import DataTransferProgressDialog from "@/components/transfer/DataTransferProgressDialog.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import type { DatabaseType } from "@/types/database";
import type { TransferTask, TransferTaskConfig } from "@/types/database";
@@ -31,13 +32,14 @@ import { decodeTransferDatabaseOption, encodeTransferDatabaseOptions, formatTran
import { formatDatabaseLabel } from "@/lib/database/defaultDatabase";
import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
import { useExportTracker } from "@/composables/useExportTracker";
import { openDataTransferTask } from "@/composables/useDialogSources";
import { useTransferTaskStore, TransferTaskNameConflictError, nextTransferTaskCopyName } from "@/stores/transferTaskStore";
import { useToast } from "@/composables/useToast";
import type { CatalogInfo } from "@/types/database";
import { ArrowRightLeft, ArrowLeftRight, Loader2 } from "@lucide/vue";
const { t } = useI18n();
const { startDataTransferTask } = useExportTracker();
const { tasks, startDataTransferTask } = useExportTracker();
const { toast } = useToast();
const taskStore = useTransferTaskStore();
const productionSafetyStore = useProductionSafetyStore();
@@ -52,8 +54,14 @@ const props = defineProps<{
prefillTargetConnectionId?: string;
prefillTargetDatabase?: string;
prefillTargetSchema?: string;
taskId?: string | null;
}>();
const trackedTransferTask = computed(() => {
if (!props.taskId) return undefined;
return tasks.value.find((task) => task.exportId === props.taskId && task.kind === "data-transfer");
});
const transferDialogStyle = {
width: "min(1120px, calc(100vw - 2rem))",
height: "min(80vh, calc(var(--dbx-viewport-height) - 2rem))",
@@ -609,9 +617,9 @@ watch(targetDatabase, async (db) => {
});
watch(
open,
async (val) => {
if (val) {
[open, () => props.taskId],
async ([val, taskId]) => {
if (val && !taskId) {
void taskStore.initFromStorage();
resetState();
pendingSourceSchemaPrefill.value = props.prefillSchema ?? "";
@@ -815,6 +823,8 @@ function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: bool
isSubmitting.value = true;
startDataTransferTask(request, `${request.sourceDatabase} → ${request.targetDatabase}`, {
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
onStarted: () => toast(t("transfer.backgroundStarted")),
onOpen: () => openDataTransferTask(request.transferId),
onDone: async () => {
if (shouldRefreshTargetTree) {
await store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog);
@@ -1052,7 +1062,7 @@ const confirmationSummary = computed(() => {
if (!request) return "";
const source = formatTransferEndpointLabel(getConnectionName(request.sourceConnectionId), request.sourceDatabase, request.sourceSchema, request.sourceCatalog);
const target = formatTransferEndpointLabel(getConnectionName(request.targetConnectionId), request.targetDatabase, request.targetSchema, request.targetCatalog);
const count = request.tables.length + request.objects.reduce((total, selection) => total + selection.names.length, 0);
const count = countTransferObjects(request);
return t("transfer.startConfirmMessage", { source, target, count });
});
@@ -1154,7 +1164,8 @@ async function saveConfigTask() {
</script>
<template>
<Dialog v-model:open="open">
<DataTransferProgressDialog v-if="trackedTransferTask" v-model:open="open" :task="trackedTransferTask" />
<Dialog v-else v-model:open="open">
<DialogContent class="dbx-transfer-dialog sm:max-w-[1120px] max-h-[80vh] flex flex-col overflow-hidden resize" :style="transferDialogStyle" @interact-outside.prevent>
<DialogHeader class="shrink-0">
<DialogTitle class="flex items-center gap-2">
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { AlertCircle, ArrowRightLeft, CheckCircle2, Loader2, Minimize2, X, XCircle } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { formatDataTransferDuration, useExportTracker, type ExportTask } from "@/composables/useExportTracker";
import { translateBackendError } from "@/i18n/backend-errors";
const { t } = useI18n();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{ task: ExportTask }>();
const { cancelTask } = useExportTracker();
const currentTime = ref(Date.now());
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
const isActive = computed(() => ["Running", "Writing", "Cancelling"].includes(props.task.status));
const progressPercent = computed(() => {
if (props.task.status === "Done") return 100;
if (!props.task.totalTables) return 0;
return Math.min(95, Math.round(((props.task.tableIndex ?? 0) / props.task.totalTables) * 100));
});
const elapsed = computed(() => {
const startedAt = props.task.startedAt ?? currentTime.value;
return formatDataTransferDuration((props.task.finishedAt ?? currentTime.value) - startedAt);
});
const statusText = computed(() => {
if (props.task.status === "Cancelling") return t("databaseBackup.cancelling");
if (props.task.status === "Done") return t("exportProgress.compareDone");
if (props.task.status === "Error") return t("exportProgress.compareError");
if (props.task.status === "Cancelled") return t("exportProgress.compareCancelled");
return t("exportProgress.compareRunning");
});
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
</script>
<template>
<Dialog :open="open" @update:open="open = $event">
<DialogContent class="sm:max-w-lg" data-transfer-progress-dialog @interact-outside.prevent>
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ArrowRightLeft class="h-4 w-4" />
{{ t("exportProgress.dataTransferTitle", { name: task.tableName }) }}
</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-3">
<div class="flex items-center gap-2 text-sm">
<Loader2 v-if="isActive" class="h-4 w-4 animate-spin text-primary" />
<CheckCircle2 v-else-if="task.status === 'Done'" class="h-4 w-4 text-green-500" />
<XCircle v-else-if="task.status === 'Error'" class="h-4 w-4 text-destructive" />
<AlertCircle v-else class="h-4 w-4 text-yellow-500" />
<span>{{ statusText }}</span>
</div>
<div class="space-y-1.5">
<div class="h-2 w-full overflow-hidden rounded-full bg-muted">
<div v-if="task.totalTables" class="h-full rounded-full transition-[width] duration-300" :class="task.status === 'Done' ? 'bg-green-500' : 'bg-primary'" :style="{ width: `${progressPercent}%` }" />
<div v-else-if="isActive" class="export-progress-indeterminate h-full rounded-full bg-primary" />
</div>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span class="truncate">{{ task.currentTable || task.tableName }}</span>
<span class="shrink-0 tabular-nums">{{ task.tableIndex ?? 0 }} / {{ task.totalTables ?? 0 }} · {{ task.rowsExported.toLocaleString() }} {{ t("exportProgress.rowsShort") }}</span>
</div>
<div class="text-xs text-muted-foreground">{{ t("exportProgress.elapsed", { duration: elapsed }) }}</div>
</div>
<div v-if="task.errorMessage" class="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{{ translateBackendError(t, task.errorMessage) }}
</div>
</div>
<DialogFooter>
<template v-if="isActive">
<Button variant="ghost" size="sm" @click="open = false">
<Minimize2 class="mr-1 h-3.5 w-3.5" />
{{ t("exportProgress.minimize") }}
</Button>
<Button variant="outline" size="sm" :disabled="task.status === 'Cancelling'" @click="cancelTask(task.exportId)">
<Loader2 v-if="task.status === 'Cancelling'" class="mr-1 h-3.5 w-3.5 animate-spin" />
<X v-else class="mr-1 h-3.5 w-3.5" />
{{ task.status === "Cancelling" ? t("databaseBackup.cancelling") : t("exportProgress.cancel") }}
</Button>
</template>
<Button v-else variant="outline" size="sm" @click="open = false">{{ t("exportProgress.close") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>
.export-progress-indeterminate {
width: 42%;
animation: export-progress-slide 1.15s ease-in-out infinite;
}
@keyframes export-progress-slide {
0% {
transform: translateX(-110%);
}
50% {
transform: translateX(70%);
}
100% {
transform: translateX(250%);
}
}
</style>
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
const mocks = vi.hoisted(() => ({
cancelTransfer: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
cancelDatabaseExport: vi.fn(),
cancelSqlFileExecution: vi.fn(),
cancelTableExport: vi.fn(),
cancelTransfer: mocks.cancelTransfer,
startTransfer: vi.fn(),
}));
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return { Dialog: passthrough, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough };
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
setup(_props, { slots }) {
return () => h("button", slots.default?.());
},
}),
};
});
import DataTransferProgressDialog from "@/components/transfer/DataTransferProgressDialog.vue";
import { useExportTracker } from "@/composables/useExportTracker";
const mountedApps: App[] = [];
function resetTracker() {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
}
beforeEach(() => {
vi.clearAllMocks();
resetTracker();
i18n.global.locale.value = "en";
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
resetTracker();
});
describe("DataTransferProgressDialog", () => {
it("minimizes without cancelling and keeps the explicit cancel action", async () => {
const tracker = useExportTracker();
const task = tracker.addDataTransferTask("transfer-progress", "source → target", 2);
let dialogOpen!: Ref<boolean>;
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
dialogOpen = ref(true);
return () => h(DataTransferProgressDialog, { open: dialogOpen.value, "onUpdate:open": (value: boolean) => (dialogOpen.value = value), task });
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
const buttons = () => [...document.body.querySelectorAll<HTMLButtonElement>("button")];
buttons()
.find((button) => button.textContent?.includes("Minimize to background"))
?.click();
await nextTick();
expect(dialogOpen.value).toBe(false);
expect(mocks.cancelTransfer).not.toHaveBeenCalled();
expect(task.status).toBe("Running");
dialogOpen.value = true;
await nextTick();
buttons()
.find((button) => button.textContent?.trim() === "Cancel")
?.click();
await vi.waitFor(() => expect(mocks.cancelTransfer).toHaveBeenCalledWith(task.exportId));
});
});
@@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildTransferObjectSelections, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
import { buildTransferObjectSelections, countTransferObjects, matchBulkObjectNames, parseBulkObjectNames } from "../transferSelections";
function setOf(names: string[]): Set<string> {
return new Set(names);
}
describe("countTransferObjects", () => {
it("counts selected tables and non-table objects from their separate request fields", () => {
expect(
countTransferObjects({
tables: ["orders", "users"],
objects: [
{ objectType: "VIEW", names: ["active_users"] },
{ objectType: "SEQUENCE", names: ["order_id_seq"] },
],
}),
).toBe(4);
});
it("keeps a table-only multi-selection nonzero", () => {
expect(countTransferObjects({ tables: ["orders", "users"], objects: [] })).toBe(2);
});
});
describe("buildTransferObjectSelections", () => {
it("serializes non-table selections in request order", () => {
const result = buildTransferObjectSelections(
@@ -1,10 +1,15 @@
import type { TransferObjectKind } from "@/lib/backend/api";
import type { TransferObjectKind, TransferRequest } from "@/lib/backend/api";
export interface TransferObjectSelectionPayload {
objectType: TransferObjectKind;
names: string[];
}
/** Counts table and non-table selections represented by a transfer request. */
export function countTransferObjects(request: Pick<TransferRequest, "tables" | "objects">): number {
return request.tables.length + request.objects.reduce((total, selection) => total + selection.names.length, 0);
}
/**
* Builds the `objects` payload of a transfer request from the tree
* selection state. TABLE is handled by `tables`, and object types that
@@ -43,6 +43,9 @@ const labels = computed(() => ({
saved: t("zookeeper.saved"),
deleted: t("zookeeper.deleted"),
base64Readonly: t("zookeeper.base64Readonly"),
clone: t("zookeeper.clone"),
copyKey: t("zookeeper.copyKey"),
export: t("zookeeper.export"),
utf8PreviewLossy: t("zookeeper.utf8PreviewLossy"),
utf8PreviewUnavailable: t("zookeeper.utf8PreviewUnavailable"),
createMode: t("zookeeper.createMode"),
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
cancelQuery: vi.fn(),
executeMultiWithProgress: vi.fn(),
openDataGenerateSession: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
cancelDatabaseExport: vi.fn(),
cancelQuery: mocks.cancelQuery,
cancelSqlFileExecution: vi.fn(),
cancelTableExport: vi.fn(),
cancelTransfer: vi.fn(),
executeMultiWithProgress: mocks.executeMultiWithProgress,
startTransfer: vi.fn(),
}));
vi.mock("@/composables/useDialogSources", () => ({
openDataGenerateSession: mocks.openDataGenerateSession,
}));
import { getDataGenerateSession, startDataGenerateSession } from "@/composables/useDataGenerateSession";
import { useExportTracker } from "@/composables/useExportTracker";
function successfulResult(): QueryResult[] {
return [
{
columns: [],
rows: [],
affected_rows: 2,
execution_time_ms: 1,
},
];
}
function startSession() {
return startDataGenerateSession(
{
connectionId: "connection-1",
database: "app",
prefillSchema: "public",
databaseType: "postgres",
label: "app.users",
targets: [
{
tableName: "users",
schema: "public",
database: "app",
targetRowCount: 2,
isSample: false,
columns: ["id"],
rows: [[1], [2]],
sql: "INSERT INTO users VALUES (1), (2);",
statements: ["INSERT INTO users VALUES (1), (2);"],
resolvedColumns: [
{
columnName: "id",
dataType: "integer",
rowCount: 2,
generatorKey: "sequence",
generatorParams: { startValue: 1, increment: 1 },
},
],
},
],
options: {
continueOnError: false,
truncate: false,
useTransaction: true,
extendedInsert: true,
timeoutSecs: 0,
batchRows: 2,
},
},
{ invalidateMetadataCache: vi.fn() },
);
}
function resetTracker() {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
}
beforeEach(() => {
vi.clearAllMocks();
resetTracker();
});
afterEach(() => {
resetTracker();
});
describe("data generation background sessions", () => {
it("continues outside the dialog, reopens from the task entry, and cleans up after completion", async () => {
let finishBatch!: (result: QueryResult[]) => void;
mocks.executeMultiWithProgress.mockImplementationOnce(
() =>
new Promise<QueryResult[]>((resolve) => {
finishBatch = resolve;
}),
);
const tracker = useExportTracker();
const session = startSession();
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
await vi.waitFor(() => expect(mocks.executeMultiWithProgress).toHaveBeenCalledOnce());
expect(session.status).toBe("running");
expect(task.status).toBe("Running");
task.onOpen?.();
expect(mocks.openDataGenerateSession).toHaveBeenCalledWith(session.id, {
connectionId: "connection-1",
database: "app",
schema: "public",
tableName: "users",
});
finishBatch(successfulResult());
await vi.waitFor(() => expect(session.status).toBe("completed"));
expect(task.status).toBe("Done");
expect(task.rowsExported).toBe(2);
expect(session.results).toEqual([{ table: "users", total: 2, ok: 2, err: 0, error: undefined, cancelled: undefined }]);
tracker.removeTask(session.id);
expect(getDataGenerateSession(session.id)).toBeUndefined();
});
it("keeps cancel separate from minimize and publishes the cancelled terminal state", async () => {
let failBatch!: (error: Error) => void;
mocks.executeMultiWithProgress.mockImplementationOnce(
() =>
new Promise<QueryResult[]>((_resolve, reject) => {
failBatch = reject;
}),
);
mocks.cancelQuery.mockResolvedValueOnce(true);
const tracker = useExportTracker();
const session = startSession();
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
await vi.waitFor(() => expect(mocks.executeMultiWithProgress).toHaveBeenCalledOnce());
await tracker.cancelTask(session.id);
expect(mocks.cancelQuery).toHaveBeenCalledWith(session.id);
expect(session.status).toBe("cancelling");
expect(task.status).toBe("Cancelling");
failBatch(new Error("Query canceled"));
await vi.waitFor(() => expect(session.status).toBe("cancelled"));
expect(task.status).toBe("Cancelled");
expect(session.results[0]?.cancelled).toBe(true);
});
it("retains per-table failures in the resumable result", async () => {
mocks.executeMultiWithProgress.mockResolvedValueOnce([
{
columns: ["Error"],
rows: [["duplicate key"]],
affected_rows: 0,
execution_time_ms: 1,
execution_error: true,
} as QueryResult,
]);
const tracker = useExportTracker();
const session = startSession();
const task = tracker.tasks.value.find((candidate) => candidate.exportId === session.id)!;
await vi.waitFor(() => expect(session.status).toBe("completed"));
expect(task.status).toBe("Error");
expect(task.errorMessage).toBe("duplicate key");
expect(session.results[0]).toMatchObject({ table: "users", ok: 0, err: 2, error: "duplicate key" });
});
});
@@ -3,7 +3,6 @@
import { createApp, defineComponent, h, ref, computed } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridColumnFilters, type DataGridColumnFilterState, type DataGridLocalFilterDraft } from "@/composables/useDataGridColumnFilters";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import type { QueryResult } from "@/types/database";
@@ -17,11 +16,6 @@ function mountColumnFilters(rows: CellValue[][]) {
localFilterOpenColumn: ref<number | null>(null),
localFilterSearch: ref(""),
localFilterDraft: ref<DataGridLocalFilterDraft | null>(null),
serverFilterLoading: ref(false),
serverFilterError: ref(""),
serverFilterOptions: ref<DataGridLocalFilterOption[]>([]),
serverFilterLimited: ref(false),
serverFilterValueByKey: ref(new Map()),
serverColumnFilters: ref({}),
};
const result: QueryResult = { columns: ["status"], rows };
@@ -36,6 +30,7 @@ function mountColumnFilters(rows: CellValue[][]) {
getConnectionId: () => undefined,
getSchema: () => undefined,
getExecutionDatabase: () => "",
scopeIdentity: computed(() => "test"),
resolvedDatabaseType: computed(() => undefined),
canUseWhereSearch: computed(() => false),
canUseServerColumnFilter: computed(() => false),
@@ -44,6 +39,7 @@ function mountColumnFilters(rows: CellValue[][]) {
whereFilterInput: ref(""),
getConnectionConfig: () => undefined,
getIdentifierQuote: () => undefined,
getGlobalQueryTimeoutSecs: () => undefined,
getNewRows: () => [],
getRowData: (row) => row,
formatValue: (value) => String(value),

Some files were not shown because too many files have changed in this diff Show More