diff --git a/src/impls/sqlparser/dql/definition_graph/output_columns.rs b/src/impls/sqlparser/dql/definition_graph/output_columns.rs index 02534d8b2..162a24eed 100644 --- a/src/impls/sqlparser/dql/definition_graph/output_columns.rs +++ b/src/impls/sqlparser/dql/definition_graph/output_columns.rs @@ -2,9 +2,8 @@ use alloc::{borrow::ToOwned, collections::BTreeMap, format, string::String, vec, use core::ops::ControlFlow; use sqlparser::ast::{ - AccessExpr, ArrayElemTypeDef, BinaryOperator, DataType, ExactNumberInfo, Expr, Ident, InTarget, - Query, Select, SelectItem, SelectItemQualifiedWildcardKind, SetExpr, SetOperator, TimezoneInfo, - TrimWhereField, Values, Visit, Visitor, + Expr, Ident, Query, Select, SelectItem, SelectItemQualifiedWildcardKind, SetExpr, SetOperator, + Values, Visit, Visitor, }; use super::{ @@ -19,7 +18,7 @@ use crate::{ errors::LookupError, structs::{ColumnBinding, ColumnDefinition, IdentifierCase, QueryOutput}, traits::DatabaseLike, - utils::object_name::object_name_last_part, + utils::figured_names::{Figure, figure}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -35,6 +34,17 @@ impl OutputName { fn exact(name: &str) -> Self { Self::Known { name: name.to_owned(), quoted: true } } + + /// The name a select-list entry without an alias takes. + fn figured(figure: Figure) -> Self { + match figure { + Figure::Strong(name, quoted) | Figure::Weak(name, quoted) => { + Self::Known { name, quoted } + } + Figure::Unnamed => Self::exact("?column?"), + Figure::Unknown => Self::Unknown, + } + } } #[derive(Clone)] @@ -148,181 +158,6 @@ fn match_output<'entries>( Err(LookupError::AmbiguousTableLookup { object_name: key.value.clone(), candidates }) } -enum Figure { - Strong(String, bool), - Weak(String, bool), - Unnamed, - Unknown, -} - -impl Figure { - fn strong(name: &str) -> Self { - Self::Strong(name.to_owned(), true) - } - - fn ident(ident: &Ident) -> Self { - Self::Strong(ident.value.clone(), ident.quote_style.is_some()) - } - - fn output_name(self) -> OutputName { - match self { - Self::Strong(name, quoted) | Self::Weak(name, quoted) => { - OutputName::Known { name, quoted } - } - Self::Unnamed => OutputName::exact("?column?"), - Self::Unknown => OutputName::Unknown, - } - } -} - -/// Mirrors PostgreSQL's `FigureColname`, answering `Unknown` where the name is -/// not modeled. -fn figure(expression: &Expr) -> Figure { - match expression { - Expr::Identifier(ident) => Figure::ident(ident), - Expr::CompoundIdentifier(parts) => parts.last().map_or(Figure::Unknown, Figure::ident), - Expr::CompoundFieldAccess { root, access_chain } => { - let mut field = None; - for access in access_chain { - if let AccessExpr::Dot(part) = access { - let Expr::Identifier(ident) = part else { - return Figure::Unknown; - }; - field = Some(ident); - } - } - field.map_or_else(|| figure(root), Figure::ident) - } - Expr::Nested(inner) | Expr::Collate { expr: inner, .. } => figure(inner), - Expr::Function(function) => { - object_name_last_part(&function.name) - .map_or(Figure::Unknown, |(name, quoted)| Figure::Strong(name.to_owned(), quoted)) - } - Expr::Cast { expr, data_type, .. } => { - match figure(expr) { - Figure::Weak(..) | Figure::Unnamed => type_figure(data_type), - named => named, - } - } - Expr::TypedString(typed) => type_figure(&typed.data_type), - Expr::Interval(_) => Figure::Weak("interval".to_owned(), true), - Expr::Case { else_result, .. } => { - match else_result.as_deref().map_or(Figure::Unnamed, figure) { - Figure::Weak(..) | Figure::Unnamed => Figure::Weak("case".to_owned(), true), - named => named, - } - } - Expr::Array(_) => Figure::strong("array"), - Expr::Tuple(_) => Figure::strong("row"), - Expr::Exists { negated: false, .. } => Figure::strong("exists"), - Expr::Subquery(query) => first_output_figure(&query.body), - Expr::Extract { .. } => Figure::strong("extract"), - Expr::Ceil { .. } => Figure::strong("ceil"), - Expr::Floor { .. } => Figure::strong("floor"), - Expr::Position { .. } => Figure::strong("position"), - Expr::Overlay { .. } => Figure::strong("overlay"), - Expr::AtTimeZone { .. } => Figure::strong("timezone"), - Expr::Substring { shorthand: true, .. } => Figure::strong("substr"), - Expr::Substring { shorthand: false, .. } => Figure::strong("substring"), - Expr::Trim { trim_where: Some(TrimWhereField::Leading), .. } => Figure::strong("ltrim"), - Expr::Trim { trim_where: Some(TrimWhereField::Trailing), .. } => Figure::strong("rtrim"), - Expr::Trim { .. } => Figure::strong("btrim"), - Expr::BinaryOp { op: BinaryOperator::Overlaps, .. } => Figure::strong("overlaps"), - Expr::In { target, .. } => { - match target { - InTarget::List(_) - | InTarget::Subquery(_) - | InTarget::Unnest(_) - | InTarget::Table { .. } => Figure::Unnamed, - } - } - Expr::Value(_) - | Expr::BinaryOp { .. } - | Expr::UnaryOp { .. } - | Expr::IsFalse(_) - | Expr::IsNotFalse(_) - | Expr::IsTrue(_) - | Expr::IsNotTrue(_) - | Expr::IsNull(_) - | Expr::IsNotNull(_) - | Expr::IsUnknown(_) - | Expr::IsNotUnknown(_) - | Expr::IsDistinctFrom(..) - | Expr::IsNotDistinctFrom(..) - | Expr::Between { .. } - | Expr::Like { .. } - | Expr::ILike { .. } - | Expr::SimilarTo { .. } - | Expr::AnyOp { .. } - | Expr::AllOp { .. } - | Expr::Exists { negated: true, .. } => Figure::Unnamed, - _ => Figure::Unknown, - } -} - -fn first_output_figure(body: &SetExpr) -> Figure { - match body { - SetExpr::Select(select) => { - match select.projection.first() { - Some(SelectItem::ExprWithAlias { alias, .. }) => Figure::ident(alias), - Some(SelectItem::UnnamedExpr(expression)) => { - match figure(expression) { - Figure::Strong(name, quoted) | Figure::Weak(name, quoted) => { - Figure::Strong(name, quoted) - } - Figure::Unnamed => Figure::strong("?column?"), - Figure::Unknown => Figure::Unknown, - } - } - _ => Figure::Unknown, - } - } - SetExpr::Query(query) => first_output_figure(&query.body), - SetExpr::SetOperation { left, .. } => first_output_figure(left), - SetExpr::Values(_) => Figure::strong("column1"), - _ => Figure::Unknown, - } -} - -fn type_figure(data_type: &DataType) -> Figure { - let name = match data_type { - DataType::Int(_) | DataType::Integer(_) | DataType::Int4(_) => "int4", - DataType::BigInt(_) | DataType::Int8(_) => "int8", - DataType::SmallInt(_) | DataType::Int2(_) => "int2", - DataType::Text => "text", - DataType::Varchar(_) | DataType::CharacterVarying(_) => "varchar", - DataType::Numeric(_) | DataType::Decimal(_) => "numeric", - DataType::Boolean | DataType::Bool => "bool", - DataType::Float(ExactNumberInfo::Precision(precision)) if *precision <= 24 => "float4", - DataType::Float(ExactNumberInfo::None | ExactNumberInfo::Precision(_)) - | DataType::Float8 - | DataType::DoublePrecision => "float8", - DataType::Real | DataType::Float4 => "float4", - DataType::Date => "date", - DataType::JSON => "json", - DataType::JSONB => "jsonb", - DataType::Char(_) | DataType::Character(_) => "bpchar", - DataType::Bytea => "bytea", - DataType::Uuid => "uuid", - DataType::Interval { .. } => "interval", - DataType::Bit(_) => "bit", - DataType::VarBit(_) | DataType::BitVarying(_) => "varbit", - DataType::Time(_, TimezoneInfo::None | TimezoneInfo::WithoutTimeZone) => "time", - DataType::Time(_, TimezoneInfo::Tz | TimezoneInfo::WithTimeZone) => "timetz", - DataType::Timestamp(_, TimezoneInfo::None | TimezoneInfo::WithoutTimeZone) => "timestamp", - DataType::Timestamp(_, TimezoneInfo::Tz | TimezoneInfo::WithTimeZone) => "timestamptz", - DataType::Array(ArrayElemTypeDef::SquareBracket(element, _)) => { - return type_figure(element); - } - DataType::Custom(name, _) => { - return object_name_last_part(name) - .map_or(Figure::Unknown, |(name, quoted)| Figure::Weak(name.to_owned(), quoted)); - } - _ => return Figure::Unknown, - }; - Figure::Weak(name.to_owned(), true) -} - fn values_entries<'query, 'db>(values: &Values) -> Vec> { let width = values.rows.first().map_or(0, |row| row.len()); (1..=width) @@ -378,7 +213,7 @@ impl<'query, 'db, DB: DatabaseLike> DefinitionDerivation<'query, 'db, DB> { quoted: alias.quote_style.is_some(), } } - _ => figure(expression.get()).output_name(), + _ => OutputName::figured(figure(expression.get())), }; let definition = self.expression_definition(expression, Some(cursor))?; outputs.push(OutputEntry::Column(OutputColumn { diff --git a/src/structs/generic_db/sqlparser.rs b/src/structs/generic_db/sqlparser.rs index bb276a508..4ab94bd7d 100644 --- a/src/structs/generic_db/sqlparser.rs +++ b/src/structs/generic_db/sqlparser.rs @@ -69,6 +69,7 @@ mod column_copy; mod constraint_validity; mod extensions; mod functions_in_expression; +mod generated_names; mod inheritance; mod leading_comments; mod like; @@ -1541,6 +1542,21 @@ impl ParserDBBuilder { } } + /// The name PostgreSQL gives an index `create_index` leaves unnamed, or + /// [`None`] when an index expression's name is not modeled. + fn generated_index_name( + &self, + create_index: &CreateIndex, + ) -> Result, crate::errors::Error> { + let Some(table) = self.resolve_table_object_name(&create_index.table_name)? else { + return Err(crate::errors::Error::TableNotFoundForIndex { + table_name: last_str(&create_index.table_name).to_string(), + index_name: "".to_string(), + }); + }; + Ok(generated_names::create_index_name(self, table, create_index)) + } + /// Resolves a reference a stored node carries, which ingestion bound, so /// a bare one means the default schema and no path is walked. fn resolve_bound_table_name( @@ -4120,6 +4136,47 @@ impl ParserDBIngestor { self.catalog.snapshot(self.access_resolution) } + /// The name applying `create_index` now stores the index under, which is + /// PostgreSQL's choice when the statement writes none, and [`None`] when + /// that choice rests on an index expression whose name is not modeled. + /// + /// # Errors + /// + /// Returns an error when the written name is one no schema can hold, or + /// when the statement leaves the name to the server and its table does not + /// exist. + /// + /// # Examples + /// + /// ``` + /// use sql_traits::prelude::*; + /// use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; + /// + /// let statement = + /// |sql| Parser::parse_sql(&PostgreSqlDialect {}, sql).expect("SQL parses").remove(0); + /// let ingestor = ParserDBIngestor::new::("catalog".to_owned()) + /// .apply_statement(statement("CREATE TABLE t (s TEXT);"))? + /// .apply_statement(statement("CREATE TABLE t_s_idx (id INT);"))?; + /// + /// let create_index = Parser::new(&PostgreSqlDialect {}) + /// .try_with_sql("ON t (s)") + /// .and_then(|mut parser| parser.parse_create_index(false)) + /// .expect("the part after CREATE INDEX parses"); + /// let name = ingestor.create_index_name(&create_index)?.expect("named after its column"); + /// assert_eq!(name.value, "t_s_idx1"); + /// # Ok::<(), sql_traits::errors::Error>(()) + /// ``` + pub fn create_index_name( + &self, + create_index: &CreateIndex, + ) -> Result, crate::errors::Error> { + let Some(name) = &create_index.name else { + return self.catalog.builder.generated_index_name(create_index); + }; + require_named(name, crate::errors::ObjectKind::Index)?; + Ok(object_name_last_identifier(name).cloned()) + } + /// Returns the final queryable schema, rolling back a transaction still /// open. #[must_use] @@ -7123,6 +7180,11 @@ impl ParserDB { require_named(index_name, crate::errors::ObjectKind::Index)?; } builder.bind_table_reference(&mut create_index.table_name); + if create_index.name.is_none() { + create_index.name = builder + .generated_index_name(&create_index)? + .map(|name| ObjectName::from(vec![name])); + } let if_not_exists = create_index.if_not_exists; let (index, metadata) = Self::process_create_index(create_index, &builder)?; let resolved_table = index.table(); @@ -7133,8 +7195,7 @@ impl ParserDB { // An index takes its schema from its table, and shares one // pool of names there with tables and with the indexes - // behind named constraints. An unnamed index is named by - // the server and contests nothing. + // behind named constraints. if let Some(index_name) = index.attribute().name.as_ref() && let Some(ObjectNamePart::Identifier(index_name)) = index_name.0.last() && let Some(conflicting_kind) = relation_name_holder( @@ -10299,7 +10360,7 @@ mod tests { } #[test] - fn test_anonymous_index_has_no_name() { + fn test_unnamed_index_takes_the_generated_name() { let sql = r" CREATE TABLE t (id INT PRIMARY KEY, name TEXT); CREATE INDEX ON t(name); @@ -10307,7 +10368,7 @@ mod tests { let db = ParserDB::parse::(sql).expect("Failed to parse SQL"); let index = db.indexes().next().expect("index should exist"); - assert!(index.name().is_none()); + assert_eq!(index.name(), Some("t_name_idx")); } #[test] diff --git a/src/structs/generic_db/sqlparser/generated_names.rs b/src/structs/generic_db/sqlparser/generated_names.rs new file mode 100644 index 000000000..5e24c4814 --- /dev/null +++ b/src/structs/generic_db/sqlparser/generated_names.rs @@ -0,0 +1,161 @@ +//! The names PostgreSQL gives indexes it is not told a name for, following +//! `ChooseIndexColumnNames`, `ChooseIndexNameAddition`, `makeObjectName` and +//! `ChooseRelationName`. + +use alloc::{ + borrow::ToOwned, + format, + string::{String, ToString}, + vec::Vec, +}; + +use sqlparser::ast::{CreateIndex, CreateTable, Ident, IndexColumn}; + +use super::{ParserDBBuilder, relation_name_holder, table_schema_qualifier}; +use crate::{ + traits::TableLike, + utils::{ + figured_names::{Figure, figure}, + identifier_resolution::normalize_identifier, + }, +}; + +/// The most bytes PostgreSQL keeps of a name, `NAMEDATALEN` less one. +const MAX_NAME_BYTES: usize = 63; + +/// The longest prefix of `name` within `limit` bytes ending on a character +/// boundary, as `pg_mbcliplen` cuts. +fn clipped(name: &str, limit: usize) -> &str { + let mut end = limit.min(name.len()); + while !name.is_char_boundary(end) { + end -= 1; + } + &name[..end] +} + +/// The name PostgreSQL stores for a written identifier, cut as its scanner cuts +/// it. +fn stored(value: &str, quoted: bool) -> String { + let mut name = normalize_identifier(value, quoted).into_owned(); + name.truncate(clipped(&name, MAX_NAME_BYTES).len()); + name +} + +/// An identifier reaching exactly the stored name `value`, bare only when +/// folding leaves it unchanged. +fn spelled(value: String) -> Ident { + // Never a keyword bare, since a generated name ends in `_` and a label. + let mut bytes = value.bytes(); + let bare = bytes.next().is_some_and(|byte| byte.is_ascii_lowercase() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'); + if bare { Ident::new(value) } else { Ident::with_quote('"', value) } +} + +/// The column part of the name of an index over `columns` and `include`, or +/// [`None`] when a key column is an expression whose name is not modeled. +pub(super) fn index_columns_part(columns: &[IndexColumn], include: &[Ident]) -> Option { + let mut names: Vec = Vec::with_capacity(columns.len() + include.len()); + let figured = columns.iter().map(|column| { + match figure(&column.column.expr) { + Figure::Strong(name, quoted) | Figure::Weak(name, quoted) => { + Some(stored(&name, quoted)) + } + Figure::Unnamed => Some("expr".to_owned()), + Figure::Unknown => None, + } + }); + let included = + include.iter().map(|ident| Some(stored(&ident.value, ident.quote_style.is_some()))); + for name in figured.chain(included) { + let name = name?; + let mut renamed: Option = None; + let mut counter = 0u32; + while names.iter().any(|held| *held == renamed.as_deref().unwrap_or(&name)) { + counter += 1; + let suffix = counter.to_string(); + renamed = Some(format!("{}{suffix}", clipped(&name, MAX_NAME_BYTES - suffix.len()))); + } + names.push(renamed.unwrap_or(name)); + } + + let mut part = String::new(); + for name in &names { + if !part.is_empty() { + part.push('_'); + } + part.push_str(name); + // The rest would be cut anyway. + if part.len() > MAX_NAME_BYTES { + break; + } + } + Some(part) +} + +/// The column part of the name of the index behind a key one column declares +/// inline. +pub(super) fn column_part(column: &Ident) -> String { + stored(&column.value, column.quote_style.is_some()) +} + +/// `name1`, the optional `name2` and `label` joined by `_`, the longer name +/// giving up a byte at a time until the whole fits. +fn object_name(name1: &str, name2: Option<&str>, label: &str) -> String { + let overhead = usize::from(name2.is_some()) + 1 + label.len(); + let available = MAX_NAME_BYTES.saturating_sub(overhead); + let mut name1_bytes = name1.len(); + let mut name2_bytes = name2.map_or(0, str::len); + while name1_bytes + name2_bytes > available { + if name1_bytes > name2_bytes { + name1_bytes -= 1; + } else { + name2_bytes -= 1; + } + } + + let mut name = String::with_capacity(MAX_NAME_BYTES); + name.push_str(clipped(name1, name1_bytes)); + if let Some(name2) = name2 { + name.push('_'); + name.push_str(clipped(name2, name2_bytes)); + } + name.push('_'); + name.push_str(label); + name +} + +/// The first name for an index on `table` that no relation in its schema holds +/// and `also_taken` does not report. +pub(super) fn index_name_on( + builder: &ParserDBBuilder, + table: &CreateTable, + columns: Option<&str>, + label: &str, + mut also_taken: impl FnMut(&Ident) -> bool, +) -> Ident { + let table_name = stored(table.table_name(), table.table_name_is_quoted()); + let schema = table_schema_qualifier(table); + let mut pass = 0u32; + loop { + let candidate = if pass == 0 { + spelled(object_name(&table_name, columns, label)) + } else { + spelled(object_name(&table_name, columns, &format!("{label}{pass}"))) + }; + if relation_name_holder(builder, &candidate, schema).is_none() && !also_taken(&candidate) { + return candidate; + } + pass += 1; + } +} + +/// The name of the index `create_index` creates on `table` without naming it, +/// or [`None`] when an index expression's name is not modeled. +pub(super) fn create_index_name( + builder: &ParserDBBuilder, + table: &CreateTable, + create_index: &CreateIndex, +) -> Option { + let columns = index_columns_part(&create_index.columns, &create_index.include)?; + Some(index_name_on(builder, table, Some(&columns), "idx", |_| false)) +} diff --git a/src/structs/generic_db/sqlparser/inheritance.rs b/src/structs/generic_db/sqlparser/inheritance.rs index 7bf638ba0..48e1c0bc3 100644 --- a/src/structs/generic_db/sqlparser/inheritance.rs +++ b/src/structs/generic_db/sqlparser/inheritance.rs @@ -18,11 +18,12 @@ use alloc::{ use sqlparser::ast::{ CharacterLength, ColumnDef, ColumnOption, ColumnOptionDef, CreateTable, DataType, - ExactNumberInfo, Expr, Ident, IndexColumn, ObjectName, TableConstraint, TimezoneInfo, + ExactNumberInfo, Ident, ObjectName, TableConstraint, TimezoneInfo, }; use super::{ - ParserDBBuilder, StoredTable, column_copy::copy_column, constraint_validity::unvalidated_copies, + ParserDBBuilder, StoredTable, column_copy::copy_column, + constraint_validity::unvalidated_copies, generated_names, }; use crate::{errors::Error, traits::TableLike, utils::is_identity}; @@ -293,16 +294,14 @@ fn inherited_column( let mut copy = copy_column(column, |option| option_passes_down(kind, option)); if kind == ParentKind::PartitionOf { - let unique_stem = format!("_{}_key", copy.name.value); + let unique_columns = generated_names::column_part(©.name); for option in &mut copy.options { - let stem = match option.option { - ColumnOption::PrimaryKey(_) => Some("_pkey"), - ColumnOption::Unique(_) => Some(unique_stem.as_str()), - _ => None, + let (columns, label) = match option.option { + ColumnOption::PrimaryKey(_) => (None, "pkey"), + ColumnOption::Unique(_) => (Some(unique_columns.as_str()), "key"), + _ => continue, }; - if let Some(stem) = stem { - option.name = Some(generated_key_name(builder, child, stem, taken)); - } + option.name = Some(generated_key_name(builder, child, columns, label, taken)); } } @@ -404,22 +403,8 @@ pub(super) fn passes_down(kind: ParentKind, constraint: &TableConstraint) -> boo } } -/// The columns a generated index name is built from, an unnamed expression -/// contributing the same `expr` PostgreSQL uses for one. -fn index_name_columns(columns: &[IndexColumn]) -> Vec<&str> { - columns - .iter() - .map(|column| { - match &column.column.expr { - Expr::Identifier(ident) => ident.value.as_str(), - _ => "expr", - } - }) - .collect() -} - -/// The name PostgreSQL gives an index it creates on its own behalf, with a -/// counter appended until the schema has room for it. +/// The name PostgreSQL gives a partition's copy of a key, which `taken` then +/// holds as well. /// /// A key is also an index, and two indexes in one schema cannot share a name, /// which is why a partition's copy of the root's key cannot simply keep the @@ -427,29 +412,15 @@ fn index_name_columns(columns: &[IndexColumn]) -> Vec<&str> { fn generated_key_name( builder: &ParserDBBuilder, child: &CreateTable, - stem: &str, + columns: Option<&str>, + label: &str, taken: &mut Vec, ) -> Ident { - let table = super::last_str(&child.name); - let base = format!("{table}{stem}"); - let schema = super::table_schema_qualifier(child); - // A generated name follows the quoting of the table it is built from, so - // one built off a quoted name stays reachable under the spelling it needs. - let spell = |value: &str| { - if child.table_name_is_quoted() { Ident::with_quote('"', value) } else { Ident::new(value) } - }; - - let mut candidate = spell(&base); - let mut counter = 0u32; - while super::relation_name_holder(builder, &candidate, schema).is_some() - || taken.iter().any(|held| super::idents_match(held, &candidate)) - { - counter += 1; - candidate = spell(&format!("{base}{counter}")); - } - - taken.push(candidate.clone()); - candidate + let name = generated_names::index_name_on(builder, child, columns, label, |candidate| { + taken.iter().any(|held| super::idents_match(held, candidate)) + }); + taken.push(name.clone()); + name } /// The copy of a parent's constraint the child holds, or [`None`] when the @@ -473,12 +444,12 @@ pub(super) fn received_constraint( let mut copy = constraint.clone(); match &mut copy { TableConstraint::PrimaryKey(primary_key) => { - primary_key.name = Some(generated_key_name(builder, child, "_pkey", taken)); + primary_key.name = Some(generated_key_name(builder, child, None, "pkey", taken)); primary_key.index_name = None; } TableConstraint::Unique(unique) => { - let stem = format!("_{}_key", index_name_columns(&unique.columns).join("_")); - unique.name = Some(generated_key_name(builder, child, &stem, taken)); + unique.name = generated_names::index_columns_part(&unique.columns, &unique.include) + .map(|columns| generated_key_name(builder, child, Some(&columns), "key", taken)); unique.index_name = None; } _ => {} diff --git a/src/utils.rs b/src/utils.rs index 73f9c3b15..a973fd3db 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -14,6 +14,7 @@ pub(crate) use is_identity::identity_generation; pub use is_identity::is_identity; mod common_snake_affix; pub use common_snake_affix::{common_column_name_snake_prefix, common_column_name_snake_suffix}; +pub(crate) mod figured_names; pub mod fingerprint_type_token; pub mod identifier_resolution; pub mod maintenance_trigger_parser; diff --git a/src/utils/figured_names.rs b/src/utils/figured_names.rs new file mode 100644 index 000000000..4dd4d06f4 --- /dev/null +++ b/src/utils/figured_names.rs @@ -0,0 +1,203 @@ +//! The names PostgreSQL's `FigureColname` gives an expression, which it uses +//! for a query output written without an alias and, as `FigureIndexColname`, +//! for an index column that is an expression. + +use alloc::{borrow::ToOwned, string::String}; + +use sqlparser::ast::{ + AccessExpr, ArrayElemTypeDef, BinaryOperator, DataType, ExactNumberInfo, Expr, + GeometricTypeKind, Ident, InTarget, SelectItem, SetExpr, TimezoneInfo, TrimWhereField, +}; + +use crate::utils::object_name::object_name_last_part; + +/// The name PostgreSQL figures for an expression, with whether it is read +/// literally (`true`) or folds as an unquoted identifier does. +pub(crate) enum Figure { + /// A name nothing overrides, such as a column or a function name. + Strong(String, bool), + /// A name an enclosing cast or `CASE` replaces, such as a type name. + Weak(String, bool), + /// An expression PostgreSQL names nothing, such as an operator. + Unnamed, + /// An expression whose name is not modeled. + Unknown, +} + +impl Figure { + fn strong(name: &str) -> Self { + Self::Strong(name.to_owned(), true) + } + + fn ident(ident: &Ident) -> Self { + Self::Strong(ident.value.clone(), ident.quote_style.is_some()) + } +} + +/// Mirrors PostgreSQL's `FigureColname`, answering [`Figure::Unknown`] where +/// the name is not modeled. +pub(crate) fn figure(expression: &Expr) -> Figure { + match expression { + Expr::Identifier(ident) => Figure::ident(ident), + Expr::CompoundIdentifier(parts) => parts.last().map_or(Figure::Unknown, Figure::ident), + Expr::CompoundFieldAccess { root, access_chain } => { + let mut field = None; + for access in access_chain { + if let AccessExpr::Dot(part) = access { + let Expr::Identifier(ident) = part else { + return Figure::Unknown; + }; + field = Some(ident); + } + } + field.map_or_else(|| figure(root), Figure::ident) + } + Expr::Nested(inner) | Expr::Collate { expr: inner, .. } => figure(inner), + Expr::Function(function) => { + object_name_last_part(&function.name) + .map_or(Figure::Unknown, |(name, quoted)| Figure::Strong(name.to_owned(), quoted)) + } + Expr::Cast { expr, data_type, .. } => { + match figure(expr) { + Figure::Weak(..) | Figure::Unnamed => type_figure(data_type), + named => named, + } + } + Expr::TypedString(typed) => type_figure(&typed.data_type), + Expr::Interval(_) => Figure::Weak("interval".to_owned(), true), + Expr::Case { else_result, .. } => { + match else_result.as_deref().map_or(Figure::Unnamed, figure) { + Figure::Weak(..) | Figure::Unnamed => Figure::Weak("case".to_owned(), true), + named => named, + } + } + Expr::Array(_) => Figure::strong("array"), + Expr::Tuple(_) => Figure::strong("row"), + Expr::Exists { negated: false, .. } => Figure::strong("exists"), + Expr::Subquery(query) => first_output_figure(&query.body), + Expr::Extract { .. } => Figure::strong("extract"), + Expr::Ceil { .. } => Figure::strong("ceil"), + Expr::Floor { .. } => Figure::strong("floor"), + Expr::Position { .. } => Figure::strong("position"), + Expr::Overlay { .. } => Figure::strong("overlay"), + Expr::AtTimeZone { .. } => Figure::strong("timezone"), + Expr::Substring { shorthand: true, .. } => Figure::strong("substr"), + Expr::Substring { shorthand: false, .. } => Figure::strong("substring"), + Expr::Trim { trim_where: Some(TrimWhereField::Leading), .. } => Figure::strong("ltrim"), + Expr::Trim { trim_where: Some(TrimWhereField::Trailing), .. } => Figure::strong("rtrim"), + Expr::Trim { .. } => Figure::strong("btrim"), + Expr::BinaryOp { op: BinaryOperator::Overlaps, .. } => Figure::strong("overlaps"), + Expr::In { target, .. } => { + match target { + InTarget::List(_) + | InTarget::Subquery(_) + | InTarget::Unnest(_) + | InTarget::Table { .. } => Figure::Unnamed, + } + } + Expr::Value(_) + | Expr::BinaryOp { .. } + | Expr::UnaryOp { .. } + | Expr::IsFalse(_) + | Expr::IsNotFalse(_) + | Expr::IsTrue(_) + | Expr::IsNotTrue(_) + | Expr::IsNull(_) + | Expr::IsNotNull(_) + | Expr::IsUnknown(_) + | Expr::IsNotUnknown(_) + | Expr::IsDistinctFrom(..) + | Expr::IsNotDistinctFrom(..) + | Expr::Between { .. } + | Expr::Like { .. } + | Expr::ILike { .. } + | Expr::SimilarTo { .. } + | Expr::AnyOp { .. } + | Expr::AllOp { .. } + | Expr::Exists { negated: true, .. } => Figure::Unnamed, + _ => Figure::Unknown, + } +} + +/// The name of the first output of a query body, which names a scalar +/// subquery. +fn first_output_figure(body: &SetExpr) -> Figure { + match body { + SetExpr::Select(select) => { + match select.projection.first() { + Some(SelectItem::ExprWithAlias { alias, .. }) => Figure::ident(alias), + Some(SelectItem::UnnamedExpr(expression)) => { + match figure(expression) { + Figure::Strong(name, quoted) | Figure::Weak(name, quoted) => { + Figure::Strong(name, quoted) + } + Figure::Unnamed => Figure::strong("?column?"), + Figure::Unknown => Figure::Unknown, + } + } + _ => Figure::Unknown, + } + } + SetExpr::Query(query) => first_output_figure(&query.body), + SetExpr::SetOperation { left, .. } => first_output_figure(left), + SetExpr::Values(_) => Figure::strong("column1"), + _ => Figure::Unknown, + } +} + +/// The last part of the name PostgreSQL resolves a written type to, which a +/// cast contributes when its operand names nothing stronger. +fn type_figure(data_type: &DataType) -> Figure { + let name = match data_type { + DataType::Int(_) | DataType::Integer(_) | DataType::Int4(_) => "int4", + DataType::BigInt(_) | DataType::Int8(_) => "int8", + DataType::SmallInt(_) | DataType::Int2(_) => "int2", + DataType::Text => "text", + DataType::Varchar(_) | DataType::CharacterVarying(_) | DataType::CharVarying(_) => { + "varchar" + } + DataType::Numeric(_) | DataType::Decimal(_) | DataType::Dec(_) => "numeric", + DataType::Boolean | DataType::Bool => "bool", + DataType::Float(ExactNumberInfo::Precision(precision)) if *precision <= 24 => "float4", + DataType::Float(ExactNumberInfo::None | ExactNumberInfo::Precision(_)) + | DataType::Float8 + | DataType::DoublePrecision => "float8", + DataType::Real | DataType::Float4 => "float4", + DataType::Date => "date", + DataType::JSON => "json", + DataType::JSONB => "jsonb", + DataType::Char(_) | DataType::Character(_) => "bpchar", + DataType::Bytea => "bytea", + DataType::Uuid => "uuid", + DataType::Interval { .. } => "interval", + DataType::Bit(_) => "bit", + DataType::VarBit(_) | DataType::BitVarying(_) => "varbit", + DataType::Time(_, TimezoneInfo::None | TimezoneInfo::WithoutTimeZone) => "time", + DataType::Time(_, TimezoneInfo::Tz | TimezoneInfo::WithTimeZone) => "timetz", + DataType::Timestamp(_, TimezoneInfo::None | TimezoneInfo::WithoutTimeZone) => "timestamp", + DataType::Timestamp(_, TimezoneInfo::Tz | TimezoneInfo::WithTimeZone) => "timestamptz", + DataType::Regclass => "regclass", + DataType::TsVector => "tsvector", + DataType::TsQuery => "tsquery", + DataType::GeometricType(kind) => { + match kind { + GeometricTypeKind::Point => "point", + GeometricTypeKind::Line => "line", + GeometricTypeKind::LineSegment => "lseg", + GeometricTypeKind::GeometricBox => "box", + GeometricTypeKind::GeometricPath => "path", + GeometricTypeKind::Polygon => "polygon", + GeometricTypeKind::Circle => "circle", + } + } + DataType::Array(ArrayElemTypeDef::SquareBracket(element, _)) => { + return type_figure(element); + } + DataType::Custom(name, _) => { + return object_name_last_part(name) + .map_or(Figure::Unknown, |(name, quoted)| Figure::Weak(name.to_owned(), quoted)); + } + _ => return Figure::Unknown, + }; + Figure::Weak(name.to_owned(), true) +} diff --git a/tests/duplicate_object_names.rs b/tests/duplicate_object_names.rs index a2c503dde..4eca0bc89 100644 --- a/tests/duplicate_object_names.rs +++ b/tests/duplicate_object_names.rs @@ -210,16 +210,20 @@ fn a_repeated_table_name_is_still_a_lookup_conflict() { ); } -/// An index the statement does not name is named by the server, so it contests -/// nothing and any number of them may sit on one table. +/// An index the statement does not name takes the first free name PostgreSQL +/// would give it, so any number of them may sit on one table. #[test] fn unnamed_indexes_never_collide() { - parse( + let db = parse( "CREATE TABLE docs (id INT); CREATE INDEX ON docs (id); CREATE INDEX ON docs (id);", ) - .expect("neither has a name to contest"); + .expect("each takes a free name"); + assert_eq!( + db.indexes().filter_map(IndexLike::name).collect::>(), + ["docs_id_idx", "docs_id_idx1"] + ); } /// `IF NOT EXISTS` skips the statement whole, whatever kind of object holds the diff --git a/tests/generated_index_names.rs b/tests/generated_index_names.rs new file mode 100644 index 000000000..b301b76db --- /dev/null +++ b/tests/generated_index_names.rs @@ -0,0 +1,313 @@ +//! Tests that an index its statement leaves unnamed takes the name PostgreSQL +//! gives it, and is then reached and contested under that name. +#![allow(clippy::expect_used, clippy::panic)] + +use sql_traits::{ + errors::{Error, ObjectKind}, + prelude::*, +}; +use sqlparser::{ + ast::{ObjectName, Statement}, + dialect::PostgreSqlDialect, + parser::Parser, +}; + +fn parse(sql: &str) -> Result { + ParserDB::parse::(sql) +} + +/// The index names a schema ends up with, sorted. +fn index_names(sql: &str) -> Vec> { + let db = parse(sql).expect("schema builds"); + let mut names: Vec<_> = + db.indexes().map(|index| index.name().map(ToString::to_string)).collect(); + names.sort(); + names +} + +fn table<'db>(db: &'db ParserDB, name: &str) -> &'db ::Table { + db.table_by_target(TargetName::new(name, false), IdentifierCase::AsWritten) + .expect("unambiguous lookup") + .expect("table exists") +} + +#[test] +fn an_unnamed_index_takes_the_name_postgresql_gives_it() { + let a61 = "a".repeat(61); + let b63 = "b".repeat(63); + let long = format!( + "CREATE TABLE {a61} ({b63} INT, c INT); + CREATE INDEX ON {a61} ({b63}, c); + CREATE INDEX ON {a61} ({b63}, c);" + ); + // A cut that would split a character stops before it. + let accented = format!("a{}", "é".repeat(30)); + let multibyte = format!( + "CREATE TABLE {accented} (bbbbbbbbbb INT); CREATE INDEX ON {accented} (bbbbbbbbbb);" + ); + let cases: [(&str, &[&str]); 8] = [ + ( + "CREATE TABLE t (s TEXT); + CREATE INDEX ON t (s); + CREATE UNIQUE INDEX ON t (s);", + &["t_s_idx", "t_s_idx1"], + ), + ( + "CREATE TABLE t (s TEXT); + CREATE INDEX ON t ((s::varchar)); + CREATE INDEX ON t ((CAST(s AS text))); + CREATE INDEX ON t ((s COLLATE \"C\"));", + &["t_s_idx", "t_s_idx1", "t_s_idx2"], + ), + ( + "CREATE TABLE t (s TEXT, n INT); + CREATE INDEX ON t (lower(s)); + CREATE INDEX ON t (coalesce(s, 'a')); + CREATE INDEX ON t ((CASE WHEN n > 0 THEN 1 END)); + CREATE INDEX ON t ((s || 'x')); + CREATE INDEX ON t ((-n)); + CREATE INDEX ON t ((n + 1), n); + CREATE INDEX ON t (s, s); + CREATE INDEX ON t (lower(s), lower(s)); + CREATE INDEX ON t (((n + 1)::int)); + CREATE INDEX ON t (n) INCLUDE (s);", + &[ + "t_lower_idx", + "t_coalesce_idx", + "t_case_idx", + "t_expr_idx", + "t_expr_idx1", + "t_expr_n_idx", + "t_s_s1_idx", + "t_lower_lower1_idx", + "t_int4_idx", + "t_n_s_idx", + ], + ), + ( + "CREATE TABLE t (\"Mixed Col\" INT); + CREATE INDEX ON t (\"Mixed Col\"); + CREATE TABLE \"Tab\" (a INT); + CREATE INDEX ON \"Tab\" (a); + CREATE TABLE UP (S TEXT); + CREATE INDEX ON UP (S);", + &["t_Mixed Col_idx", "Tab_a_idx", "up_s_idx"], + ), + ( + "CREATE TABLE t (n INT); + CREATE TABLE t_n_idx (x INT); + CREATE VIEW t_n_idx1 AS SELECT 1 AS one; + CREATE INDEX ON t (n);", + &["t_n_idx2"], + ), + ( + "CREATE TABLE u (s TEXT); + CREATE INDEX ON u (s); + CREATE INDEX ON u (s); + DROP INDEX u_s_idx; + CREATE INDEX ON u (s);", + &["u_s_idx1", "u_s_idx"], + ), + ( + &long, + &[ + &format!("{}_{}_idx", &a61[..29], &b63[..29]), + &format!("{}_{}_idx1", &a61[..29], &b63[..28]), + ], + ), + (&multibyte, &[&format!("a{}_bbbbbbbbbb_idx", "é".repeat(23))]), + ]; + + for (sql, expected) in cases { + let mut expected: Vec> = + expected.iter().map(|name| Some((*name).to_string())).collect(); + expected.sort(); + assert_eq!(index_names(sql), expected, "for {sql}"); + } +} + +#[test] +fn an_index_counts_the_names_in_its_own_tables_schema() { + let db = parse( + "CREATE SCHEMA other; + CREATE TABLE t (s TEXT); + CREATE TABLE other.t (s TEXT); + CREATE INDEX ON t (s); + CREATE INDEX ON other.t (s); + SET search_path TO other; + CREATE INDEX ON t (s);", + ) + .expect("schema builds"); + + let mut placed: Vec<(Option<&str>, Option<&str>)> = db + .indexes() + .map(|index| (IndexLike::table(index, &db).table_schema(), index.name())) + .collect(); + placed.sort_unstable(); + assert_eq!( + placed, + [ + (None, Some("t_s_idx")), + (Some("other"), Some("t_s_idx")), + (Some("other"), Some("t_s_idx1")), + ] + ); +} + +#[test] +fn a_generated_name_is_reached_and_contested_like_a_written_one() { + const INDEXED: &str = "CREATE TABLE t (s TEXT); CREATE INDEX ON t (s);"; + const QUOTED: &str = "CREATE TABLE \"Tab\" (a INT); CREATE INDEX ON \"Tab\" (a);"; + + let dropped = parse(&format!("{INDEXED} DROP INDEX t_s_idx;")).expect("the index is found"); + assert_eq!(dropped.indexes().count(), 0); + + let renamed = parse(&format!("{INDEXED} ALTER INDEX T_S_IDX RENAME TO kept;")) + .expect("the unquoted spelling folds onto the generated name"); + assert_eq!(renamed.indexes().filter_map(IndexLike::name).collect::>(), ["kept"]); + + let contested = parse(&format!("{INDEXED} CREATE TABLE t_s_idx (x INT);")); + assert!( + matches!( + &contested, + Err(Error::RelationNameAlreadyTaken { + object_kind: ObjectKind::Table, + conflicting_kind: ObjectKind::Index, + .. + }) + ), + "got {contested:?}" + ); + + assert!( + parse(&format!("{QUOTED} DROP INDEX Tab_a_idx;")).is_err(), + "the folded spelling misses a name holding capitals" + ); + parse(&format!("{QUOTED} DROP INDEX \"Tab_a_idx\";")).expect("the quoted spelling is found"); +} + +#[test] +fn an_index_over_an_expression_whose_name_is_not_modeled_stays_unnamed() { + assert_eq!( + index_names( + "CREATE TABLE t (s TEXT); + CREATE INDEX ON t (((s || 'x')::nvarchar)); + CREATE INDEX ON t (((s || 'x')::nvarchar));" + ), + [None, None] + ); +} + +#[test] +fn naming_an_index_as_create_index_name_answers_stores_the_same_index() { + let statements = Parser::parse_sql( + &PostgreSqlDialect {}, + "CREATE TABLE t (s TEXT, n INT); + CREATE TABLE t_s_idx (x INT); + CREATE INDEX ON t (s); + CREATE INDEX ON t (lower(s), n); + CREATE INDEX ON t (((s || 'x')::nvarchar)); + CREATE INDEX ON t (s);", + ) + .expect("SQL parses"); + + let mut unnamed = ParserDBIngestor::new::("catalog".to_owned()); + let mut named = ParserDBIngestor::new::("catalog".to_owned()); + let mut answers = Vec::new(); + for statement in statements { + let mut written = statement.clone(); + if let Statement::CreateIndex(create_index) = &mut written { + let name = named.create_index_name(create_index).expect("the table exists"); + answers.push(name.as_ref().map(|name| name.value.clone())); + create_index.name = name.map(|name| ObjectName::from(vec![name])); + } + unnamed = unnamed.apply_statement(statement).expect("unnamed statement applies"); + named = named.apply_statement(written).expect("named statement applies"); + } + + assert_eq!( + answers, + [ + Some("t_s_idx1".to_owned()), + Some("t_lower_n_idx".to_owned()), + None, + Some("t_s_idx2".to_owned()) + ] + ); + let stored = |ingestor: ParserDBIngestor| { + ingestor.finish().indexes().map(|index| index.attribute().clone()).collect::>() + }; + assert_eq!(stored(unnamed), stored(named)); +} + +#[test] +fn create_index_name_answers_a_written_name_and_refuses_a_missing_table() { + let ingestor = ParserDB::parse::("CREATE TABLE t (s TEXT);") + .expect("schema builds") + .into_ingestor(); + let create_index = |sql| { + match Parser::parse_sql(&PostgreSqlDialect {}, sql).expect("SQL parses").remove(0) { + Statement::CreateIndex(create_index) => create_index, + other => panic!("expected CREATE INDEX, got {other}"), + } + }; + + let written = ingestor + .create_index_name(&create_index("CREATE INDEX \"Mine\" ON t (s);")) + .expect("a written name is answered"); + assert_eq!( + written.map(|name| (name.value, name.quote_style)), + Some(("Mine".to_owned(), Some('"'))) + ); + + let missing = ingestor.create_index_name(&create_index("CREATE INDEX ON absent (s);")); + assert!(matches!(missing, Err(Error::TableNotFoundForIndex { .. })), "got {missing:?}"); + let applied = parse("CREATE INDEX ON absent (s);"); + assert!(matches!(applied, Err(Error::TableNotFoundForIndex { .. })), "got {applied:?}"); +} + +#[test] +fn a_partition_key_name_is_cut_to_fit_and_counted_in_the_schema() { + let a61 = "a".repeat(61); + let b63 = "b".repeat(63); + let db = parse(&format!( + "CREATE TABLE r ({b63} INT PRIMARY KEY, c INT, UNIQUE ({b63}, c)) PARTITION BY RANGE ({b63}); + CREATE TABLE {a61} PARTITION OF r FOR VALUES FROM (1) TO (2); + CREATE TABLE {a61}2 PARTITION OF r FOR VALUES FROM (2) TO (3);" + )) + .expect("schema builds"); + + let key_names = |partition: &str| { + let mut names: Vec = table(&db, partition) + .unique_indices(&db) + .expect("in database") + .filter_map(|index| IndexLike::name(index).map(ToString::to_string)) + .collect(); + names.sort(); + names + }; + assert_eq!( + key_names(&a61), + [format!("{}_{}_key", &a61[..29], &b63[..29]), format!("{}_pkey", &a61[..58])] + ); + assert_eq!( + key_names(&format!("{a61}2")), + [format!("{}_{}_key1", &a61[..29], &b63[..28]), format!("{}_pkey1", &a61[..57])] + ); +} + +#[test] +fn a_partition_names_its_copy_of_an_inline_unique_key_after_the_column() { + let db = parse( + "CREATE TABLE r (A INT UNIQUE) PARTITION BY RANGE (A); + CREATE TABLE p PARTITION OF r FOR VALUES FROM (1) TO (2);", + ) + .expect("schema builds"); + + let names: Vec<_> = table(&db, "p") + .unique_indices(&db) + .expect("in database") + .filter_map(|index| IndexLike::name(index).map(ToString::to_string)) + .collect(); + assert_eq!(names, ["p_a_key"]); +} diff --git a/tests/object_kind_reporting.rs b/tests/object_kind_reporting.rs index b28bec4c4..73dc448b6 100644 --- a/tests/object_kind_reporting.rs +++ b/tests/object_kind_reporting.rs @@ -180,12 +180,13 @@ fn a_policy_reports_itself_by_name() { assert_eq!(policy.check_functions(&other).err(), Some(ObjectKind::Policy.not_in_database("p"))); } -/// `CREATE INDEX` without a name is valid, so the anonymous branch is reachable -/// for an index too, not only for a constraint. +/// An index over an expression whose name is not modeled stays unnamed, so the +/// anonymous branch is reachable for an index too, not only for a constraint. #[test] fn an_anonymous_index_reports_its_table() { let host = ParserDB::parse::( - "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT); CREATE INDEX ON t (name);", + "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT); + CREATE INDEX ON t (((name || 'x')::nvarchar));", ) .expect("host schema builds"); let other = ParserDB::parse::(OTHER).expect("other schema builds"); diff --git a/tests/output_names.rs b/tests/output_names.rs index 11012e9f6..bdc2d86c4 100644 --- a/tests/output_names.rs +++ b/tests/output_names.rs @@ -288,6 +288,17 @@ fn unaliased_expressions_carry_postgres_implicit_names() { ("'2020-01-01'::timestamp", "\"timestamp\""), ("'2020-01-01'::timestamptz", "timestamptz"), ("'a'::name", "\"name\""), + ("'a'::tsvector", "tsvector"), + ("'pg_class'::regclass", "regclass"), + ("'(1,2)'::point", "point"), + ("'a'::tsquery", "tsquery"), + ("point '(1,2)'", "point"), + ("line '{1,2,3}'", "line"), + ("lseg '[(1,1),(2,2)]'", "lseg"), + ("box '((1,1),(2,2))'", "box"), + ("path '[(1,1),(2,2)]'", "path"), + ("polygon '((1,1),(2,2),(3,1))'", "polygon"), + ("circle '<(1,1),1>'", "circle"), ] { let sql = format!("SELECT {projection} FROM b ORDER BY {key}"); let resolved = order_by(&sql).unwrap(); @@ -308,7 +319,8 @@ fn unknown_output_names_answer_opaque() { ); assert_eq!(order_by("SELECT 'a' IS NORMALIZED, id AS x FROM b ORDER BY x").unwrap(), "b.id"); for projection in [ - "'a'::tsvector", + "'a'::nvarchar", + "(b.id).f()", "(WITH c AS (SELECT 1) TABLE b)", "(SELECT * FROM b)", "(SELECT 'a' IS NORMALIZED)", diff --git a/tests/query_outputs.rs b/tests/query_outputs.rs index bc424c3f4..40738de9a 100644 --- a/tests/query_outputs.rs +++ b/tests/query_outputs.rs @@ -257,7 +257,7 @@ fn unexpanded_wildcards_and_unmodeled_names_stay_unknown() { names("SELECT b.id, *, 1 + 1, b.id AS again FROM b, generate_series(1, 2) AS g"), ["id", "*", "\"?column?\"", "again"] ); - assert_eq!(names("SELECT '1'::tsvector FROM b"), ["?"]); + assert_eq!(names("SELECT '1'::nvarchar FROM b"), ["?"]); } #[test]