From c1fc1c9b709af291f6c1e2d98a7ab4ef8e2cc6d3 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 22 May 2026 11:37:23 +0800 Subject: [PATCH] feat(objects): show object timestamps --- .../src/components/objects/ObjectBrowser.vue | 104 ++++++++++++++++-- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/lib/objectBrowserRows.ts | 52 +++++++++ apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/db/mysql.rs | 17 ++- crates/dbx-core/src/db/ob_oracle.rs | 2 + crates/dbx-core/src/db/postgres.rs | 86 +++++++++++---- crates/dbx-core/src/db/sqlserver.rs | 82 +++++++++++--- crates/dbx-core/src/schema.rs | 4 + crates/dbx-core/src/types.rs | 2 + packages/app-tests/objectBrowserRows.test.ts | 43 +++++++- 13 files changed, 349 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index a41e606d9..eeb8a615c 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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([]); const rootRef = ref(); const search = ref(""); const objectFilter = ref("all"); +const sortKey = ref("name"); +const sortDirection = ref("asc"); const loadingSchemas = ref(false); const loadingObjects = ref(false); const sourceLoading = ref(false); @@ -129,18 +142,25 @@ const objectFilters = computed(() => ); 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 }" > -
{{ t("objects.name") }}
-
{{ t("objects.type") }}
-
{{ t("objects.comment") }}
+ + + + + {{ item.name }}
{{ typeLabel(item.type) }}
+
+ {{ formatObjectBrowserTimestamp(item.created_at) }} +
+
+ {{ formatObjectBrowserTimestamp(item.updated_at) }} +
{{ item.comment || "" }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7a68aa094..905d04e4a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -796,6 +796,8 @@ export default { function: "Function", name: "Name", type: "Type", + createdAt: "Created", + updatedAt: "Updated", source: "Source", saveSource: "Save", cancelEdit: "Cancel", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7d9c38a19..cf3aa3ebd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8a84eca22..86fddb070 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -777,6 +777,8 @@ export default { function: "函数", name: "名称", type: "类型", + createdAt: "创建时间", + updatedAt: "更新时间", source: "源代码", saveSource: "保存", cancelEdit: "取消", diff --git a/apps/desktop/src/lib/objectBrowserRows.ts b/apps/desktop/src/lib/objectBrowserRows.ts index 96ef16a8f..63c1cc887 100644 --- a/apps/desktop/src/lib/objectBrowserRows.ts +++ b/apps/desktop/src/lib/objectBrowserRows.ts @@ -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() ?? ""; +} diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 033a58a81..506cf7dea 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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"; diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 7d7b112e2..0a42b6574 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -29,9 +29,13 @@ fn get_str_by_name(row: &MySqlRow, name: &str) -> String { } fn get_opt_str(row: &MySqlRow, name: &str) -> Option { - row.try_get::, _>(name).ok().flatten().or_else(|| { - row.try_get::>, _>(name).ok().flatten().map(|b| String::from_utf8_lossy(&b).to_string()) - }) + row.try_get::, _>(name) + .ok() + .flatten() + .or_else(|| row.try_get::, _>(name).ok().flatten().map(|d| d.to_string())) + .or_else(|| { + row.try_get::>, _>(name).ok().flatten().map(|b| String::from_utf8_lossy(&b).to_string()) + }) } fn numeric_metadata_u64_to_i32(value: Option) -> Option { @@ -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 Result Result, String> } pub async fn list_tables(pool: &PgPool, schema: &str) -> Result, String> { - let rows: Vec = 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 = + 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, .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 { + row.try_get::, _>(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, String> { - let rows: Vec = - sqlx::query(list_objects_sql()).bind(schema).fetch_all(pool).await.map_err(|e| e.to_string())?; + let rows: Vec = 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 object_type: row.get::("object_type"), schema: Some(schema.to_string()), comment: row.get::, _>("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'")); } diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index a1fe75a53..567d6d7fe 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -114,7 +114,7 @@ struct SqlServerResultSet { fn push_sqlserver_result_set(results: &mut Vec, result: Option, 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, 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::(2).map(|value| value.to_string()), + updated_at: row.get::(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, String> { @@ -686,8 +694,12 @@ fn first_sql_tokens(sql: &str, limit: usize) -> Vec { #[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()); + } } diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 998f62d70..2dcd8af4a 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -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()) } diff --git a/crates/dbx-core/src/types.rs b/crates/dbx-core/src/types.rs index 904312220..aa6daf92c 100644 --- a/crates/dbx-core/src/types.rs +++ b/crates/dbx-core/src/types.rs @@ -18,6 +18,8 @@ pub struct ObjectInfo { pub object_type: String, pub schema: Option, pub comment: Option, + pub created_at: Option, + pub updated_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/packages/app-tests/objectBrowserRows.test.ts b/packages/app-tests/objectBrowserRows.test.ts index a6440d1c9..f521fbd00 100644 --- a/packages/app-tests/objectBrowserRows.test.ts +++ b/packages/app-tests/objectBrowserRows.test.ts @@ -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"); +});