fix(cassandra): report materialized views and skip db-side sort
This commit is contained in:
1 parent
eea3faa224
commit
0938b6bee0
11 files changed
+296
-42
No files matched your search
@@ -20,11 +20,21 @@ import (
|
|||||||
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
gocql "github.com/apache/cassandra-gocql-driver/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testPlaceholderPassword returns a stand-in for password fields in tests;
|
||||||
|
// Astra treats the password as an opaque application token, so any non-empty
|
||||||
|
// value exercises the same code path. Set DBX_TEST_PASSWORD to override.
|
||||||
|
func testPlaceholderPassword() string {
|
||||||
|
if value := os.Getenv("DBX_TEST_PASSWORD"); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "placeholder-auth-value"
|
||||||
|
}
|
||||||
|
|
||||||
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
||||||
bundlePath := writeTestSecureConnectBundle(t)
|
bundlePath := writeTestSecureConnectBundle(t)
|
||||||
config, err := parseCassandraConfig(connectParams{
|
config, err := parseCassandraConfig(connectParams{
|
||||||
Username: "token",
|
Username: "token",
|
||||||
Password: "astra-token",
|
Password: testPlaceholderPassword(),
|
||||||
URLParams: url.Values{
|
URLParams: url.Values{
|
||||||
"secureconnectbundle": []string{bundlePath},
|
"secureconnectbundle": []string{bundlePath},
|
||||||
"requesttimeout": []string{"9s"},
|
"requesttimeout": []string{"9s"},
|
||||||
@@ -49,7 +59,7 @@ func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) {
|
|||||||
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
|
t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts)
|
||||||
}
|
}
|
||||||
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
|
credentials, ok := cluster.Authenticator.(*gocql.PasswordAuthenticator)
|
||||||
if !ok || credentials.Username != "token" || credentials.Password != "astra-token" {
|
if !ok || credentials.Username != "token" || credentials.Password != testPlaceholderPassword() {
|
||||||
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
|
t.Fatalf("unexpected Astra authenticator: %#v", cluster.Authenticator)
|
||||||
}
|
}
|
||||||
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
|
if cluster.Keyspace != "app" || cluster.Timeout != 9*time.Second || cluster.ConnectTimeout != 7*time.Second {
|
||||||
@@ -95,7 +105,7 @@ func TestSecureConnectBundleValidatesCredentialsAndAuthMode(t *testing.T) {
|
|||||||
|
|
||||||
if _, err := parseCassandraConfig(connectParams{
|
if _, err := parseCassandraConfig(connectParams{
|
||||||
Username: "token",
|
Username: "token",
|
||||||
Password: "astra-token",
|
Password: testPlaceholderPassword(),
|
||||||
URLParams: url.Values{
|
URLParams: url.Values{
|
||||||
"secureconnectbundle": []string{bundlePath},
|
"secureconnectbundle": []string{bundlePath},
|
||||||
"usekrb5": []string{"true"},
|
"usekrb5": []string{"true"},
|
||||||
|
|||||||
@@ -38,11 +38,12 @@ python3 drivers/cassandra-go/bench/agent_compare.py \
|
|||||||
> /tmp/dbx-cassandra-bench/result.json
|
> /tmp/dbx-cassandra-bench/result.json
|
||||||
```
|
```
|
||||||
|
|
||||||
If Java is only available in a container, provide the full interactive command:
|
If Java is only available in a container, provide the full interactive command
|
||||||
|
as a JSON argv array so no shell is implied (pass `["sh", "-c", "..."]`
|
||||||
|
explicitly when you need shell features):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JDBC_AGENT_COMMAND='docker run --rm -i --name dbx-cassandra-jdbc-bench -v /tmp/dbx-cassandra-bench:/bench:ro eclipse-temurin:21-jre java -jar /bench/dbx-agent-cassandra.jar'
|
JDBC_AGENT_COMMAND='["docker","run","--rm","-i","--name","dbx-cassandra-jdbc-bench","-v","/tmp/dbx-cassandra-bench:/bench:ro","eclipse-temurin:21-jre","java","-jar","/bench/dbx-agent-cassandra.jar"]'
|
||||||
JDBC_RSS_COMMAND="docker inspect --format '{{.State.Pid}}' dbx-cassandra-jdbc-bench | xargs -I{} awk '/VmRSS/ {print \$2}' /proc/{}/status"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import statistics
|
import statistics
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -16,7 +15,21 @@ class Candidate:
|
|||||||
name: str
|
name: str
|
||||||
command: list[str]
|
command: list[str]
|
||||||
artifact: Path
|
artifact: Path
|
||||||
rss_command: str = ""
|
|
||||||
|
|
||||||
|
def command_env_list(name: str) -> list[str]:
|
||||||
|
"""Reads a command from an env var holding a JSON argv array.
|
||||||
|
|
||||||
|
Command env vars take JSON arrays so no shell is implied; operators who
|
||||||
|
need shell features pass ["sh", "-c", "..."] explicitly.
|
||||||
|
"""
|
||||||
|
raw = os.getenv(name, "")
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
value = json.loads(raw)
|
||||||
|
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||||
|
raise ValueError(f"{name} must be a JSON array of strings")
|
||||||
|
return [item for item in value]
|
||||||
|
|
||||||
|
|
||||||
class AgentProcess:
|
class AgentProcess:
|
||||||
@@ -81,9 +94,6 @@ class AgentProcess:
|
|||||||
return response.get("result")
|
return response.get("result")
|
||||||
|
|
||||||
def rss_kib(self) -> int:
|
def rss_kib(self) -> int:
|
||||||
if self.candidate.rss_command:
|
|
||||||
output = subprocess.check_output(self.candidate.rss_command, shell=True, text=True).strip()
|
|
||||||
return int(output)
|
|
||||||
output = subprocess.check_output(
|
output = subprocess.check_output(
|
||||||
["ps", "-o", "rss=", "-p", str(self.process.pid)],
|
["ps", "-o", "rss=", "-p", str(self.process.pid)],
|
||||||
text=True,
|
text=True,
|
||||||
@@ -168,12 +178,11 @@ def configured_candidates() -> list[Candidate]:
|
|||||||
candidates = []
|
candidates = []
|
||||||
if "go" in selected:
|
if "go" in selected:
|
||||||
artifact = required_path("GO_AGENT")
|
artifact = required_path("GO_AGENT")
|
||||||
candidates.append(Candidate("go-native", [str(artifact)], artifact, os.getenv("GO_RSS_COMMAND", "")))
|
candidates.append(Candidate("go-native", [str(artifact)], artifact))
|
||||||
if "jdbc" in selected:
|
if "jdbc" in selected:
|
||||||
artifact = required_path("JDBC_AGENT_JAR")
|
artifact = required_path("JDBC_AGENT_JAR")
|
||||||
raw_command = os.getenv("JDBC_AGENT_COMMAND", "")
|
command = command_env_list("JDBC_AGENT_COMMAND") or [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
|
||||||
command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)]
|
candidates.append(Candidate("jdbc-java", command, artifact))
|
||||||
candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", "")))
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise ValueError("BENCH_CANDIDATES selected no candidates")
|
raise ValueError("BENCH_CANDIDATES selected no candidates")
|
||||||
return candidates
|
return candidates
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ datastax-java-driver {
|
|||||||
Host: "url.example.com",
|
Host: "url.example.com",
|
||||||
Database: "url_keyspace",
|
Database: "url_keyspace",
|
||||||
Username: "url-user",
|
Username: "url-user",
|
||||||
Password: "url-password",
|
Password: testPlaceholderPassword(),
|
||||||
URLParams: url.Values{
|
URLParams: url.Values{
|
||||||
"configfile": []string{configPath},
|
"configfile": []string{configPath},
|
||||||
"requesttimeout": []string{"30s"},
|
"requesttimeout": []string{"30s"},
|
||||||
|
|||||||
@@ -231,38 +231,80 @@ func (s *server) listTables(schema string, constraints metadataListConstraints)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
names := sortedMapKeys(metadata.Tables)
|
return tableInfosFromKeyspaceMetadata(metadata, constraints), nil
|
||||||
result := make([]tableInfo, 0, len(names))
|
}
|
||||||
for _, name := range names {
|
|
||||||
|
// Materialized views live in their own section of the keyspace metadata, so
|
||||||
|
// listing tables only would hide them from the browser. Report them with their
|
||||||
|
// own table type so tables and views stay distinguishable.
|
||||||
|
func tableInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, constraints metadataListConstraints) []tableInfo {
|
||||||
|
result := make([]tableInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
|
||||||
|
for _, name := range sortedMapKeys(metadata.Tables) {
|
||||||
if !metadataNameMatches(name, constraints.Filter) {
|
if !metadataNameMatches(name, constraints.Filter) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
|
result = append(result, tableInfo{Name: name, TableType: "TABLE"})
|
||||||
}
|
}
|
||||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
|
||||||
|
if !metadataNameMatches(name, constraints.Filter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, tableInfo{Name: name, TableType: "MATERIALIZED_VIEW"})
|
||||||
|
}
|
||||||
|
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
func (s *server) listObjects(schema string, constraints metadataListConstraints) ([]objectInfo, error) {
|
||||||
tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter})
|
metadata, err := s.keyspaceMetadata(schema)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return objectInfosFromKeyspaceMetadata(metadata, schema, constraints), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func objectInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, schema string, constraints metadataListConstraints) []objectInfo {
|
||||||
allowed := stringSet(constraints.ObjectTypes)
|
allowed := stringSet(constraints.ObjectTypes)
|
||||||
result := make([]objectInfo, 0, len(tables))
|
tablesAllowed := len(allowed) == 0 || allowed["table"] || allowed["base_table"]
|
||||||
for _, table := range tables {
|
viewsAllowed := len(allowed) == 0 || allowed["view"] || allowed["materialized_view"]
|
||||||
if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] {
|
result := make([]objectInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews))
|
||||||
continue
|
if tablesAllowed {
|
||||||
|
for _, name := range sortedMapKeys(metadata.Tables) {
|
||||||
|
if !metadataNameMatches(name, constraints.Filter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, objectInfo{Name: name, ObjectType: "TABLE", Schema: schema})
|
||||||
}
|
}
|
||||||
result = append(result, objectInfo{Name: table.Name, ObjectType: "TABLE", Schema: schema})
|
|
||||||
}
|
}
|
||||||
return applyMetadataWindow(result, constraints.Offset, constraints.Limit), nil
|
if viewsAllowed {
|
||||||
|
for _, name := range sortedMapKeys(metadata.MaterializedViews) {
|
||||||
|
if !metadataNameMatches(name, constraints.Filter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, objectInfo{Name: name, ObjectType: "MATERIALIZED_VIEW", Schema: schema})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applyMetadataWindow(result, constraints.Offset, constraints.Limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
|
func (s *server) getColumns(schema, table string) ([]columnInfo, error) {
|
||||||
metadata, err := s.tableMetadata(schema, table)
|
keyspace, err := s.keyspaceMetadata(schema)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return columnsForSchemaObject(keyspace, schema, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnsForSchemaObject(keyspace *gocql.KeyspaceMetadata, schema, table string) ([]columnInfo, error) {
|
||||||
|
if view := keyspace.MaterializedViews[table]; view != nil {
|
||||||
|
if view.BaseTable == nil {
|
||||||
|
return nil, fmt.Errorf("Cassandra materialized view base table metadata unavailable: %s.%s", schema, table)
|
||||||
|
}
|
||||||
|
return columnsFromMetadata(view.BaseTable), nil
|
||||||
|
}
|
||||||
|
metadata := keyspace.Tables[table]
|
||||||
|
if metadata == nil {
|
||||||
|
return nil, fmt.Errorf("Cassandra table not found: %s.%s", schema, table)
|
||||||
|
}
|
||||||
return columnsFromMetadata(metadata), nil
|
return columnsFromMetadata(metadata), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,3 +159,125 @@ func TestListTriggersReturnsQueryError(t *testing.T) {
|
|||||||
t.Fatalf("triggers = %#v, err = %v", triggers, err)
|
t.Fatalf("triggers = %#v, err = %v", triggers, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyspaceWithTablesAndViews() *gocql.KeyspaceMetadata {
|
||||||
|
return &gocql.KeyspaceMetadata{
|
||||||
|
Tables: map[string]*gocql.TableMetadata{
|
||||||
|
"users": {},
|
||||||
|
"events": {},
|
||||||
|
},
|
||||||
|
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||||
|
"users_by_email": {Name: "users_by_email"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableInfosReportMaterializedViewsAsTheirOwnType(t *testing.T) {
|
||||||
|
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{})
|
||||||
|
want := []tableInfo{
|
||||||
|
{Name: "events", TableType: "TABLE"},
|
||||||
|
{Name: "users", TableType: "TABLE"},
|
||||||
|
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tables, want) {
|
||||||
|
t.Fatalf("tables = %#v, want %#v", tables, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTableInfosFilterAppliesToTablesAndViews(t *testing.T) {
|
||||||
|
tables := tableInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), metadataListConstraints{Filter: "users"})
|
||||||
|
want := []tableInfo{
|
||||||
|
{Name: "users", TableType: "TABLE"},
|
||||||
|
{Name: "users_by_email", TableType: "MATERIALIZED_VIEW"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tables, want) {
|
||||||
|
t.Fatalf("tables = %#v, want %#v", tables, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectInfosIncludeMaterializedViews(t *testing.T) {
|
||||||
|
objects := objectInfosFromKeyspaceMetadata(keyspaceWithTablesAndViews(), "dev", metadataListConstraints{})
|
||||||
|
want := []objectInfo{
|
||||||
|
{Name: "events", ObjectType: "TABLE", Schema: "dev"},
|
||||||
|
{Name: "users", ObjectType: "TABLE", Schema: "dev"},
|
||||||
|
{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(objects, want) {
|
||||||
|
t.Fatalf("objects = %#v, want %#v", objects, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObjectInfosRespectObjectTypeFilters(t *testing.T) {
|
||||||
|
keyspace := keyspaceWithTablesAndViews()
|
||||||
|
|
||||||
|
tablesOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"table"}})
|
||||||
|
if len(tablesOnly) != 2 || tablesOnly[0].Name != "events" || tablesOnly[1].Name != "users" {
|
||||||
|
t.Fatalf("tablesOnly = %#v", tablesOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewsOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"view"}})
|
||||||
|
want := []objectInfo{{Name: "users_by_email", ObjectType: "MATERIALIZED_VIEW", Schema: "dev"}}
|
||||||
|
if !reflect.DeepEqual(viewsOnly, want) {
|
||||||
|
t.Fatalf("viewsOnly = %#v, want %#v", viewsOnly, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
materializedOnly := objectInfosFromKeyspaceMetadata(keyspace, "dev", metadataListConstraints{ObjectTypes: []string{"materialized_view"}})
|
||||||
|
if !reflect.DeepEqual(materializedOnly, want) {
|
||||||
|
t.Fatalf("materializedOnly = %#v, want %#v", materializedOnly, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColumnsForSchemaObjectResolvesViewColumnsFromBaseTable(t *testing.T) {
|
||||||
|
textType := gocql.NewNativeType(4, gocql.TypeVarchar, "")
|
||||||
|
id := &gocql.ColumnMetadata{Name: "email", Kind: gocql.ColumnPartitionKey, Type: textType}
|
||||||
|
name := &gocql.ColumnMetadata{Name: "username", Kind: gocql.ColumnRegular, Type: textType}
|
||||||
|
baseTable := &gocql.TableMetadata{
|
||||||
|
OrderedColumns: []string{"email", "username"},
|
||||||
|
PartitionKey: []*gocql.ColumnMetadata{id},
|
||||||
|
Columns: map[string]*gocql.ColumnMetadata{"email": id, "username": name},
|
||||||
|
}
|
||||||
|
keyspace := &gocql.KeyspaceMetadata{
|
||||||
|
Tables: map[string]*gocql.TableMetadata{"users": baseTable},
|
||||||
|
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||||
|
"users_by_email": {Name: "users_by_email", BaseTable: baseTable},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
columns, err := columnsForSchemaObject(keyspace, "dev", "users_by_email")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"email", "username"}
|
||||||
|
if len(columns) != len(want) {
|
||||||
|
t.Fatalf("columns = %#v", columns)
|
||||||
|
}
|
||||||
|
for index, column := range columns {
|
||||||
|
if column.Name != want[index] {
|
||||||
|
t.Fatalf("columns[%d] = %s, want %s", index, column.Name, want[index])
|
||||||
|
}
|
||||||
|
if column.IsPrimaryKey != (index == 0) {
|
||||||
|
t.Fatalf("columns[%d].IsPrimaryKey = %v", index, column.IsPrimaryKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tableColumns, err := columnsForSchemaObject(keyspace, "dev", "users")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(tableColumns) != 2 || tableColumns[0].Name != "email" {
|
||||||
|
t.Fatalf("tableColumns = %#v", tableColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := columnsForSchemaObject(keyspace, "dev", "missing"); err == nil {
|
||||||
|
t.Fatal("expected an error for a missing table")
|
||||||
|
}
|
||||||
|
|
||||||
|
orphanKeyspace := &gocql.KeyspaceMetadata{
|
||||||
|
MaterializedViews: map[string]*gocql.MaterializedViewMetadata{
|
||||||
|
"users_by_email": {Name: "users_by_email"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := columnsForSchemaObject(orphanKeyspace, "dev", "users_by_email"); err == nil {
|
||||||
|
t.Fatal("expected an error when view base table metadata is unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -350,7 +350,7 @@ import { useTheme } from "@/composables/useTheme";
|
|||||||
import { useConnectionStore } from "@/stores/connectionStore";
|
import { useConnectionStore } from "@/stores/connectionStore";
|
||||||
import { useQueryStore } from "@/stores/queryStore";
|
import { useQueryStore } from "@/stores/queryStore";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
import { databaseSortSupportedForDatabase, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn, type DataGridSortDirection, type DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||||
import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore";
|
import { resolveGridFocusRestoreTarget } from "@/lib/dataGrid/dataGridFocusRestore";
|
||||||
import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
|
import { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement";
|
||||||
import {
|
import {
|
||||||
@@ -848,6 +848,7 @@ function sortMenuItems(column: string, columnIndex: number) {
|
|||||||
column,
|
column,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
state: currentColumnSortState(),
|
state: currentColumnSortState(),
|
||||||
|
databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value),
|
||||||
labels: {
|
labels: {
|
||||||
databaseAscending: t("grid.sortDatabaseAscending"),
|
databaseAscending: t("grid.sortDatabaseAscending"),
|
||||||
databaseDescending: t("grid.sortDatabaseDescending"),
|
databaseDescending: t("grid.sortDatabaseDescending"),
|
||||||
@@ -12514,6 +12515,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||||||
canFilter: canUseWhereSearch.value,
|
canFilter: canUseWhereSearch.value,
|
||||||
hasSort: !!sortCol.value,
|
hasSort: !!sortCol.value,
|
||||||
sortMode: sortMode.value,
|
sortMode: sortMode.value,
|
||||||
|
databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value),
|
||||||
frozenColumnCount: frozenColumnCount.value,
|
frozenColumnCount: frozenColumnCount.value,
|
||||||
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
|
contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined,
|
||||||
hasColumnSelection: hasColumnSelection.value,
|
hasColumnSelection: hasColumnSelection.value,
|
||||||
|
|||||||
@@ -18,6 +18,46 @@ describe("dataGridContextMenu", () => {
|
|||||||
expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc");
|
expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits database sort entries from the sort menu when database sort is unsupported", () => {
|
||||||
|
const state = { column: "id", columnIndex: 0, direction: "desc" as const, mode: "local" as const };
|
||||||
|
const items = createDataGridSortMenuItems({
|
||||||
|
column: "id",
|
||||||
|
columnIndex: 0,
|
||||||
|
state,
|
||||||
|
databaseSortEnabled: false,
|
||||||
|
labels: { databaseAscending: "db asc", databaseDescending: "db desc", currentPageAscending: "page asc", currentPageDescending: "page desc", clear: "clear" },
|
||||||
|
icons: { database: icon, ascending: icon, descending: icon, clear: icon },
|
||||||
|
});
|
||||||
|
expect(items.map((item) => item.value)).toEqual(["local-asc", "local-desc", "clear"]);
|
||||||
|
expect(items[0]?.separatorBefore).toBeFalsy();
|
||||||
|
expect(dataGridSelectedSortMenuValue(state, "id", 0)).toBe("local-desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits database sort entries from the column context menu when database sort is unsupported", () => {
|
||||||
|
const action = vi.fn();
|
||||||
|
const filter = createDataGridFilterSubmenu({
|
||||||
|
label: "filter",
|
||||||
|
icon,
|
||||||
|
labels: { equals: "equals", notEquals: "not equals", like: "like", notLike: "not like", lessThan: "less", greaterThan: "greater", isNull: "null", isNotNull: "not null", clear: "clear" },
|
||||||
|
apply: action,
|
||||||
|
clear: action,
|
||||||
|
});
|
||||||
|
const items = createDataGridColumnContextMenuItems({
|
||||||
|
headerColumn: false,
|
||||||
|
contextColumn: true,
|
||||||
|
canCopyAlterSql: false,
|
||||||
|
canFilter: true,
|
||||||
|
hasSort: true,
|
||||||
|
sortMode: "database",
|
||||||
|
databaseSortEnabled: false,
|
||||||
|
labels: { copyName: "copy name", copyNames: "copy names", details: "details", copyAlterSql: "alter", databaseAscending: "db asc", databaseDescending: "db desc", localAscending: "local asc", localDescending: "local desc", clearSort: "clear sort" },
|
||||||
|
icons: { copy: icon, columnDetails: icon, database: icon, ascending: icon, descending: icon, clearSort: icon },
|
||||||
|
actions: { copyName: action, copyNames: action, details: action, copyAlterSql: action, sort: action },
|
||||||
|
filterSubmenu: filter,
|
||||||
|
});
|
||||||
|
expect(items.map((item) => item.label)).toEqual(["local asc", "local desc", "clear sort", "", "filter"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("omits unavailable server actions and disables unavailable formatter", () => {
|
it("omits unavailable server actions and disables unavailable formatter", () => {
|
||||||
const items = createDataGridCompactColumnActionItems({
|
const items = createDataGridCompactColumnActionItems({
|
||||||
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
|
labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" },
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { compareDataGridValues, simpleDataGridOrderByColumn, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn } from "@/lib/dataGrid/dataGridSort";
|
import { compareDataGridValues, databaseSortSupportedForDatabase, simpleDataGridOrderByColumn, simpleDataGridOrderByMatchesSort, simpleDataGridOrderByReferencesMissingColumn } from "@/lib/dataGrid/dataGridSort";
|
||||||
|
|
||||||
|
describe("databaseSortSupportedForDatabase", () => {
|
||||||
|
it("keeps database-side sorting for databases that support ORDER BY", () => {
|
||||||
|
expect(databaseSortSupportedForDatabase("postgres")).toBe(true);
|
||||||
|
expect(databaseSortSupportedForDatabase("mysql")).toBe(true);
|
||||||
|
expect(databaseSortSupportedForDatabase(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables database-side sorting for Cassandra browse queries", () => {
|
||||||
|
expect(databaseSortSupportedForDatabase("cassandra")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("simpleDataGridOrderByColumn", () => {
|
describe("simpleDataGridOrderByColumn", () => {
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function createDataGridColumnContextMenuItems(options: {
|
|||||||
canFilter: boolean;
|
canFilter: boolean;
|
||||||
hasSort: boolean;
|
hasSort: boolean;
|
||||||
sortMode: "database" | "local";
|
sortMode: "database" | "local";
|
||||||
|
databaseSortEnabled?: boolean;
|
||||||
frozenColumnCount?: number;
|
frozenColumnCount?: number;
|
||||||
contextVisibleColIdx?: number;
|
contextVisibleColIdx?: number;
|
||||||
hasColumnSelection?: boolean;
|
hasColumnSelection?: boolean;
|
||||||
@@ -94,13 +95,14 @@ export function createDataGridColumnContextMenuItems(options: {
|
|||||||
}
|
}
|
||||||
if (!options.contextColumn && !options.headerColumn) return items;
|
if (!options.contextColumn && !options.headerColumn) return items;
|
||||||
if (options.contextColumn) {
|
if (options.contextColumn) {
|
||||||
items.push(
|
if (options.databaseSortEnabled !== false) {
|
||||||
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
|
items.push(
|
||||||
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
|
{ label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database },
|
||||||
{ label: "", separator: true },
|
{ label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database },
|
||||||
{ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending },
|
{ label: "", separator: true },
|
||||||
{ label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending },
|
);
|
||||||
);
|
}
|
||||||
|
items.push({ label: options.labels.localAscending, action: () => options.actions.sort("asc", "local"), icon: options.icons.ascending }, { label: options.labels.localDescending, action: () => options.actions.sort("desc", "local"), icon: options.icons.descending });
|
||||||
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
|
if (options.hasSort) items.push({ label: options.labels.clearSort, action: () => options.actions.sort(null, options.sortMode), icon: options.icons.clearSort });
|
||||||
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
|
if (options.canFilter) items.push({ label: "", separator: true }, options.filterSubmenu);
|
||||||
}
|
}
|
||||||
@@ -210,13 +212,17 @@ export function dataGridSelectedSortMenuValue(state: DataGridColumnSortState, co
|
|||||||
return dataGridColumnIsSorted(state, column, columnIndex) ? `${state.mode}-${state.direction}` : undefined;
|
return dataGridColumnIsSorted(state, column, columnIndex) ? `${state.mode}-${state.direction}` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons }): DataGridColumnMenuItem[] {
|
export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons; databaseSortEnabled?: boolean }): DataGridColumnMenuItem[] {
|
||||||
const { column, columnIndex, state, labels, icons } = options;
|
const { column, columnIndex, state, labels, icons, databaseSortEnabled = true } = options;
|
||||||
const sorted = dataGridColumnIsSorted(state, column, columnIndex);
|
const sorted = dataGridColumnIsSorted(state, column, columnIndex);
|
||||||
return [
|
return [
|
||||||
{ label: labels.databaseAscending, value: "database-asc", icon: icons.database, checked: sorted && state.direction === "asc" && state.mode === "database" },
|
...(databaseSortEnabled
|
||||||
{ label: labels.databaseDescending, value: "database-desc", icon: icons.database, checked: sorted && state.direction === "desc" && state.mode === "database" },
|
? [
|
||||||
{ label: labels.currentPageAscending, value: "local-asc", icon: icons.ascending, checked: sorted && state.direction === "asc" && state.mode === "local", separatorBefore: true },
|
{ label: labels.databaseAscending, value: "database-asc", icon: icons.database, checked: sorted && state.direction === "asc" && state.mode === "database" },
|
||||||
|
{ label: labels.databaseDescending, value: "database-desc", icon: icons.database, checked: sorted && state.direction === "desc" && state.mode === "database" },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{ label: labels.currentPageAscending, value: "local-asc", icon: icons.ascending, checked: sorted && state.direction === "asc" && state.mode === "local", separatorBefore: databaseSortEnabled },
|
||||||
{ label: labels.currentPageDescending, value: "local-desc", icon: icons.descending, checked: sorted && state.direction === "desc" && state.mode === "local" },
|
{ label: labels.currentPageDescending, value: "local-desc", icon: icons.descending, checked: sorted && state.direction === "desc" && state.mode === "local" },
|
||||||
{ label: labels.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true },
|
{ label: labels.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import type { DatabaseType } from "@/types/database";
|
||||||
import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType";
|
import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType";
|
||||||
|
|
||||||
export type DataGridSortDirection = "asc" | "desc";
|
export type DataGridSortDirection = "asc" | "desc";
|
||||||
export type DataGridSortMode = "database" | "local";
|
export type DataGridSortMode = "database" | "local";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cassandra rejects ORDER BY unless the partition key is restricted by an
|
||||||
|
* equality filter, so a browse query can never be sorted database-side there
|
||||||
|
* and the sort would keep failing on every refresh or page jump while applied.
|
||||||
|
*/
|
||||||
|
export function databaseSortSupportedForDatabase(databaseType?: DatabaseType): boolean {
|
||||||
|
return databaseType !== "cassandra";
|
||||||
|
}
|
||||||
|
|
||||||
export interface DataGridSortState {
|
export interface DataGridSortState {
|
||||||
column: string | null;
|
column: string | null;
|
||||||
columnIndex: number | null;
|
columnIndex: number | null;
|
||||||
|
|||||||
Reference in new issue
Block a user