polyc-query-model 2026.9.6

DataFusion-free semantic request and result vocabulary shared by Query clients and the Query service.
//! The `DescribeCatalog` vocabulary — names and logical types only.
//!
//! A catalog reply is server-derived, never caller-shaped: the request
//! carries nothing, and the reply carries exactly the tables the verified
//! credential may name. `Debug` reports counts only, matching the crate's
//! rule that a value which could carry names never prints one.

use std::fmt;

use crate::ModelError;

/// Largest number of tables one catalog reply may carry.
pub const MAX_CATALOG_TABLES: usize = 256;
/// Largest number of columns one catalog table may carry.
pub const MAX_CATALOG_COLUMNS: usize = 256;
/// Largest UTF-8 name the catalog carries for a table or a column.
pub const MAX_CATALOG_NAME_BYTES: usize = 256;

/// A column's logical type — the catalog's closed mirror of the projection
/// registry's own `LogicalType`.
///
/// The mirror is deliberate: the reply must describe exactly the types the
/// registry declares, and a registry type this enum cannot name is a refusal
/// at the boundary, not a silent coercion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatalogColumnType {
    /// A UTF-8 string.
    Utf8,
    /// A fixed-width byte string of `len` bytes.
    FixedBytes {
        /// The declared byte width.
        len: u32,
    },
    /// An unsigned 64-bit integer.
    UInt64,
    /// A Boolean.
    Boolean,
}

/// One column of a catalog table, in schema order.
#[derive(Clone, PartialEq, Eq)]
pub struct CatalogColumn {
    name: String,
    logical_type: CatalogColumnType,
    nullable: bool,
}

impl CatalogColumn {
    /// Validates one column description.
    ///
    /// # Errors
    ///
    /// Returns [`ModelError::Bounds`] for an empty or over-bound name.
    pub fn try_new(
        name: String,
        logical_type: CatalogColumnType,
        nullable: bool,
    ) -> Result<Self, ModelError> {
        if name.is_empty() || name.len() > MAX_CATALOG_NAME_BYTES {
            return Err(ModelError::Bounds("name"));
        }
        Ok(Self {
            name,
            logical_type,
            nullable,
        })
    }

    /// Returns the column name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the column's logical type.
    #[must_use]
    pub const fn logical_type(&self) -> CatalogColumnType {
        self.logical_type
    }

    /// Returns whether the column may hold a SQL null.
    #[must_use]
    pub const fn nullable(&self) -> bool {
        self.nullable
    }
}

impl fmt::Debug for CatalogColumn {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("CatalogColumn")
            .field("logical_type", &self.logical_type)
            .field("nullable", &self.nullable)
            .finish_non_exhaustive()
    }
}

/// One table the caller may name, with its SQL-visible columns.
#[derive(Clone, PartialEq, Eq)]
pub struct CatalogTable {
    name: String,
    columns: Vec<CatalogColumn>,
}

impl CatalogTable {
    /// Validates one table description.
    ///
    /// # Errors
    ///
    /// Returns [`ModelError::Bounds`] for an empty or over-bound name, or a
    /// table describing no column.
    pub fn try_new(name: String, columns: Vec<CatalogColumn>) -> Result<Self, ModelError> {
        if name.is_empty() || name.len() > MAX_CATALOG_NAME_BYTES {
            return Err(ModelError::Bounds("name"));
        }
        if columns.is_empty() || columns.len() > MAX_CATALOG_COLUMNS {
            return Err(ModelError::Bounds("columns"));
        }
        Ok(Self { name, columns })
    }

    /// Returns the table name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Returns the columns, in schema order.
    #[must_use]
    pub fn columns(&self) -> &[CatalogColumn] {
        &self.columns
    }
}

impl fmt::Debug for CatalogTable {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("CatalogTable")
            .field("columns", &self.columns.len())
            .finish_non_exhaustive()
    }
}

/// The complete `DescribeCatalog` reply.
///
/// Tables are strictly ordered by name — canonical order like
/// [`crate::SourceEvidence`]'s pins, so a duplicate or an unsorted pair is a
/// refusal rather than a second copy of the same answer.
#[derive(Clone, PartialEq, Eq)]
pub struct CatalogReply {
    tables: Vec<CatalogTable>,
}

impl CatalogReply {
    /// Validates a complete reply.
    ///
    /// # Errors
    ///
    /// Returns [`ModelError::Bounds`] when the table count exceeds
    /// [`MAX_CATALOG_TABLES`], and [`ModelError::Order`] when the tables are
    /// not strictly ordered by name.
    pub fn try_new(tables: Vec<CatalogTable>) -> Result<Self, ModelError> {
        if tables.len() > MAX_CATALOG_TABLES {
            return Err(ModelError::Bounds("tables"));
        }
        if tables
            .windows(2)
            .any(|pair| pair[0].name() >= pair[1].name())
        {
            return Err(ModelError::Order("tables"));
        }
        Ok(Self { tables })
    }

    /// Returns the tables, strictly ordered by name.
    #[must_use]
    pub fn tables(&self) -> &[CatalogTable] {
        &self.tables
    }
}

impl fmt::Debug for CatalogReply {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("CatalogReply")
            .field("tables", &self.tables.len())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn column(name: &str) -> CatalogColumn {
        CatalogColumn::try_new(name.to_owned(), CatalogColumnType::Utf8, false).unwrap()
    }

    fn table(name: &str) -> CatalogTable {
        CatalogTable::try_new(name.to_owned(), vec![column("id")]).unwrap()
    }

    #[test]
    fn reply_refuses_unsorted_or_duplicate_tables() {
        assert_eq!(
            CatalogReply::try_new(vec![table("b"), table("a")]),
            Err(ModelError::Order("tables"))
        );
        assert_eq!(
            CatalogReply::try_new(vec![table("a"), table("a")]),
            Err(ModelError::Order("tables"))
        );
        assert_eq!(
            CatalogReply::try_new(vec![table("a"), table("b")])
                .unwrap()
                .tables()
                .len(),
            2
        );
    }

    #[test]
    fn table_and_column_refuse_empty_names() {
        assert_eq!(
            CatalogTable::try_new(String::new(), vec![column("id")]),
            Err(ModelError::Bounds("name"))
        );
        assert_eq!(
            CatalogTable::try_new("t".to_owned(), vec![]),
            Err(ModelError::Bounds("columns"))
        );
        assert_eq!(
            CatalogColumn::try_new(String::new(), CatalogColumnType::Boolean, true),
            Err(ModelError::Bounds("name"))
        );
    }
}