use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::pagination::Cursor;
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum ListEntries {
Brief(Vec<String>),
Detailed(IndexMap<String, Value>),
}
impl ListEntries {
#[must_use]
pub fn len(&self) -> usize {
match self {
Self::Brief(v) => v.len(),
Self::Detailed(m) => m.len(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn as_brief(&self) -> Option<&[String]> {
if let Self::Brief(v) = self { Some(v) } else { None }
}
#[must_use]
pub fn as_detailed(&self) -> Option<&IndexMap<String, Value>> {
if let Self::Detailed(m) = self { Some(m) } else { None }
}
#[must_use]
pub fn into_brief(self) -> Option<Vec<String>> {
if let Self::Brief(v) = self { Some(v) } else { None }
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListTablesResponse {
pub tables: ListEntries,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl ListTablesResponse {
#[must_use]
pub fn brief(tables: Vec<String>, next_cursor: Option<Cursor>) -> Self {
Self {
tables: ListEntries::Brief(tables),
next_cursor,
}
}
#[must_use]
pub fn detailed(tables: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
Self {
tables: ListEntries::Detailed(tables),
next_cursor,
}
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct MessageResponse {
pub message: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct ListDatabasesRequest {
#[serde(default)]
pub cursor: Option<Cursor>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListDatabasesResponse {
pub databases: Vec<String>,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct CreateDatabaseRequest {
pub database: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct DropDatabaseRequest {
pub database: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListViewsRequest")]
pub struct PinnedListViewsRequest {
#[serde(default)]
pub cursor: Option<Cursor>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListViewsRequest")]
pub struct UnpinnedListViewsRequest {
#[serde(flatten)]
pub inner: PinnedListViewsRequest,
#[serde(default)]
pub database: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListViewsResponse {
pub views: ListEntries,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl ListViewsResponse {
#[must_use]
pub fn brief(views: Vec<String>, next_cursor: Option<Cursor>) -> Self {
Self {
views: ListEntries::Brief(views),
next_cursor,
}
}
#[must_use]
pub fn detailed(views: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
Self {
views: ListEntries::Detailed(views),
next_cursor,
}
}
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListTriggersRequest")]
pub struct PinnedListTriggersRequest {
#[serde(default)]
pub cursor: Option<Cursor>,
#[serde(default)]
pub search: Option<String>,
#[serde(default)]
pub detailed: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListTriggersRequest")]
pub struct UnpinnedListTriggersRequest {
#[serde(flatten)]
pub inner: PinnedListTriggersRequest,
#[serde(default)]
pub database: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListTriggersResponse {
pub triggers: ListEntries,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl ListTriggersResponse {
#[must_use]
pub fn brief(triggers: Vec<String>, next_cursor: Option<Cursor>) -> Self {
Self {
triggers: ListEntries::Brief(triggers),
next_cursor,
}
}
#[must_use]
pub fn detailed(triggers: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
Self {
triggers: ListEntries::Detailed(triggers),
next_cursor,
}
}
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListFunctionsRequest")]
pub struct PinnedListFunctionsRequest {
#[serde(default)]
pub cursor: Option<Cursor>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ListFunctionsRequest")]
pub struct UnpinnedListFunctionsRequest {
#[serde(flatten)]
pub inner: PinnedListFunctionsRequest,
#[serde(default)]
pub database: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListFunctionsResponse {
pub functions: ListEntries,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl ListFunctionsResponse {
#[must_use]
pub fn brief(functions: Vec<String>, next_cursor: Option<Cursor>) -> Self {
Self {
functions: ListEntries::Brief(functions),
next_cursor,
}
}
#[must_use]
pub fn detailed(functions: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
Self {
functions: ListEntries::Detailed(functions),
next_cursor,
}
}
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ListProceduresResponse {
pub procedures: ListEntries,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl ListProceduresResponse {
#[must_use]
pub fn brief(procedures: Vec<String>, next_cursor: Option<Cursor>) -> Self {
Self {
procedures: ListEntries::Brief(procedures),
next_cursor,
}
}
#[must_use]
pub fn detailed(procedures: IndexMap<String, Value>, next_cursor: Option<Cursor>) -> Self {
Self {
procedures: ListEntries::Detailed(procedures),
next_cursor,
}
}
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "QueryRequest")]
pub struct PinnedQueryRequest {
pub query: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "QueryRequest")]
pub struct UnpinnedQueryRequest {
#[serde(flatten)]
pub inner: PinnedQueryRequest,
#[serde(default)]
pub database: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ReadQueryRequest")]
pub struct PinnedReadQueryRequest {
pub query: String,
#[serde(default)]
pub cursor: Option<Cursor>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ReadQueryRequest")]
pub struct UnpinnedReadQueryRequest {
#[serde(flatten)]
pub inner: PinnedReadQueryRequest,
#[serde(default)]
pub database: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct QueryResponse {
pub rows: Vec<Value>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReadQueryResponse {
pub rows: Vec<Value>,
#[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ExplainQueryRequest")]
pub struct PinnedExplainQueryRequest {
pub query: String,
#[serde(default)]
pub analyze: bool,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[schemars(rename = "ExplainQueryRequest")]
pub struct UnpinnedExplainQueryRequest {
#[serde(flatten)]
pub inner: PinnedExplainQueryRequest,
#[serde(default)]
pub database: Option<String>,
}
#[cfg(test)]
mod tests {
use super::{
IndexMap, ListEntries, ListFunctionsResponse, ListTablesResponse, ListTriggersResponse,
PinnedListTriggersRequest, UnpinnedListTriggersRequest,
};
use serde_json::{Value, json};
#[test]
fn unpinned_list_triggers_request_defaults_to_brief_mode_without_search() {
let req: PinnedListTriggersRequest = serde_json::from_str("{}").expect("empty object should parse");
assert!(req.search.is_none());
assert!(!req.detailed, "detailed must default to false");
}
#[test]
fn unpinned_list_triggers_request_accepts_search_and_detailed() {
let req: PinnedListTriggersRequest =
serde_json::from_str(r#"{"search": "audit", "detailed": true}"#).expect("parse");
assert_eq!(req.search.as_deref(), Some("audit"));
assert!(req.detailed);
}
#[test]
fn pinned_list_triggers_request_accepts_database_and_inner_fields() {
let req: UnpinnedListTriggersRequest =
serde_json::from_str(r#"{"database": "mydb", "search": "audit", "detailed": true}"#).expect("parse");
assert_eq!(req.database.as_deref(), Some("mydb"));
assert_eq!(req.inner.search.as_deref(), Some("audit"));
assert!(req.inner.detailed);
}
#[test]
fn brief_serializes_as_bare_string_array() {
let entries = ListEntries::Brief(vec!["customers".into(), "orders".into()]);
assert_eq!(serde_json::to_value(&entries).unwrap(), json!(["customers", "orders"]));
}
#[test]
fn detailed_serializes_as_keyed_object() {
let entries = ListEntries::Detailed(IndexMap::from([("orders".into(), json!({"kind": "TABLE"}))]));
assert_eq!(
serde_json::to_value(&entries).unwrap(),
json!({"orders": {"kind": "TABLE"}})
);
}
#[test]
fn brief_empty_serializes_as_empty_array() {
assert_eq!(serde_json::to_value(ListEntries::Brief(Vec::new())).unwrap(), json!([]));
}
#[test]
fn detailed_empty_serializes_as_empty_object() {
assert_eq!(
serde_json::to_value(ListEntries::Detailed(IndexMap::new())).unwrap(),
json!({})
);
}
#[test]
fn detailed_preserves_insertion_order() {
let map = IndexMap::from([
("c".into(), json!({})),
("a".into(), json!({})),
("b".into(), json!({})),
]);
let s = serde_json::to_string(&ListEntries::Detailed(map)).unwrap();
let positions = ["\"c\"", "\"a\"", "\"b\""].map(|k| s.find(k).expect(k));
assert!(positions.is_sorted(), "insertion order not preserved: {s}");
}
#[test]
fn list_tables_response_brief_matches_legacy_wire_shape() {
let response = ListTablesResponse {
tables: ListEntries::Brief(vec!["a".into()]),
next_cursor: None,
};
assert_eq!(serde_json::to_value(&response).unwrap(), json!({"tables": ["a"]}));
}
#[test]
fn list_triggers_response_brief_matches_legacy_wire_shape() {
let response = ListTriggersResponse {
triggers: ListEntries::Brief(vec!["t1".into()]),
next_cursor: None,
};
assert_eq!(serde_json::to_value(&response).unwrap(), json!({"triggers": ["t1"]}));
}
#[test]
fn as_brief_and_as_detailed_unwrap_correct_variant() {
let brief = ListEntries::Brief(vec!["a".into()]);
assert_eq!(brief.as_brief(), Some(&["a".into()][..]));
assert!(brief.as_detailed().is_none());
let det = ListEntries::Detailed(IndexMap::from([("x".into(), json!(1))]));
assert!(det.as_brief().is_none());
assert_eq!(det.as_detailed().map(IndexMap::len), Some(1));
}
#[test]
fn detailed_payload_strictly_smaller_than_array_form() {
let metadata = json!({
"schema": "public", "kind": "TABLE", "owner": "app", "comment": null,
"columns": [
{"name": "id", "dataType": "bigint", "ordinalPosition": 1, "nullable": false, "default": null, "comment": null},
{"name": "created_at", "dataType": "timestamptz", "ordinalPosition": 2, "nullable": false, "default": "now()", "comment": null},
],
"constraints": [{"name": "pk", "type": "PRIMARY KEY", "columns": ["id"], "definition": "PRIMARY KEY (id)"}],
"indexes": [], "triggers": [],
});
let tables = [
"customers",
"orders",
"items",
"products",
"inventory",
"suppliers",
"shipments",
"invoices",
"payments",
"audits",
];
let new_map: IndexMap<String, Value> = tables.iter().map(|n| ((*n).into(), metadata.clone())).collect();
let old: Vec<Value> = tables
.iter()
.map(|n| {
let mut v = metadata.clone();
v["name"] = json!(n);
v
})
.collect();
let new_len = serde_json::to_vec(&ListEntries::Detailed(new_map)).unwrap().len();
let old_len = serde_json::to_vec(&old).unwrap().len();
assert!(new_len < old_len, "payload not smaller: new={new_len} old={old_len}");
}
#[test]
fn list_functions_response_brief_constructor_wraps_vec() {
let response = ListFunctionsResponse::brief(vec!["calc_total".into()], None);
assert!(matches!(response.functions, ListEntries::Brief(ref v) if v == &["calc_total"]));
assert!(response.next_cursor.is_none());
}
#[test]
fn list_functions_response_detailed_constructor_wraps_indexmap() {
let map = IndexMap::from([("calc_total(integer)".into(), json!({"language": "sql"}))]);
let response = ListFunctionsResponse::detailed(map, None);
assert!(matches!(response.functions, ListEntries::Detailed(_)));
}
#[test]
fn list_functions_response_brief_matches_legacy_wire_shape() {
let response = ListFunctionsResponse::brief(vec!["audit_user_login".into()], None);
assert_eq!(
serde_json::to_value(&response).unwrap(),
json!({"functions": ["audit_user_login"]})
);
}
#[test]
fn list_procedures_response_brief_constructor_wraps_vec() {
let response = super::ListProceduresResponse::brief(vec!["archive_order".into()], None);
assert!(matches!(response.procedures, ListEntries::Brief(ref v) if v == &["archive_order"]));
assert!(response.next_cursor.is_none());
}
#[test]
fn list_procedures_response_detailed_constructor_wraps_indexmap() {
let map = IndexMap::from([("archive_order(integer)".into(), json!({"language": "plpgsql"}))]);
let response = super::ListProceduresResponse::detailed(map, None);
assert!(matches!(response.procedures, ListEntries::Detailed(_)));
}
#[test]
fn list_procedures_response_brief_matches_legacy_wire_shape() {
let response = super::ListProceduresResponse::brief(vec!["archive_order".into()], None);
assert_eq!(
serde_json::to_value(&response).unwrap(),
json!({"procedures": ["archive_order"]})
);
}
#[test]
fn list_views_response_brief_constructor_wraps_vec() {
let response = super::ListViewsResponse::brief(vec!["active_users".into()], None);
assert!(matches!(response.views, ListEntries::Brief(ref v) if v == &["active_users"]));
assert!(response.next_cursor.is_none());
}
#[test]
fn list_views_response_detailed_constructor_wraps_indexmap() {
let map = IndexMap::from([("active_users".into(), json!({"schema": "public"}))]);
let response = super::ListViewsResponse::detailed(map, None);
assert!(matches!(response.views, ListEntries::Detailed(_)));
}
#[test]
fn list_views_response_brief_matches_legacy_wire_shape() {
let response = super::ListViewsResponse::brief(vec!["active_users".into()], None);
assert_eq!(
serde_json::to_value(&response).unwrap(),
json!({"views": ["active_users"]})
);
}
}