diff --git a/agents/drivers/cassandra-go/astra_test.go b/agents/drivers/cassandra-go/astra_test.go index 6c8020c9f7..8ee81c5c41 100644 --- a/agents/drivers/cassandra-go/astra_test.go +++ b/agents/drivers/cassandra-go/astra_test.go @@ -20,11 +20,21 @@ import ( 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) { bundlePath := writeTestSecureConnectBundle(t) config, err := parseCassandraConfig(connectParams{ Username: "token", - Password: "astra-token", + Password: testPlaceholderPassword(), URLParams: url.Values{ "secureconnectbundle": []string{bundlePath}, "requesttimeout": []string{"9s"}, @@ -49,7 +59,7 @@ func TestSecureConnectBundleBuildsAstraClusterWithoutHost(t *testing.T) { t.Fatalf("Astra cluster must use dialer placeholder hosts: %#v", cluster.Hosts) } 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) } 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{ Username: "token", - Password: "astra-token", + Password: testPlaceholderPassword(), URLParams: url.Values{ "secureconnectbundle": []string{bundlePath}, "usekrb5": []string{"true"}, diff --git a/agents/drivers/cassandra-go/bench/README.md b/agents/drivers/cassandra-go/bench/README.md index 5d2a750435..6a9581107e 100644 --- a/agents/drivers/cassandra-go/bench/README.md +++ b/agents/drivers/cassandra-go/bench/README.md @@ -38,11 +38,12 @@ python3 drivers/cassandra-go/bench/agent_compare.py \ > /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 -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" +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"]' ``` ## Configuration diff --git a/agents/drivers/cassandra-go/bench/agent_compare.py b/agents/drivers/cassandra-go/bench/agent_compare.py index 42fa909da2..33f2e87d42 100755 --- a/agents/drivers/cassandra-go/bench/agent_compare.py +++ b/agents/drivers/cassandra-go/bench/agent_compare.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import json import os -import shlex import statistics import subprocess import sys @@ -16,7 +15,21 @@ class Candidate: name: str command: list[str] 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: @@ -81,9 +94,6 @@ class AgentProcess: return response.get("result") 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( ["ps", "-o", "rss=", "-p", str(self.process.pid)], text=True, @@ -168,12 +178,11 @@ def configured_candidates() -> list[Candidate]: candidates = [] if "go" in selected: 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: artifact = required_path("JDBC_AGENT_JAR") - raw_command = os.getenv("JDBC_AGENT_COMMAND", "") - command = shlex.split(raw_command) if raw_command else [env_default("JAVA_BIN", "java"), "-jar", str(artifact)] - candidates.append(Candidate("jdbc-java", command, artifact, os.getenv("JDBC_RSS_COMMAND", ""))) + command = command_env_list("JDBC_AGENT_COMMAND") or [env_default("JAVA_BIN", "java"), "-jar", str(artifact)] + candidates.append(Candidate("jdbc-java", command, artifact)) if not candidates: raise ValueError("BENCH_CANDIDATES selected no candidates") return candidates diff --git a/agents/drivers/cassandra-go/config_file_test.go b/agents/drivers/cassandra-go/config_file_test.go index ae81f80cbe..ee9aec2351 100644 --- a/agents/drivers/cassandra-go/config_file_test.go +++ b/agents/drivers/cassandra-go/config_file_test.go @@ -61,7 +61,7 @@ datastax-java-driver { Host: "url.example.com", Database: "url_keyspace", Username: "url-user", - Password: "url-password", + Password: testPlaceholderPassword(), URLParams: url.Values{ "configfile": []string{configPath}, "requesttimeout": []string{"30s"}, diff --git a/agents/drivers/cassandra-go/metadata.go b/agents/drivers/cassandra-go/metadata.go index 0119eeac26..d8ec9eb82b 100644 --- a/agents/drivers/cassandra-go/metadata.go +++ b/agents/drivers/cassandra-go/metadata.go @@ -231,38 +231,80 @@ func (s *server) listTables(schema string, constraints metadataListConstraints) if err != nil { return nil, err } - names := sortedMapKeys(metadata.Tables) - result := make([]tableInfo, 0, len(names)) - for _, name := range names { + return tableInfosFromKeyspaceMetadata(metadata, constraints), nil +} + +// 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) { continue } 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) { - tables, err := s.listTables(schema, metadataListConstraints{Filter: constraints.Filter}) + metadata, err := s.keyspaceMetadata(schema) if err != nil { return nil, err } + return objectInfosFromKeyspaceMetadata(metadata, schema, constraints), nil +} + +func objectInfosFromKeyspaceMetadata(metadata *gocql.KeyspaceMetadata, schema string, constraints metadataListConstraints) []objectInfo { allowed := stringSet(constraints.ObjectTypes) - result := make([]objectInfo, 0, len(tables)) - for _, table := range tables { - if len(allowed) > 0 && !allowed["table"] && !allowed["base_table"] { - continue + tablesAllowed := len(allowed) == 0 || allowed["table"] || allowed["base_table"] + viewsAllowed := len(allowed) == 0 || allowed["view"] || allowed["materialized_view"] + result := make([]objectInfo, 0, len(metadata.Tables)+len(metadata.MaterializedViews)) + 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) { - metadata, err := s.tableMetadata(schema, table) + keyspace, err := s.keyspaceMetadata(schema) if err != nil { 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 } diff --git a/agents/drivers/cassandra-go/metadata_test.go b/agents/drivers/cassandra-go/metadata_test.go index fe43e15c83..a699d3f081 100644 --- a/agents/drivers/cassandra-go/metadata_test.go +++ b/agents/drivers/cassandra-go/metadata_test.go @@ -159,3 +159,125 @@ func TestListTriggersReturnsQueryError(t *testing.T) { 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") + } +} diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index ac69221801..be8c8b6bef 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -350,7 +350,7 @@ import { useTheme } from "@/composables/useTheme"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; 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 { buildOrderedGridRows, type GridInsertRowPosition, type GridNewRowPlacement } from "@/lib/dataGrid/gridNewRowPlacement"; import { @@ -848,6 +848,7 @@ function sortMenuItems(column: string, columnIndex: number) { column, columnIndex, state: currentColumnSortState(), + databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value), labels: { databaseAscending: t("grid.sortDatabaseAscending"), databaseDescending: t("grid.sortDatabaseDescending"), @@ -12514,6 +12515,7 @@ const gridContextMenuItems = computed(() => { canFilter: canUseWhereSearch.value, hasSort: !!sortCol.value, sortMode: sortMode.value, + databaseSortEnabled: databaseSortSupportedForDatabase(resolvedDatabaseType.value), frozenColumnCount: frozenColumnCount.value, contextVisibleColIdx: contextHeaderVisibleColIdx.value ?? undefined, hasColumnSelection: hasColumnSelection.value, diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts index 6c1420c049..84c257e36d 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridContextMenu.spec.ts @@ -18,6 +18,46 @@ describe("dataGridContextMenu", () => { 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", () => { const items = createDataGridCompactColumnActionItems({ labels: { formatter: "formatter", clearFormatter: "clear formatter", localFilter: "local", serverFilter: "server" }, diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts index 471ce67cf3..fc45b667df 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSort.spec.ts @@ -1,5 +1,17 @@ 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", () => { it.each([ diff --git a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts index c0d5a9e896..18ff420c7c 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts @@ -77,6 +77,7 @@ export function createDataGridColumnContextMenuItems(options: { canFilter: boolean; hasSort: boolean; sortMode: "database" | "local"; + databaseSortEnabled?: boolean; frozenColumnCount?: number; contextVisibleColIdx?: number; hasColumnSelection?: boolean; @@ -94,13 +95,14 @@ export function createDataGridColumnContextMenuItems(options: { } if (!options.contextColumn && !options.headerColumn) return items; if (options.contextColumn) { - items.push( - { label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database }, - { label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database }, - { label: "", separator: true }, - { 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.databaseSortEnabled !== false) { + items.push( + { label: options.labels.databaseAscending, action: () => options.actions.sort("asc", "database"), icon: options.icons.database }, + { label: options.labels.databaseDescending, action: () => options.actions.sort("desc", "database"), icon: options.icons.database }, + { label: "", separator: true }, + ); + } + 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.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; } -export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons }): DataGridColumnMenuItem[] { - const { column, columnIndex, state, labels, icons } = options; +export function createDataGridSortMenuItems(options: { column: string; columnIndex: number; state: DataGridColumnSortState; labels: SortMenuLabels; icons: SortMenuIcons; databaseSortEnabled?: boolean }): DataGridColumnMenuItem[] { + const { column, columnIndex, state, labels, icons, databaseSortEnabled = true } = options; const sorted = dataGridColumnIsSorted(state, column, columnIndex); return [ - { 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: true }, + ...(databaseSortEnabled + ? [ + { 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.clear, value: "clear", icon: icons.clear, disabled: !sorted, separatorBefore: true }, ]; diff --git a/apps/desktop/src/lib/dataGrid/dataGridSort.ts b/apps/desktop/src/lib/dataGrid/dataGridSort.ts index 7faad6daae..6df5aff24b 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSort.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSort.ts @@ -1,8 +1,18 @@ +import type { DatabaseType } from "@/types/database"; import { isNumericColumnType } from "@/lib/dataGrid/dataGridColumnType"; export type DataGridSortDirection = "asc" | "desc"; 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 { column: string | null; columnIndex: number | null;