fix(cassandra): report materialized views and skip db-side sort

This commit is contained in:
dienaso authored and GitHub committed 2026-09-06 10:31:47 +08:00
1 parent eea3faa224
commit 0938b6bee0
11 files changed
+296 -42

No files matched your search

+13 -3
View File
@@ -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"},
+4 -3
View File
@@ -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"},
+54 -12
View File
@@ -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;