feat(objects): show object timestamps

This commit is contained in:
t8y2 2026-05-22 11:37:23 +08:00
parent 8b6a628b5d
commit c1fc1c9b70
13 changed files with 349 additions and 51 deletions

View File

@ -2,6 +2,8 @@
import { computed, ref, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import {
ArrowDown,
ArrowUp,
Braces,
Code2,
Copy,
@ -48,7 +50,16 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildObjectBrowserRows, filterObjectBrowserRows, type ObjectBrowserRow } from "@/lib/objectBrowserRows";
import {
buildObjectBrowserRows,
filterObjectBrowserRows,
formatObjectBrowserTimestamp,
initialObjectBrowserSortDirection,
sortObjectBrowserRows,
type ObjectBrowserRow,
type ObjectBrowserSortDirection,
type ObjectBrowserSortKey,
} from "@/lib/objectBrowserRows";
type ObjectFilter = "all" | "tables" | "views" | "procedures" | "functions";
@ -74,6 +85,8 @@ const rows = ref<ObjectBrowserRow[]>([]);
const rootRef = ref<HTMLElement>();
const search = ref("");
const objectFilter = ref<ObjectFilter>("all");
const sortKey = ref<ObjectBrowserSortKey>("name");
const sortDirection = ref<ObjectBrowserSortDirection>("asc");
const loadingSchemas = ref(false);
const loadingObjects = ref(false);
const sourceLoading = ref(false);
@ -129,18 +142,25 @@ const objectFilters = computed<ObjectFilter[]>(() =>
);
const showObjectFilter = computed(() => objectFilters.value.length > 2);
const hasComments = computed(() => rows.value.some((row) => row.comment?.trim()));
const gridTemplateColumns = computed(() =>
hasComments.value ? "minmax(0,1fr) 120px minmax(160px,0.7fr)" : "minmax(0,1fr) 120px",
);
const hasCreatedAt = computed(() => rows.value.some((row) => row.created_at?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => row.updated_at?.trim()));
const gridTemplateColumns = computed(() => {
const columns = ["minmax(0,1fr)", "120px"];
if (hasCreatedAt.value) columns.push("150px");
if (hasUpdatedAt.value) columns.push("150px");
if (hasComments.value) columns.push("minmax(160px,0.7fr)");
return columns.join(" ");
});
const searchedRows = computed(() => {
return filterObjectBrowserRows(rows.value, search.value);
});
const filteredRows = computed(() => {
if (objectFilter.value === "tables") return searchedRows.value.filter((row) => row.type === "TABLE");
if (objectFilter.value === "views") return searchedRows.value.filter((row) => row.type === "VIEW");
if (objectFilter.value === "procedures") return searchedRows.value.filter((row) => row.type === "PROCEDURE");
if (objectFilter.value === "functions") return searchedRows.value.filter((row) => row.type === "FUNCTION");
return searchedRows.value;
let rows = searchedRows.value;
if (objectFilter.value === "tables") rows = rows.filter((row) => row.type === "TABLE");
if (objectFilter.value === "views") rows = rows.filter((row) => row.type === "VIEW");
if (objectFilter.value === "procedures") rows = rows.filter((row) => row.type === "PROCEDURE");
if (objectFilter.value === "functions") rows = rows.filter((row) => row.type === "FUNCTION");
return sortObjectBrowserRows(rows, sortKey.value, sortDirection.value);
});
function iconFor(row: ObjectBrowserRow) {
@ -157,6 +177,20 @@ function typeLabel(type: ObjectBrowserRow["type"]) {
return t("objects.table");
}
function sortIconFor(key: ObjectBrowserSortKey) {
if (sortKey.value !== key) return null;
return sortDirection.value === "asc" ? ArrowUp : ArrowDown;
}
function toggleSort(key: ObjectBrowserSortKey) {
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
return;
}
sortKey.value = key;
sortDirection.value = initialObjectBrowserSortDirection(key);
}
function iconClass(type: ObjectBrowserRow["type"]) {
if (type === "VIEW") return "text-purple-500";
if (type === "PROCEDURE") return "text-blue-500";
@ -630,9 +664,41 @@ watch(
class="grid h-8 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground"
:style="{ gridTemplateColumns }"
>
<div class="truncate">{{ t("objects.name") }}</div>
<div class="truncate">{{ t("objects.type") }}</div>
<div v-if="hasComments" class="truncate">{{ t("objects.comment") }}</div>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('name')">
<span class="truncate">{{ t("objects.name") }}</span>
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
</button>
<button class="flex min-w-0 items-center gap-1 truncate text-left" type="button" @click="toggleSort('type')">
<span class="truncate">{{ t("objects.type") }}</span>
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
</button>
<button
v-if="hasCreatedAt"
class="flex min-w-0 items-center gap-1 truncate text-left"
type="button"
@click="toggleSort('created_at')"
>
<span class="truncate">{{ t("objects.createdAt") }}</span>
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
</button>
<button
v-if="hasUpdatedAt"
class="flex min-w-0 items-center gap-1 truncate text-left"
type="button"
@click="toggleSort('updated_at')"
>
<span class="truncate">{{ t("objects.updatedAt") }}</span>
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
</button>
<button
v-if="hasComments"
class="flex min-w-0 items-center gap-1 truncate text-left"
type="button"
@click="toggleSort('comment')"
>
<span class="truncate">{{ t("objects.comment") }}</span>
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
</button>
</div>
<RecycleScroller
class="object-browser-scroller min-h-0 flex-1"
@ -656,6 +722,20 @@ watch(
<span class="truncate text-[13px] font-medium text-foreground">{{ item.name }}</span>
</div>
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
<div
v-if="hasCreatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.created_at)"
>
{{ formatObjectBrowserTimestamp(item.created_at) }}
</div>
<div
v-if="hasUpdatedAt"
class="truncate text-xs tabular-nums text-muted-foreground"
:title="formatObjectBrowserTimestamp(item.updated_at)"
>
{{ formatObjectBrowserTimestamp(item.updated_at) }}
</div>
<div v-if="hasComments" class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
{{ item.comment || "" }}
</div>

View File

@ -796,6 +796,8 @@ export default {
function: "Function",
name: "Name",
type: "Type",
createdAt: "Created",
updatedAt: "Updated",
source: "Source",
saveSource: "Save",
cancelEdit: "Cancel",

View File

@ -693,6 +693,8 @@ export default {
function: "Función",
name: "Nombre",
type: "Tipo",
createdAt: "Creado",
updatedAt: "Actualizado",
source: "Código fuente",
saveSource: "Guardar",
cancelEdit: "Cancelar",

View File

@ -777,6 +777,8 @@ export default {
function: "函数",
name: "名称",
type: "类型",
createdAt: "创建时间",
updatedAt: "更新时间",
source: "源代码",
saveSource: "保存",
cancelEdit: "取消",

View File

@ -7,8 +7,13 @@ export type ObjectBrowserRow = {
schema?: string;
type: "TABLE" | "VIEW" | "PROCEDURE" | "FUNCTION";
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
};
export type ObjectBrowserSortKey = "name" | "type" | "created_at" | "updated_at" | "comment";
export type ObjectBrowserSortDirection = "asc" | "desc";
export function normalizeObjectBrowserType(type: string): ObjectBrowserRow["type"] {
const value = type.toUpperCase();
if (value.includes("VIEW")) return "VIEW";
@ -40,6 +45,8 @@ export function buildObjectBrowserRows(options: {
schema,
type,
comment: object.comment,
created_at: object.created_at,
updated_at: object.updated_at,
},
];
});
@ -52,3 +59,48 @@ export function filterObjectBrowserRows(rows: ObjectBrowserRow[], query: string)
[row.name, row.type, row.comment].filter(Boolean).some((value) => String(value).toLowerCase().includes(q)),
);
}
export function sortObjectBrowserRows(
rows: ObjectBrowserRow[],
key: ObjectBrowserSortKey,
direction: ObjectBrowserSortDirection,
): ObjectBrowserRow[] {
const multiplier = direction === "asc" ? 1 : -1;
return [...rows].sort((left, right) => {
const compared = compareObjectBrowserValue(left[key], right[key], key, direction);
if (compared !== 0) return compared * multiplier;
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
});
}
export function initialObjectBrowserSortDirection(key: ObjectBrowserSortKey): ObjectBrowserSortDirection {
return key === "created_at" || key === "updated_at" ? "desc" : "asc";
}
export function formatObjectBrowserTimestamp(value: string | null | undefined): string {
const text = value?.trim();
if (!text) return "";
return text
.replace("T", " ")
.replace(/\.\d+(?=$|[+-]\d{2}(?::?\d{2})?$)/, "")
.replace(/(?:Z|[+-]\d{2}(?::?\d{2})?)$/, "");
}
function compareObjectBrowserValue(
left: string | null | undefined,
right: string | null | undefined,
key: ObjectBrowserSortKey,
direction: ObjectBrowserSortDirection,
): number {
const leftText = normalizeSortValue(left);
const rightText = normalizeSortValue(right);
if (!leftText && !rightText) return 0;
if (!leftText) return direction === "asc" ? 1 : -1;
if (!rightText) return direction === "asc" ? -1 : 1;
if (key === "created_at" || key === "updated_at") return leftText.localeCompare(rightText);
return leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: "base" });
}
function normalizeSortValue(value: string | null | undefined): string {
return value?.trim() ?? "";
}

View File

@ -143,6 +143,8 @@ export interface ObjectInfo {
object_type: DatabaseObjectType | string;
schema?: string | null;
comment?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export type ObjectSourceKind = "VIEW" | "PROCEDURE" | "FUNCTION";

View File

@ -29,9 +29,13 @@ fn get_str_by_name(row: &MySqlRow, name: &str) -> String {
}
fn get_opt_str(row: &MySqlRow, name: &str) -> Option<String> {
row.try_get::<Option<String>, _>(name).ok().flatten().or_else(|| {
row.try_get::<Option<Vec<u8>>, _>(name).ok().flatten().map(|b| String::from_utf8_lossy(&b).to_string())
})
row.try_get::<Option<String>, _>(name)
.ok()
.flatten()
.or_else(|| row.try_get::<Option<NaiveDateTime>, _>(name).ok().flatten().map(|d| d.to_string()))
.or_else(|| {
row.try_get::<Option<Vec<u8>>, _>(name).ok().flatten().map(|b| String::from_utf8_lossy(&b).to_string())
})
}
fn numeric_metadata_u64_to_i32(value: Option<u64>) -> Option<i32> {
@ -288,11 +292,14 @@ fn list_objects_sql(database: &str) -> String {
"SELECT TABLE_NAME AS object_name, \
CASE WHEN TABLE_TYPE = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS object_type, \
TABLE_COMMENT AS object_comment, \
CREATE_TIME AS created_at, \
UPDATE_TIME AS updated_at, \
CASE WHEN TABLE_TYPE = 'VIEW' THEN 1 ELSE 0 END AS sort_order \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {db} \
UNION ALL \
SELECT ROUTINE_NAME AS object_name, ROUTINE_TYPE AS object_type, NULL AS object_comment, \
CREATED AS created_at, LAST_ALTERED AS updated_at, \
CASE WHEN ROUTINE_TYPE = 'PROCEDURE' THEN 2 ELSE 3 END AS sort_order \
FROM information_schema.ROUTINES \
WHERE ROUTINE_SCHEMA = {db} AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') \
@ -312,6 +319,8 @@ pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result<Vec<Object
object_type: get_str_by_name(row, "object_type"),
schema: Some(database.to_string()),
comment: get_opt_str(row, "object_comment").filter(|s| !s.is_empty()),
created_at: get_opt_str(row, "created_at"),
updated_at: get_opt_str(row, "updated_at"),
})
.collect())
}
@ -600,6 +609,8 @@ mod tests {
assert!(sql.contains("information_schema.TABLES"));
assert!(sql.contains("information_schema.ROUTINES"));
assert!(sql.contains("CREATE_TIME"));
assert!(sql.contains("UPDATE_TIME"));
assert!(sql.contains("'PROCEDURE'"));
assert!(sql.contains("'FUNCTION'"));
}

View File

@ -79,6 +79,8 @@ pub async fn list_objects(pool: &MySqlPool, schema: &str) -> Result<Vec<ObjectIn
object_type: get_str(row, 1),
schema: Some(schema.to_string()),
comment: None,
created_at: None,
updated_at: None,
})
.collect())
}

View File

@ -202,21 +202,8 @@ pub async fn list_databases(pool: &PgPool) -> Result<Vec<DatabaseInfo>, String>
}
pub async fn list_tables(pool: &PgPool, schema: &str) -> Result<Vec<TableInfo>, String> {
let rows: Vec<PgRow> = sqlx::query(
"SELECT c.relname AS table_name, \
CASE c.relkind WHEN 'r' THEN 'BASE TABLE' WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' \
WHEN 'p' THEN 'BASE TABLE' END AS table_type, \
obj_description(c.oid) AS table_comment \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
ORDER BY c.relname",
)
.bind(schema)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let rows: Vec<PgRow> =
sqlx::query(postgres_tables_sql()).bind(schema).fetch_all(pool).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
@ -228,7 +215,58 @@ pub async fn list_tables(pool: &PgPool, schema: &str) -> Result<Vec<TableInfo>,
.collect())
}
fn list_objects_sql() -> &'static str {
fn postgres_tables_sql() -> &'static str {
"SELECT c.relname AS table_name, \
CASE c.relkind WHEN 'r' THEN 'BASE TABLE' WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' \
WHEN 'p' THEN 'BASE TABLE' END AS table_type, \
obj_description(c.oid) AS table_comment, \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
ORDER BY c.relname"
}
fn get_opt_text(row: &PgRow, name: &str) -> Option<String> {
row.try_get::<Option<String>, _>(name).ok().flatten().filter(|s| !s.is_empty())
}
fn list_objects_sql(include_timestamps: bool) -> &'static str {
if include_timestamps {
return "SELECT c.relname AS object_name, \
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
WHEN 'm' THEN 'VIEW' \
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
stat.creation::text AS created_at, \
COALESCE( \
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
THEN pg_xact_commit_timestamp(c.xmin)::text END, \
stat.modification::text \
) AS updated_at, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN LATERAL pg_stat_file( \
CASE WHEN c.relkind IN ('r','m','f','p') THEN pg_relation_filepath(c.oid) END, true \
) stat ON true \
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
UNION ALL \
SELECT p.proname AS object_name, \
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
obj_description(p.oid) AS object_comment, \
NULL::text AS created_at, \
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
THEN pg_xact_commit_timestamp(p.xmin)::text END AS updated_at, \
CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \
FROM pg_catalog.pg_proc p \
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
WHERE n.nspname = $1 AND p.prokind IN ('p','f') \
ORDER BY sort_order, object_name";
}
"SELECT c.relname AS object_name, \
CASE c.relkind \
WHEN 'v' THEN 'VIEW' \
@ -236,6 +274,8 @@ fn list_objects_sql() -> &'static str {
ELSE 'TABLE' \
END AS object_type, \
obj_description(c.oid) AS object_comment, \
NULL::text AS created_at, \
NULL::text AS updated_at, \
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
@ -244,6 +284,8 @@ fn list_objects_sql() -> &'static str {
SELECT p.proname AS object_name, \
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
obj_description(p.oid) AS object_comment, \
NULL::text AS created_at, \
NULL::text AS updated_at, \
CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \
FROM pg_catalog.pg_proc p \
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
@ -252,8 +294,10 @@ fn list_objects_sql() -> &'static str {
}
pub async fn list_objects(pool: &PgPool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
let rows: Vec<PgRow> =
sqlx::query(list_objects_sql()).bind(schema).fetch_all(pool).await.map_err(|e| e.to_string())?;
let rows: Vec<PgRow> = match sqlx::query(list_objects_sql(true)).bind(schema).fetch_all(pool).await {
Ok(rows) => rows,
Err(_) => sqlx::query(list_objects_sql(false)).bind(schema).fetch_all(pool).await.map_err(|e| e.to_string())?,
};
Ok(rows
.iter()
@ -262,6 +306,8 @@ pub async fn list_objects(pool: &PgPool, schema: &str) -> Result<Vec<ObjectInfo>
object_type: row.get::<String, _>("object_type"),
schema: Some(schema.to_string()),
comment: row.get::<Option<String>, _>("object_comment").filter(|s| !s.is_empty()),
created_at: get_opt_text(row, "created_at"),
updated_at: get_opt_text(row, "updated_at"),
})
.collect())
}
@ -590,10 +636,12 @@ mod tests {
#[test]
fn postgres_list_objects_sql_includes_routines() {
let sql = list_objects_sql();
let sql = list_objects_sql(true);
assert!(sql.contains("pg_catalog.pg_class"));
assert!(sql.contains("pg_catalog.pg_proc"));
assert!(sql.contains("pg_stat_file"));
assert!(sql.contains("pg_xact_commit_timestamp"));
assert!(sql.contains("'PROCEDURE'"));
assert!(sql.contains("'FUNCTION'"));
}

View File

@ -114,7 +114,7 @@ struct SqlServerResultSet {
fn push_sqlserver_result_set(results: &mut Vec<QueryResult>, result: Option<SqlServerResultSet>, start: Instant) {
if let Some(result) = result {
if result.rows.is_empty() {
if result.rows.is_empty() && result.columns.is_empty() {
return;
}
results.push(QueryResult {
@ -304,7 +304,24 @@ fn escape_like_literal(value: &str) -> String {
}
pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<Vec<crate::types::ObjectInfo>, String> {
let sql = format!(
let sql = sqlserver_list_objects_sql(schema);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| crate::types::ObjectInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
object_type: row.get::<&str, _>(1).unwrap_or("TABLE").to_string(),
schema: Some(schema.to_string()),
comment: None,
created_at: row.get::<chrono::NaiveDateTime, _>(2).map(|value| value.to_string()),
updated_at: row.get::<chrono::NaiveDateTime, _>(3).map(|value| value.to_string()),
})
.collect())
}
fn sqlserver_list_objects_sql(schema: &str) -> String {
format!(
"SELECT o.name, \
CASE o.type \
WHEN 'U' THEN 'TABLE' \
@ -316,7 +333,9 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
WHEN 'FS' THEN 'FUNCTION' \
WHEN 'FT' THEN 'FUNCTION' \
ELSE o.type_desc \
END AS object_type \
END AS object_type, \
o.create_date, \
o.modify_date \
FROM sys.objects o \
JOIN sys.schemas s ON s.schema_id = o.schema_id \
WHERE s.name = '{}' \
@ -329,18 +348,7 @@ pub async fn list_objects(client: &mut SqlServerClient, schema: &str) -> Result<
ELSE 3 \
END, o.name",
schema.replace('\'', "''")
);
let stream = client.query(&*sql, &[]).await.map_err(|e| e.to_string())?;
let rows = stream.into_first_result().await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| crate::types::ObjectInfo {
name: row.get::<&str, _>(0).unwrap_or("").to_string(),
object_type: row.get::<&str, _>(1).unwrap_or("TABLE").to_string(),
schema: Some(schema.to_string()),
comment: None,
})
.collect())
)
}
pub async fn get_columns(client: &mut SqlServerClient, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
@ -686,8 +694,12 @@ fn first_sql_tokens(sql: &str, limit: usize) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{requires_simple_query_batch, sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_indexes_sql};
use super::{
requires_simple_query_batch, sqlserver_cell_to_json, sqlserver_columns_sql, sqlserver_indexes_sql,
sqlserver_list_objects_sql, SqlServerResultSet,
};
use chrono::NaiveDate;
use std::time::Instant;
use tiberius::{ColumnData, IntoSql};
#[test]
@ -738,6 +750,14 @@ mod tests {
assert!(indexes_sql.contains("OBJECT_ID('d''bo.t''able')"));
}
#[test]
fn sqlserver_list_objects_sql_includes_timestamps() {
let sql = sqlserver_list_objects_sql("dbo");
assert!(sql.contains("create_date"));
assert!(sql.contains("modify_date"));
}
#[test]
fn sqlserver_tinyint_cells_are_json_numbers() {
assert_eq!(sqlserver_cell_to_json(&ColumnData::U8(Some(7))), serde_json::json!(7));
@ -750,4 +770,34 @@ mod tests {
assert_eq!(sqlserver_cell_to_json(&cell), serde_json::json!("2026-05-13 09:08:07.123"));
}
#[test]
fn sqlserver_keeps_empty_result_sets_when_metadata_exists() {
let mut results = Vec::new();
super::push_sqlserver_result_set(
&mut results,
Some(SqlServerResultSet {
columns: vec!["id".to_string(), "name".to_string()],
rows: vec![],
truncated: false,
}),
Instant::now(),
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].columns, vec!["id".to_string(), "name".to_string()]);
assert!(results[0].rows.is_empty());
}
#[test]
fn sqlserver_drops_truly_empty_result_sets_without_metadata() {
let mut results = Vec::new();
super::push_sqlserver_result_set(
&mut results,
Some(SqlServerResultSet { columns: vec![], rows: vec![], truncated: false }),
Instant::now(),
);
assert!(results.is_empty());
}
}

View File

@ -482,6 +482,8 @@ pub async fn list_objects_core(
object_type: table.table_type,
schema: None,
comment: table.comment,
created_at: None,
updated_at: None,
})
.collect())
})
@ -533,6 +535,8 @@ pub async fn list_objects_core(
object_type: table.table_type,
schema: if schema.is_empty() { None } else { Some(schema.to_string()) },
comment: table.comment,
created_at: None,
updated_at: None,
})
.collect())
}

View File

@ -18,6 +18,8 @@ pub struct ObjectInfo {
pub object_type: String,
pub schema: Option<String>,
pub comment: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]

View File

@ -1,6 +1,11 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildObjectBrowserRows, filterObjectBrowserRows } from "../../apps/desktop/src/lib/objectBrowserRows.ts";
import {
buildObjectBrowserRows,
filterObjectBrowserRows,
formatObjectBrowserTimestamp,
sortObjectBrowserRows,
} from "../../apps/desktop/src/lib/objectBrowserRows.ts";
test("builds unique row ids for overloaded routines with the same visible name", () => {
const rows = buildObjectBrowserRows({
@ -37,3 +42,39 @@ test("object browser search matches names, types, and comments but not schema na
["orders", "refresh_exam_stats"],
);
});
test("object browser rows preserve table timestamps and sort recent updates first", () => {
const rows = buildObjectBrowserRows({
objects: [
{
name: "users",
object_type: "TABLE",
schema: "public",
created_at: "2026-05-20 09:30:00",
updated_at: "2026-05-21 10:15:00",
},
{
name: "orders",
object_type: "TABLE",
schema: "public",
created_at: "2026-05-22 08:00:00",
updated_at: "2026-05-22 08:20:00",
},
{ name: "active_users", object_type: "VIEW", schema: "public" },
],
database: "app",
fallbackSchema: "public",
needsSchema: true,
});
assert.deepEqual(
sortObjectBrowserRows(rows, "updated_at", "desc").map((row) => row.name),
["orders", "users", "active_users"],
);
assert.equal(formatObjectBrowserTimestamp(rows[0].created_at), "2026-05-20 09:30:00");
});
test("object browser timestamp display strips timezone suffixes", () => {
assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24+08"), "2026-05-22 10:18:24");
assert.equal(formatObjectBrowserTimestamp("2026-05-22 10:18:24.123456+08:00"), "2026-05-22 10:18:24");
});