use pg_query::NodeEnum;
use pg_query::protobuf::{CommentStmt, ObjectType};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::catalog::Catalog;
use crate::ir::function::{ArgMode, FunctionArg, NormalizedArgTypes};
use crate::parse::builder::shared;
use crate::parse::error::{ParseError, SourceLocation};
pub fn apply_comment(
stmt: &CommentStmt,
catalog: &mut Catalog,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<(), ParseError> {
let kind = ObjectType::try_from(stmt.objtype).unwrap_or(ObjectType::Undefined);
let comment = if stmt.comment.is_empty() {
None
} else {
Some(stmt.comment.clone())
};
apply_comment_inner(stmt, catalog, default_schema, location, kind, comment)
}
#[allow(clippy::too_many_lines)] fn apply_comment_inner(
stmt: &CommentStmt,
catalog: &mut Catalog,
default_schema: Option<&Identifier>,
location: &SourceLocation,
kind: ObjectType,
comment: Option<String>,
) -> Result<(), ParseError> {
match kind {
ObjectType::ObjectSchema => {
let name = single_string(stmt, location)?;
let schema_name = shared::ident(&name, location)?;
let schema = catalog
.schemas
.iter_mut()
.find(|s| s.name == schema_name)
.ok_or_else(|| missing(location, "schema", &schema_name.to_string()))?;
schema.comment = comment;
}
ObjectType::ObjectTable => {
let qname = qualified_name(stmt, default_schema, location)?;
let table = catalog
.tables
.iter_mut()
.find(|t| t.qname == qname)
.ok_or_else(|| missing(location, "table", &qname.to_string()))?;
table.comment = comment;
}
ObjectType::ObjectIndex => {
let qname = qualified_name(stmt, default_schema, location)?;
let index = catalog
.indexes
.iter_mut()
.find(|i| i.qname == qname)
.ok_or_else(|| missing(location, "index", &qname.to_string()))?;
index.comment = comment;
}
ObjectType::ObjectSequence => {
let qname = qualified_name(stmt, default_schema, location)?;
let seq = catalog
.sequences
.iter_mut()
.find(|s| s.qname == qname)
.ok_or_else(|| missing(location, "sequence", &qname.to_string()))?;
seq.comment = comment;
}
ObjectType::ObjectTabconstraint => {
let parts = string_parts(stmt, location)?;
let (table_qname, con_name) = split_column_target(&parts, default_schema, location)?;
let table = catalog
.tables
.iter_mut()
.find(|t| t.qname == table_qname)
.ok_or_else(|| missing(location, "table", &table_qname.to_string()))?;
let con = table
.constraints
.iter_mut()
.find(|c| c.qname.name == con_name)
.ok_or_else(|| {
missing(location, "constraint", &format!("{table_qname}.{con_name}"))
})?;
con.comment = comment;
}
ObjectType::ObjectView => {
let qname = qualified_name(stmt, default_schema, location)?;
let view = catalog
.views
.iter_mut()
.find(|v| v.qname == qname)
.ok_or_else(|| missing(location, "view", &qname.to_string()))?;
view.comment = comment;
}
ObjectType::ObjectMatview => {
let qname = qualified_name(stmt, default_schema, location)?;
let mv = catalog
.materialized_views
.iter_mut()
.find(|m| m.qname == qname)
.ok_or_else(|| missing(location, "materialized view", &qname.to_string()))?;
mv.comment = comment;
}
ObjectType::ObjectColumn => {
let parts = string_parts(stmt, location)?;
let (obj_qname, col_name) = split_column_target(&parts, default_schema, location)?;
apply_column_comment(catalog, location, &obj_qname, &col_name, comment)?;
}
ObjectType::ObjectCollation => {
let qname = qualified_name(stmt, default_schema, location)?;
let coll = catalog
.collations
.iter_mut()
.find(|c| c.qname == qname)
.ok_or_else(|| missing(location, "collation", &qname.to_string()))?;
coll.comment = comment;
}
ObjectType::ObjectType | ObjectType::ObjectDomain => {
let qname = qualified_name_from_type_name(stmt, default_schema, location)?;
let ty = catalog
.types
.iter_mut()
.find(|t| t.qname == qname)
.ok_or_else(|| missing(location, "type", &qname.to_string()))?;
ty.comment = comment;
}
ObjectType::ObjectFunction => {
let (qname, arg_types_normalized) =
qname_and_args_from_object_with_args(stmt, default_schema, location)?;
let func = catalog
.functions
.iter_mut()
.find(|f| f.qname == qname && f.arg_types_normalized == arg_types_normalized)
.ok_or_else(|| missing(location, "function", &qname.to_string()))?;
func.comment = comment;
}
ObjectType::ObjectProcedure => {
let qname = qname_from_object_with_args(stmt, default_schema, location)?;
let proc = catalog
.procedures
.iter_mut()
.find(|p| p.qname == qname)
.ok_or_else(|| missing(location, "procedure", &qname.to_string()))?;
proc.comment = comment;
}
ObjectType::ObjectExtension => {
let name_str = single_string(stmt, location)?;
let ext_name = shared::ident(&name_str, location)?;
let ext = catalog
.extensions
.iter_mut()
.find(|e| e.name == ext_name)
.ok_or_else(|| missing(location, "extension", &ext_name.to_string()))?;
ext.comment = comment;
}
ObjectType::ObjectTrigger => {
let parts = string_parts(stmt, location)?;
let (table_qn, ident) = split_column_target(&parts, default_schema, location)?;
let qname = crate::identifier::QualifiedName::new(table_qn.schema, ident);
let trg = catalog
.triggers
.iter_mut()
.find(|t| t.qname == qname)
.ok_or_else(|| missing(location, "trigger", &qname.to_string()))?;
trg.comment = comment;
}
other => {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("unsupported COMMENT target kind: {other:?}"),
});
}
}
Ok(())
}
fn apply_column_comment(
catalog: &mut Catalog,
location: &SourceLocation,
obj_qname: &QualifiedName,
col_name: &Identifier,
comment: Option<String>,
) -> Result<(), ParseError> {
if let Some(table) = catalog.tables.iter_mut().find(|t| t.qname == *obj_qname) {
let col = table
.columns
.iter_mut()
.find(|c| c.name == *col_name)
.ok_or_else(|| missing(location, "column", &format!("{obj_qname}.{col_name}")))?;
col.comment = comment;
} else if let Some(view) = catalog.views.iter_mut().find(|v| v.qname == *obj_qname) {
let col = view
.columns
.iter_mut()
.find(|c| c.name == *col_name)
.ok_or_else(|| missing(location, "view column", &format!("{obj_qname}.{col_name}")))?;
col.comment = comment;
} else if let Some(mv) = catalog
.materialized_views
.iter_mut()
.find(|m| m.qname == *obj_qname)
{
let col = mv
.columns
.iter_mut()
.find(|c| c.name == *col_name)
.ok_or_else(|| {
missing(
location,
"materialized view column",
&format!("{obj_qname}.{col_name}"),
)
})?;
col.comment = comment;
} else {
return Err(missing(location, "table/view/mv", &obj_qname.to_string()));
}
Ok(())
}
fn qname_from_object_with_args(
stmt: &CommentStmt,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<QualifiedName, ParseError> {
let obj = stmt
.object
.as_ref()
.and_then(|o| o.node.as_ref())
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "COMMENT missing object reference".into(),
})?;
let NodeEnum::ObjectWithArgs(owa) = obj else {
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"COMMENT ON FUNCTION/PROCEDURE expected ObjectWithArgs, got {:?}",
std::mem::discriminant(obj)
),
});
};
shared::qname_from_string_list(&owa.objname, default_schema, location)
}
fn qname_and_args_from_object_with_args(
stmt: &CommentStmt,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<(QualifiedName, NormalizedArgTypes), ParseError> {
let obj = stmt
.object
.as_ref()
.and_then(|o| o.node.as_ref())
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "COMMENT missing object reference".into(),
})?;
let NodeEnum::ObjectWithArgs(owa) = obj else {
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"COMMENT ON FUNCTION expected ObjectWithArgs, got {:?}",
std::mem::discriminant(obj)
),
});
};
let qname = shared::qname_from_string_list(&owa.objname, default_schema, location)?;
let mut args: Vec<FunctionArg> = Vec::with_capacity(owa.objargs.len());
for node in &owa.objargs {
let Some(NodeEnum::TypeName(tn)) = node.node.as_ref() else {
return Err(ParseError::Structural {
location: location.clone(),
message: format!(
"COMMENT ON FUNCTION {qname}: expected TypeName in objargs, got {:?}",
node.node.as_ref().map(std::mem::discriminant)
),
});
};
let ty = shared::type_name_to_column_type(tn, location)?;
args.push(FunctionArg {
name: None,
mode: ArgMode::In,
ty,
default: None,
});
}
let arg_types_normalized = NormalizedArgTypes::from_args(&args);
Ok((qname, arg_types_normalized))
}
fn missing(location: &SourceLocation, kind: &'static str, name: &str) -> ParseError {
ParseError::Structural {
location: location.clone(),
message: format!("COMMENT references {kind} {name} which is not defined in source"),
}
}
fn single_string(stmt: &CommentStmt, location: &SourceLocation) -> Result<String, ParseError> {
let obj = stmt
.object
.as_ref()
.and_then(|o| o.node.as_ref())
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "COMMENT missing object reference".into(),
})?;
if let NodeEnum::String(s) = obj {
return Ok(s.sval.clone());
}
if let NodeEnum::List(list) = obj
&& let Some(NodeEnum::String(s)) = list.items.first().and_then(|n| n.node.as_ref())
{
return Ok(s.sval.clone());
}
Err(ParseError::Structural {
location: location.clone(),
message: "COMMENT object expected a string identifier".into(),
})
}
fn string_parts(stmt: &CommentStmt, location: &SourceLocation) -> Result<Vec<String>, ParseError> {
let obj = stmt
.object
.as_ref()
.and_then(|o| o.node.as_ref())
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "COMMENT missing object reference".into(),
})?;
let NodeEnum::List(list) = obj else {
return Err(ParseError::Structural {
location: location.clone(),
message: "COMMENT object expected a qualified-name list".into(),
});
};
let mut out = Vec::with_capacity(list.items.len());
for n in &list.items {
let Some(NodeEnum::String(s)) = n.node.as_ref() else {
return Err(ParseError::Structural {
location: location.clone(),
message: "COMMENT qualified-name parts must be string identifiers".into(),
});
};
out.push(s.sval.clone());
}
Ok(out)
}
fn qualified_name(
stmt: &CommentStmt,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<QualifiedName, ParseError> {
let parts = string_parts(stmt, location)?;
match parts.as_slice() {
[name] => {
let schema = default_schema
.cloned()
.ok_or_else(|| ParseError::UnqualifiedName {
location: location.clone(),
})?;
Ok(QualifiedName::new(schema, shared::ident(name, location)?))
}
[schema, name] => Ok(QualifiedName::new(
shared::ident(schema, location)?,
shared::ident(name, location)?,
)),
_ => Err(ParseError::Structural {
location: location.clone(),
message: "COMMENT object name must have one or two qualified components".into(),
}),
}
}
fn qualified_name_from_type_name(
stmt: &CommentStmt,
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<QualifiedName, ParseError> {
use pg_query::NodeEnum;
let obj = stmt
.object
.as_ref()
.and_then(|o| o.node.as_ref())
.ok_or_else(|| ParseError::Structural {
location: location.clone(),
message: "COMMENT missing object reference".into(),
})?;
let NodeEnum::TypeName(type_name) = obj else {
return Err(ParseError::Structural {
location: location.clone(),
message: format!("COMMENT ON TYPE expected TypeName node, got {obj:?}"),
});
};
let parts: Vec<String> = type_name
.names
.iter()
.filter_map(|n| n.node.as_ref())
.filter_map(|n| {
if let NodeEnum::String(s) = n {
Some(s.sval.clone())
} else {
None
}
})
.collect();
match parts.as_slice() {
[name] => {
let schema = default_schema
.cloned()
.ok_or_else(|| ParseError::UnqualifiedName {
location: location.clone(),
})?;
Ok(QualifiedName::new(schema, shared::ident(name, location)?))
}
[schema, name] => Ok(QualifiedName::new(
shared::ident(schema, location)?,
shared::ident(name, location)?,
)),
_ => Err(ParseError::Structural {
location: location.clone(),
message: format!(
"COMMENT ON TYPE object name must have 1-2 qualified components; got {parts:?}"
),
}),
}
}
fn split_column_target(
parts: &[String],
default_schema: Option<&Identifier>,
location: &SourceLocation,
) -> Result<(QualifiedName, Identifier), ParseError> {
match parts {
[table, last] => {
let schema = default_schema
.cloned()
.ok_or_else(|| ParseError::UnqualifiedName {
location: location.clone(),
})?;
Ok((
QualifiedName::new(schema, shared::ident(table, location)?),
shared::ident(last, location)?,
))
}
[schema, table, last] => Ok((
QualifiedName::new(
shared::ident(schema, location)?,
shared::ident(table, location)?,
),
shared::ident(last, location)?,
)),
_ => Err(ParseError::Structural {
location: location.clone(),
message: "expected `[schema.]table.<column-or-constraint>` for this COMMENT target"
.into(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::column::Column;
use crate::ir::column_type::ColumnType;
use crate::ir::constraint::{Constraint as IrConstraint, ConstraintKind, Deferrable};
use crate::ir::index::{
Index as IrIndex, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder,
SortOrder,
};
use crate::ir::schema::Schema;
use crate::ir::sequence::Sequence;
use crate::ir::table::Table;
use std::path::PathBuf;
fn loc() -> SourceLocation {
SourceLocation::new(PathBuf::from("test.sql"), 1, 1)
}
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(name: &str) -> QualifiedName {
QualifiedName::new(id("app"), id(name))
}
fn seed_catalog() -> Catalog {
let mut c = Catalog::empty();
c.schemas.push(Schema::new(id("app")));
c.tables.push(Table {
qname: qn("users"),
columns: vec![Column {
name: id("email"),
ty: ColumnType::Text,
nullable: true,
default: None,
identity: None,
generated: None,
collation: None,
storage: None,
compression: None,
comment: None,
}],
constraints: vec![IrConstraint {
qname: qn("users_pkey"),
kind: ConstraintKind::PrimaryKey {
columns: vec![id("email")],
include: vec![],
},
deferrable: Deferrable::NotDeferrable,
comment: None,
}],
partition_by: None,
partition_of: None,
comment: None,
owner: None,
grants: vec![],
rls_enabled: false,
rls_forced: false,
policies: vec![],
storage: crate::ir::reloptions::TableStorageOptions::default(),
access_method: None,
tablespace: None,
});
c.indexes.push(IrIndex {
qname: qn("users_email_idx"),
on: IndexParent::Table(qn("users")),
method: IndexMethod::BTree,
columns: vec![IndexColumn {
expr: IndexColumnExpr::Column(id("email")),
collation: None,
opclass: None,
sort_order: SortOrder::Asc,
nulls_order: NullsOrder::NullsLast,
}],
include: vec![],
unique: false,
nulls_not_distinct: false,
predicate: None,
tablespace: None,
comment: None,
storage: crate::ir::reloptions::IndexStorageOptions::default(),
});
c.sequences.push(Sequence {
qname: qn("seq1"),
data_type: ColumnType::BigInt,
start: 1,
increment: 1,
min_value: None,
max_value: None,
cache: 1,
cycle: false,
owned_by: None,
comment: None,
owner: None,
grants: vec![],
});
c
}
fn parse_first(sql: &str) -> CommentStmt {
let parsed = pg_query::parse(sql).expect("parses");
let stmt = parsed
.protobuf
.stmts
.into_iter()
.next()
.and_then(|raw| raw.stmt)
.and_then(|n| n.node)
.expect("stmt");
let NodeEnum::CommentStmt(c) = stmt else {
panic!("not CommentStmt")
};
*c
}
#[test]
fn comment_on_schema() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON SCHEMA app IS 'application';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(c.schemas[0].comment.as_deref(), Some("application"));
}
#[test]
fn comment_on_table() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON TABLE app.users IS 'people';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(c.tables[0].comment.as_deref(), Some("people"));
}
#[test]
fn comment_on_column() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON COLUMN app.users.email IS 'their email';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(
c.tables[0].columns[0].comment.as_deref(),
Some("their email")
);
}
#[test]
fn comment_on_index() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON INDEX app.users_email_idx IS 'lookup';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(c.indexes[0].comment.as_deref(), Some("lookup"));
}
#[test]
fn comment_on_sequence() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON SEQUENCE app.seq1 IS 'counter';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(c.sequences[0].comment.as_deref(), Some("counter"));
}
#[test]
fn comment_on_constraint() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON CONSTRAINT users_pkey ON app.users IS 'primary';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(
c.tables[0].constraints[0].comment.as_deref(),
Some("primary")
);
}
#[test]
fn comment_on_missing_table_errors() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON TABLE app.does_not_exist IS 'x';");
let err = apply_comment(&stmt, &mut c, None, &loc()).unwrap_err();
assert!(matches!(err, ParseError::Structural { .. }));
}
#[test]
fn empty_comment_clears_comment_field() {
let mut c = seed_catalog();
c.tables[0].comment = Some("old".into());
let stmt = parse_first("COMMENT ON TABLE app.users IS NULL;");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert!(c.tables[0].comment.is_none());
}
#[test]
fn comment_on_type_sets_comment() {
use crate::ir::user_type::{UserType, UserTypeKind};
let mut c = seed_catalog();
let qname = QualifiedName::new(
Identifier::from_unquoted("app").unwrap(),
Identifier::from_unquoted("mytype").unwrap(),
);
c.types.push(UserType {
qname: qname.clone(),
kind: UserTypeKind::Enum { values: vec![] },
comment: None,
owner: None,
grants: vec![],
});
let stmt = parse_first("COMMENT ON TYPE app.mytype IS 'a type';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
let ty = c.types.iter().find(|t| t.qname == qname).unwrap();
assert_eq!(ty.comment.as_deref(), Some("a type"));
}
#[test]
fn comment_on_domain_sets_comment() {
use crate::ir::column_type::ColumnType;
use crate::ir::user_type::{UserType, UserTypeKind};
let mut c = seed_catalog();
let qname = QualifiedName::new(
Identifier::from_unquoted("app").unwrap(),
Identifier::from_unquoted("email").unwrap(),
);
c.types.push(UserType {
qname: qname.clone(),
kind: UserTypeKind::Domain {
base: ColumnType::Text,
nullable: true,
default: None,
check_constraints: vec![],
collation: None,
},
comment: None,
owner: None,
grants: vec![],
});
let stmt = parse_first("COMMENT ON DOMAIN app.email IS 'email domain';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
let ty = c.types.iter().find(|t| t.qname == qname).unwrap();
assert_eq!(ty.comment.as_deref(), Some("email domain"));
}
#[test]
fn comment_on_function_sets_comment() {
use crate::ir::function::{
ArgMode, Function, FunctionArg, FunctionLanguage, NormalizedArgTypes, ParallelSafety,
ReturnType, SecurityMode, Volatility,
};
use crate::parse::normalize_body::NormalizedBody;
let mut c = seed_catalog();
let args = vec![FunctionArg {
name: Some(id("x")),
mode: ArgMode::In,
ty: ColumnType::Integer,
default: None,
}];
let arg_types_normalized = NormalizedArgTypes::from_args(&args);
c.functions.push(Function {
qname: qn("double"),
args,
arg_types_normalized,
return_type: ReturnType::Scalar {
ty: ColumnType::Integer,
},
language: FunctionLanguage::Sql,
body: NormalizedBody::from_sql("SELECT $1 * 2").unwrap(),
body_dependencies: vec![],
volatility: Volatility::Immutable,
strict: true,
security: SecurityMode::Invoker,
parallel: ParallelSafety::Safe,
leakproof: false,
cost: Some(1.0),
rows: None,
comment: None,
owner: None,
grants: vec![],
});
let stmt = parse_first("COMMENT ON FUNCTION app.double(integer) IS 'doubles the value';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(c.functions[0].comment.as_deref(), Some("doubles the value"));
}
#[test]
fn comment_on_collation_attaches_to_ir() {
use crate::ir::collation::{Collation, CollationProvider};
let mut c = seed_catalog();
let qname = QualifiedName::new(
Identifier::from_unquoted("app").unwrap(),
Identifier::from_unquoted("x").unwrap(),
);
c.collations.push(Collation {
qname: qname.clone(),
provider: CollationProvider::Libc,
lc_collate: "C".into(),
lc_ctype: "C".into(),
deterministic: true,
version: None,
owner: None,
comment: None,
});
let stmt = parse_first("COMMENT ON COLLATION app.x IS 'pinned for sorting';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
let coll = c.collations.iter().find(|x| x.qname == qname).unwrap();
assert_eq!(coll.comment.as_deref(), Some("pinned for sorting"));
}
#[test]
fn comment_on_missing_collation_errors() {
let mut c = seed_catalog();
let stmt = parse_first("COMMENT ON COLLATION app.nope IS 'x';");
let err = apply_comment(&stmt, &mut c, None, &loc()).unwrap_err();
assert!(matches!(err, ParseError::Structural { .. }));
}
#[test]
fn comment_on_procedure_sets_comment() {
use crate::ir::function::{FunctionLanguage, SecurityMode};
use crate::ir::procedure::Procedure;
use crate::parse::normalize_body::NormalizedBody;
let mut c = seed_catalog();
c.procedures.push(Procedure {
qname: qn("greet"),
args: vec![],
language: FunctionLanguage::PlPgSql,
body: NormalizedBody::empty(),
body_dependencies: vec![],
security: SecurityMode::Invoker,
commits_in_body: false,
comment: None,
owner: None,
grants: vec![],
});
let stmt = parse_first("COMMENT ON PROCEDURE app.greet IS 'greeting procedure';");
apply_comment(&stmt, &mut c, None, &loc()).unwrap();
assert_eq!(
c.procedures[0].comment.as_deref(),
Some("greeting procedure")
);
}
}