feat(objects): show object timestamps
This commit is contained in:
parent
8b6a628b5d
commit
c1fc1c9b70
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -796,6 +796,8 @@ export default {
|
|||
function: "Function",
|
||||
name: "Name",
|
||||
type: "Type",
|
||||
createdAt: "Created",
|
||||
updatedAt: "Updated",
|
||||
source: "Source",
|
||||
saveSource: "Save",
|
||||
cancelEdit: "Cancel",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -777,6 +777,8 @@ export default {
|
|||
function: "函数",
|
||||
name: "名称",
|
||||
type: "类型",
|
||||
createdAt: "创建时间",
|
||||
updatedAt: "更新时间",
|
||||
source: "源代码",
|
||||
saveSource: "保存",
|
||||
cancelEdit: "取消",
|
||||
|
|
|
|||
|
|
@ -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() ?? "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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'"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue