use ahash::{AHashMap, AHashSet};
use sqlparser::ast::{
self, BinaryOperator, Expr, GroupByExpr, ObjectName, Query, SelectItem, SetExpr, Statement, TableFactor, Value,
};
use crate::dialect::SqlDialect;
use crate::errors::{ErrorCode, ScytheError};
use super::types::{AnalyzedColumn, AnalyzedParam, Scope, TypeInfo};
pub(super) const UNNAMED_COLUMN: &str = "unknown";
pub(super) const AMBIGUOUS_COLUMN_MARKER: &str = "__ambiguous__:";
pub(super) const UNKNOWN_COLUMN_MARKER: &str = "__unknown_col__:";
pub(super) const UNKNOWN_FUNCTION_MARKER: &str = "__unknown_func__:";
pub(super) const AMBIGUOUS_FUNCTION_MARKER: &str = "__ambiguous_func__:";
pub(super) const UNTYPEABLE_RECORD_MARKER: &str = "__untypeable_record__:";
pub(super) const UNTYPEABLE_CATALOG_RECORD_MARKER: &str = "__untypeable_catalog_record__:";
pub(super) const UNTYPEABLE_ROW_MARKER: &str = "__untypeable_row__:";
pub(super) const UNRESOLVED_EXPR_MARKER: &str = "__unresolved_expr__:";
fn find_marker_payload<'a>(neutral_type: &'a str, marker: &str) -> Option<&'a str> {
let start = neutral_type.find(marker)? + marker.len();
let end = neutral_type[start..]
.find(['<', '>'])
.map(|rel| start + rel)
.unwrap_or(neutral_type.len());
Some(&neutral_type[start..end])
}
pub(super) fn unresolved_type_error(neutral_type: &str, subject: &str, column_name: &str) -> Option<ScytheError> {
if let Some(name) = find_marker_payload(neutral_type, AMBIGUOUS_COLUMN_MARKER) {
return Some(ScytheError::ambiguous_column(name));
}
if let Some(name) = find_marker_payload(neutral_type, UNKNOWN_COLUMN_MARKER) {
return Some(ScytheError::unknown_column(name));
}
if let Some(name) = find_marker_payload(neutral_type, UNKNOWN_FUNCTION_MARKER) {
return Some(ScytheError::unknown_function(name));
}
if let Some(name) = find_marker_payload(neutral_type, AMBIGUOUS_FUNCTION_MARKER) {
return Some(ScytheError::ambiguous_function(name));
}
if let Some(function) = find_marker_payload(neutral_type, UNTYPEABLE_RECORD_MARKER) {
return Some(ScytheError::untypeable_record(subject, column_name, function));
}
if let Some(function) = find_marker_payload(neutral_type, UNTYPEABLE_CATALOG_RECORD_MARKER) {
return Some(ScytheError::new(
ErrorCode::UnresolvedType,
format!(
"{subject} \"{column_name}\": function {function}(...) returns PostgreSQL's anonymous `record` type; \
declare a named composite return type or explicit RETURNS TABLE/OUT columns so scythe can resolve \
the result fields"
),
));
}
if neutral_type.contains(UNTYPEABLE_ROW_MARKER) {
return Some(ScytheError::untypeable_row_constructor(subject, column_name));
}
if neutral_type.contains(UNRESOLVED_EXPR_MARKER) {
return Some(ScytheError::unresolved_expression_type(subject, column_name));
}
None
}
pub(super) fn reject_unresolved_columns(columns: &[AnalyzedColumn]) -> Result<(), ScytheError> {
for col in columns {
if let Some(err) = unresolved_type_error(&col.neutral_type, "column", &col.name) {
return Err(err);
}
}
Ok(())
}
pub(super) fn reject_unresolved_params(params: &[AnalyzedParam]) -> Result<(), ScytheError> {
for param in params {
if let Some(err) = unresolved_type_error(¶m.neutral_type, "parameter", ¶m.name) {
return Err(err);
}
}
Ok(())
}
pub(super) fn disambiguate_duplicate_names<'a>(names: impl IntoIterator<Item = &'a mut String>) {
let mut names: Vec<&'a mut String> = names.into_iter().collect();
let mut occurrences: AHashMap<String, usize> = AHashMap::new();
for name in &names {
*occurrences.entry((*name).clone()).or_insert(0) += 1;
}
let mut taken: AHashSet<String> = occurrences.keys().cloned().collect();
let mut next_suffix: AHashMap<String, usize> = AHashMap::new();
for name in &mut names {
if occurrences.get(name.as_str()).copied().unwrap_or(0) < 2 {
continue;
}
let base = (**name).clone();
let suffix = next_suffix.entry(base.clone()).or_insert(0);
let renamed = loop {
*suffix += 1;
let candidate = format!("{base}_{suffix}");
if taken.insert(candidate.clone()) {
break candidate;
}
};
**name = renamed;
}
debug_assert!(
{
let mut unique: AHashSet<&str> = AHashSet::new();
names.iter().all(|name| unique.insert(name.as_str()))
},
"disambiguate_duplicate_names must leave every name unique"
);
}
pub(super) fn value_is_placeholder(vws: &ast::ValueWithSpan) -> Option<&str> {
match &vws.value {
Value::Placeholder(s) => Some(s.as_str()),
_ => None,
}
}
pub(super) fn value_is_string(vws: &ast::ValueWithSpan) -> bool {
matches!(&vws.value, Value::SingleQuotedString(_) | Value::DoubleQuotedString(_))
}
pub(super) fn value_is_boolean(vws: &ast::ValueWithSpan) -> bool {
matches!(&vws.value, Value::Boolean(_))
}
pub(super) fn value_is_null(vws: &ast::ValueWithSpan) -> bool {
matches!(&vws.value, Value::Null)
}
pub(super) fn object_name_to_string(name: &ObjectName) -> String {
name.0
.iter()
.map(|part| match part {
ast::ObjectNamePart::Identifier(ident) => ident.value.clone(),
_ => String::new(),
})
.collect::<Vec<_>>()
.join(".")
}
pub(super) fn table_factor_name(tf: &TableFactor) -> String {
match tf {
TableFactor::Table { name, .. } => object_name_to_string(name).to_lowercase(),
_ => String::new(),
}
}
pub(super) fn assignment_target_name(target: &ast::AssignmentTarget) -> String {
match target {
ast::AssignmentTarget::ColumnName(name) => object_name_to_string(name).to_lowercase(),
ast::AssignmentTarget::Tuple(names) => names
.first()
.map(|n| object_name_to_string(n).to_lowercase())
.unwrap_or_default(),
}
}
pub(super) fn expr_to_name(expr: &Expr) -> String {
match expr {
Expr::Identifier(ident) => {
if ident.quote_style.is_some() {
ident.value.clone()
} else {
ident.value.to_lowercase()
}
}
Expr::CompoundIdentifier(parts) => parts
.last()
.map(|i| i.value.to_lowercase())
.unwrap_or_else(|| "unknown".to_string()),
Expr::Function(func) => object_name_to_string(&func.name).to_lowercase(),
Expr::Cast { expr: inner, .. } => expr_to_name(inner),
Expr::Nested(inner) => expr_to_name(inner),
Expr::UnaryOp { expr: inner, .. } => expr_to_name(inner),
Expr::Case { .. } => "case".to_string(),
Expr::Subquery(_) => "subquery".to_string(),
Expr::Value(vws) => {
if let Some(p) = value_is_placeholder(vws)
&& let Some(pos) = parse_placeholder(p)
{
return format!("p{}", pos);
}
"unknown".to_string()
}
Expr::BinaryOp { left, .. } => expr_to_name(left),
Expr::CompoundFieldAccess { access_chain, .. } => {
if let Some(last) = access_chain.last()
&& let ast::AccessExpr::Dot(inner) = last
{
return expr_to_name(inner);
}
"unknown".to_string()
}
_ => "unknown".to_string(),
}
}
pub(super) fn detect_select_star_source(stmt: &Statement) -> Option<String> {
if let Statement::Query(query) = stmt
&& let SetExpr::Select(select) = query.body.as_ref()
{
let is_star = select.projection.len() == 1 && matches!(select.projection[0], SelectItem::Wildcard(_));
if is_star
&& select.from.len() == 1
&& select.from[0].joins.is_empty()
&& let TableFactor::Table { name, .. } = &select.from[0].relation
{
let table_name = object_name_to_string(name).to_lowercase();
return Some(table_name);
}
}
None
}
pub(super) fn parse_placeholder(s: &str) -> Option<i64> {
if s == "?" {
return None;
}
s.strip_prefix('$')?.parse::<i64>().ok()
}
pub(super) fn is_positional_placeholder(s: &str) -> bool {
s == "?"
}
pub(super) fn derive_param_name_from_comparison(
col_name: &str,
col_side: &Expr,
_param_side: &Expr,
op: Option<&BinaryOperator>,
) -> String {
if let Expr::Function(_) = col_side
&& let Some(op) = op
{
match op {
BinaryOperator::Gt | BinaryOperator::GtEq => {
return format!("min_{}", col_name);
}
BinaryOperator::Lt | BinaryOperator::LtEq => {
return format!("max_{}", col_name);
}
_ => {}
}
}
col_name.to_string()
}
pub(super) fn is_not_null_guard(condition: &Expr, result: &Expr, scope: &Scope) -> bool {
match condition {
Expr::IsNotNull(inner) => same_column_identity(inner, result, scope),
_ => false,
}
}
fn expr_to_qualified_name(expr: &Expr) -> (Option<String>, String) {
match expr {
Expr::CompoundIdentifier(parts) if parts.len() >= 2 => {
let qualifier = parts[parts.len() - 2].value.to_lowercase();
let name = parts[parts.len() - 1].value.to_lowercase();
(Some(qualifier), name)
}
_ => (None, expr_to_name(expr)),
}
}
fn same_column_identity(a: &Expr, b: &Expr, scope: &Scope) -> bool {
let (qual_a, name_a) = expr_to_qualified_name(a);
let (qual_b, name_b) = expr_to_qualified_name(b);
if name_a != name_b {
return false;
}
match (qual_a, qual_b) {
(Some(qa), Some(qb)) => qa == qb,
_ => scope.sources.len() <= 1,
}
}
pub(super) fn is_literal(expr: &Expr) -> bool {
match expr {
Expr::Value(vws) => {
matches!(
&vws.value,
Value::Number(_, _) | Value::SingleQuotedString(_) | Value::DoubleQuotedString(_) | Value::Boolean(_)
)
}
_ => false,
}
}
pub(super) fn value_is_null_in_dialect(vws: &ast::ValueWithSpan, dialect: SqlDialect) -> bool {
if dialect != SqlDialect::Oracle {
return false;
}
matches!(
&vws.value,
Value::SingleQuotedString(s) | Value::NationalStringLiteral(s) if s.is_empty()
)
}
pub(super) fn is_non_null_literal(expr: &Expr, dialect: SqlDialect) -> bool {
is_literal(expr) && !matches!(expr, Expr::Value(vws) if value_is_null_in_dialect(vws, dialect))
}
pub(super) fn function_has_null_treatment(func: &ast::Function) -> bool {
if func.null_treatment.is_some() {
return true;
}
match &func.args {
ast::FunctionArguments::List(arg_list) => arg_list
.clauses
.iter()
.any(|clause| matches!(clause, ast::FunctionArgumentClause::IgnoreOrRespectNulls(_))),
_ => false,
}
}
pub(super) fn pluralize(name: &str) -> String {
if name.ends_with('s') || name.ends_with('x') || name.ends_with("sh") || name.ends_with("ch") {
format!("{}es", name)
} else if name.ends_with('y') && !name.ends_with("ey") && !name.ends_with("ay") && !name.ends_with("oy") {
format!("{}ies", &name[..name.len() - 1])
} else {
format!("{}s", name)
}
}
pub(super) fn is_integer_type(t: &str) -> bool {
matches!(t, "int16" | "int32" | "int64")
}
pub(super) fn is_float_type(t: &str) -> bool {
matches!(t, "float32" | "float64")
}
pub(super) fn sum_result_type(neutral: &str) -> String {
if is_integer_type(neutral) {
if neutral == "int64" {
"decimal".to_string()
} else {
"int64".to_string()
}
} else if is_float_type(neutral) {
neutral.to_string()
} else {
"decimal".to_string()
}
}
pub(super) fn avg_result_type(neutral: &str) -> String {
if is_float_type(neutral) {
"float64".to_string()
} else {
"decimal".to_string()
}
}
pub(super) fn is_comparable_types(a: &str, b: &str) -> bool {
let numeric = ["int16", "int32", "int64", "float32", "float64", "decimal"];
if numeric.contains(&a) && numeric.contains(&b) {
return true;
}
if a == "string" && b == "string" {
return true;
}
let temporal = ["date", "datetime", "datetime_tz", "time", "time_tz"];
if temporal.contains(&a) && temporal.contains(&b) {
return true;
}
if (a.starts_with("enum::") && b == "string") || (b.starts_with("enum::") && a == "string") {
return true;
}
if a.starts_with("enum::") && b.starts_with("enum::") {
return true;
}
false
}
pub(super) fn neutral_to_sql_label(neutral: &str) -> &str {
match neutral {
"int16" => "smallint",
"int32" => "integer",
"int64" => "bigint",
"float32" => "real",
"float64" => "double precision",
"decimal" => "numeric",
"string" => "text",
"bool" => "boolean",
"bytes" => "bytea",
"date" => "date",
"time" => "time",
"time_tz" => "timetz",
"datetime" => "timestamp",
"datetime_tz" => "timestamptz",
"interval" => "interval",
"json" => "json",
"uuid" => "uuid",
_ => neutral,
}
}
pub(super) fn is_aggregate_function_name(name: &str) -> bool {
matches!(
name,
"count"
| "sum"
| "avg"
| "min"
| "max"
| "string_agg"
| "array_agg"
| "bool_and"
| "bool_or"
| "every"
| "json_agg"
| "jsonb_agg"
| "json_object_agg"
| "jsonb_object_agg"
)
}
fn unwrap_nested(expr: &Expr) -> &Expr {
match expr {
Expr::Nested(inner) => unwrap_nested(inner),
_ => expr,
}
}
pub(super) fn is_single_row_aggregate_query(query: &Query) -> bool {
let SetExpr::Select(select) = query.body.as_ref() else {
return false;
};
if select.having.is_some() {
return false;
}
if !matches!(&select.group_by, GroupByExpr::Expressions(exprs, _) if exprs.is_empty()) {
return false;
}
if select.projection.len() != 1 {
return false;
}
let expr = match &select.projection[0] {
SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => e,
_ => return false,
};
match unwrap_nested(expr) {
Expr::Function(func) if func.over.is_none() => {
is_aggregate_function_name(&object_name_to_string(&func.name).to_lowercase())
}
_ => false,
}
}
pub(super) fn find_nested_placeholder_id(neutral_type: &str) -> Option<u32> {
const MARKER: &str = "__nested__";
let start = neutral_type.find(MARKER)?;
let digits_start = start + MARKER.len();
let digits_end = neutral_type[digits_start..]
.find(|c: char| !c.is_ascii_digit())
.map(|rel| digits_start + rel)
.unwrap_or(neutral_type.len());
if digits_end == digits_start {
return None;
}
neutral_type[digits_start..digits_end].parse::<u32>().ok()
}
pub(super) fn widen_type(a: &str, b: &str) -> String {
if a == b {
return a.to_string();
}
if a == "unknown" {
return b.to_string();
}
if b == "unknown" {
return a.to_string();
}
let int_rank = |t: &str| -> Option<u8> {
match t {
"int16" => Some(0),
"int32" => Some(1),
"int64" => Some(2),
_ => None,
}
};
let float_rank = |t: &str| -> Option<u8> {
match t {
"float32" => Some(0),
"float64" => Some(1),
_ => None,
}
};
let is_decimal = |t: &str| t == "decimal";
if let (Some(ra), Some(rb)) = (int_rank(a), int_rank(b)) {
return if ra >= rb { a.to_string() } else { b.to_string() };
}
if let (Some(ra), Some(rb)) = (float_rank(a), float_rank(b)) {
return if ra >= rb { a.to_string() } else { b.to_string() };
}
if (is_decimal(a) && int_rank(b).is_some()) || (int_rank(a).is_some() && is_decimal(b)) {
return "decimal".to_string();
}
if (int_rank(a).is_some() || is_decimal(a)) && float_rank(b).is_some() {
return "float64".to_string();
}
if float_rank(a).is_some() && (int_rank(b).is_some() || is_decimal(b)) {
return "float64".to_string();
}
a.to_string()
}
pub(super) fn widen_neutral_type(a: &str, b: &str) -> String {
widen_type(a, b)
}
pub(super) fn widen_type_info(a: &TypeInfo, b: &TypeInfo) -> TypeInfo {
TypeInfo::new(
widen_neutral_type(&a.neutral_type, &b.neutral_type),
a.nullable || b.nullable,
)
}
pub(super) fn literal_number_neutral_type(text: &str) -> &'static str {
if text.contains(['e', 'E']) {
return "float64";
}
if text.contains('.') {
return "decimal";
}
match text.parse::<i64>() {
Ok(v) if (i32::MIN as i64..=i32::MAX as i64).contains(&v) => "int32",
Ok(_) => "int64",
Err(_) => "decimal",
}
}
pub(super) fn extract_result_type(dialect: SqlDialect) -> &'static str {
match dialect {
SqlDialect::MySQL => "int64",
SqlDialect::PostgreSQL | SqlDialect::Oracle | SqlDialect::Snowflake => "decimal",
SqlDialect::SQLite | SqlDialect::MsSql => "float64",
}
}
pub(super) fn round_result_type(input_neutral_type: &str) -> String {
if is_float_type(input_neutral_type) {
input_neutral_type.to_string()
} else {
"decimal".to_string()
}
}
pub(super) fn function_arg_exprs(args: &[ast::FunctionArg]) -> Vec<Expr> {
args.iter()
.filter_map(|arg| match arg {
ast::FunctionArg::Unnamed(ast::FunctionArgExpr::Expr(e))
| ast::FunctionArg::Named {
arg: ast::FunctionArgExpr::Expr(e),
..
} => Some(e.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::super::types::{ScopeColumn, ScopeSource};
use super::*;
use sqlparser::ast::{Ident, ObjectNamePart, ValueWithSpan};
use sqlparser::tokenizer::Span;
fn single_source_scope() -> Scope {
Scope {
sources: vec![ScopeSource {
alias: "t".to_string(),
table_name: "t".to_string(),
columns: vec![ScopeColumn::new("bio", "string", true)],
nullable_from_join: false,
}],
}
}
fn two_source_scope() -> Scope {
Scope {
sources: vec![
ScopeSource {
alias: "u".to_string(),
table_name: "users".to_string(),
columns: vec![ScopeColumn::new("deleted_at", "datetime_tz", true)],
nullable_from_join: false,
},
ScopeSource {
alias: "o".to_string(),
table_name: "orders".to_string(),
columns: vec![ScopeColumn::new("deleted_at", "datetime_tz", true)],
nullable_from_join: false,
},
],
}
}
fn compound(qualifier: &str, name: &str) -> Expr {
Expr::CompoundIdentifier(vec![Ident::new(qualifier), Ident::new(name)])
}
#[test]
fn test_widen_type_same() {
assert_eq!(widen_type("int32", "int32"), "int32");
assert_eq!(widen_type("float64", "float64"), "float64");
assert_eq!(widen_type("string", "string"), "string");
}
#[test]
fn test_widen_type_integer_widening() {
assert_eq!(widen_type("int16", "int32"), "int32");
assert_eq!(widen_type("int16", "int64"), "int64");
assert_eq!(widen_type("int32", "int64"), "int64");
assert_eq!(widen_type("int64", "int16"), "int64");
assert_eq!(widen_type("int32", "int16"), "int32");
}
#[test]
fn test_widen_type_float_widening() {
assert_eq!(widen_type("float32", "float64"), "float64");
assert_eq!(widen_type("float64", "float32"), "float64");
}
#[test]
fn test_widen_type_int_float_mix() {
assert_eq!(widen_type("int32", "float64"), "float64");
assert_eq!(widen_type("int64", "float32"), "float64");
assert_eq!(widen_type("float32", "int16"), "float64");
assert_eq!(widen_type("float64", "int64"), "float64");
}
#[test]
fn test_widen_type_default_fallback() {
assert_eq!(widen_type("string", "int32"), "string");
assert_eq!(widen_type("bool", "string"), "bool");
}
#[test]
fn test_widen_type_decimal_vs_int_widens_to_decimal() {
assert_eq!(widen_type("int64", "decimal"), "decimal");
assert_eq!(widen_type("decimal", "int64"), "decimal");
assert_eq!(widen_type("int16", "decimal"), "decimal");
assert_eq!(widen_type("decimal", "int32"), "decimal");
}
#[test]
fn test_widen_type_decimal_vs_float_widens_to_float64() {
assert_eq!(widen_type("decimal", "float64"), "float64");
assert_eq!(widen_type("float64", "decimal"), "float64");
assert_eq!(widen_type("decimal", "float32"), "float64");
assert_eq!(widen_type("float32", "decimal"), "float64");
}
#[test]
fn test_widen_type_is_symmetric_for_supported_types() {
let types = ["unknown", "int16", "int32", "int64", "decimal", "float32", "float64"];
for a in types {
for b in types {
assert_eq!(
widen_type(a, b),
widen_type(b, a),
"widening {a} with {b} must not depend on operand order"
);
}
}
}
#[test]
fn test_widen_neutral_type_unknown_absorbed() {
assert_eq!(widen_neutral_type("unknown", "int32"), "int32");
assert_eq!(widen_neutral_type("int32", "unknown"), "int32");
assert_eq!(widen_neutral_type("unknown", "unknown"), "unknown");
}
#[test]
fn test_widen_neutral_type_delegates_to_widen_type() {
assert_eq!(widen_neutral_type("int32", "decimal"), "decimal");
assert_eq!(widen_neutral_type("string", "string"), "string");
}
#[test]
fn test_widen_type_info_ors_nullability() {
let a = TypeInfo::new("int32", false);
let b = TypeInfo::new("decimal", true);
let widened = widen_type_info(&a, &b);
assert_eq!(widened.neutral_type, "decimal");
assert!(widened.nullable);
}
#[test]
fn test_literal_number_neutral_type_small_integer_is_int32() {
assert_eq!(literal_number_neutral_type("1"), "int32");
assert_eq!(literal_number_neutral_type("42"), "int32");
assert_eq!(literal_number_neutral_type("-2147483648"), "int32");
assert_eq!(literal_number_neutral_type("2147483647"), "int32");
}
#[test]
fn test_literal_number_neutral_type_large_integer_is_int64() {
assert_eq!(literal_number_neutral_type("2147483648"), "int64");
assert_eq!(literal_number_neutral_type("9223372036854775807"), "int64");
}
#[test]
fn test_literal_number_neutral_type_overflowing_i64_is_decimal() {
assert_eq!(literal_number_neutral_type("99999999999999999999999999999"), "decimal");
}
#[test]
fn test_literal_number_neutral_type_decimal() {
assert_eq!(literal_number_neutral_type("0.5"), "decimal");
assert_eq!(literal_number_neutral_type("1.0"), "decimal");
}
#[test]
fn test_literal_number_neutral_type_exponent() {
assert_eq!(literal_number_neutral_type("1e10"), "float64");
assert_eq!(literal_number_neutral_type("1.5E-3"), "float64");
}
#[test]
fn test_extract_result_type_postgresql_is_decimal() {
assert_eq!(extract_result_type(SqlDialect::PostgreSQL), "decimal");
}
#[test]
fn test_extract_result_type_mysql_is_int64() {
assert_eq!(extract_result_type(SqlDialect::MySQL), "int64");
}
#[test]
fn test_extract_result_type_sqlite_and_mssql_keep_float64_default() {
assert_eq!(extract_result_type(SqlDialect::SQLite), "float64");
assert_eq!(extract_result_type(SqlDialect::MsSql), "float64");
}
#[test]
fn test_round_result_type_preserves_float() {
assert_eq!(round_result_type("float64"), "float64");
assert_eq!(round_result_type("float32"), "float32");
}
#[test]
fn test_round_result_type_int_and_decimal_widen_to_decimal() {
assert_eq!(round_result_type("int32"), "decimal");
assert_eq!(round_result_type("decimal"), "decimal");
}
#[test]
fn test_is_comparable_types_numeric() {
assert!(is_comparable_types("int16", "int32"));
assert!(is_comparable_types("int32", "int64"));
assert!(is_comparable_types("int64", "float64"));
assert!(is_comparable_types("float32", "decimal"));
assert!(is_comparable_types("decimal", "int16"));
}
#[test]
fn test_is_comparable_types_string() {
assert!(is_comparable_types("string", "string"));
}
#[test]
fn test_is_comparable_types_temporal() {
assert!(is_comparable_types("date", "datetime"));
assert!(is_comparable_types("datetime", "datetime_tz"));
assert!(is_comparable_types("time", "time_tz"));
}
#[test]
fn test_is_comparable_types_incompatible() {
assert!(!is_comparable_types("string", "int32"));
assert!(!is_comparable_types("bool", "int64"));
assert!(!is_comparable_types("date", "string"));
assert!(!is_comparable_types("json", "string"));
}
#[test]
fn test_parse_placeholder_valid() {
assert_eq!(parse_placeholder("$1"), Some(1));
assert_eq!(parse_placeholder("$99"), Some(99));
assert_eq!(parse_placeholder("$0"), Some(0));
}
#[test]
fn test_parse_placeholder_invalid() {
assert_eq!(parse_placeholder("1"), None);
assert_eq!(parse_placeholder("$abc"), None);
assert_eq!(parse_placeholder(""), None);
assert_eq!(parse_placeholder("$$"), None);
assert_eq!(parse_placeholder("?"), None);
}
#[test]
fn test_is_positional_placeholder() {
assert!(is_positional_placeholder("?"));
assert!(!is_positional_placeholder("$1"));
assert!(!is_positional_placeholder(""));
assert!(!is_positional_placeholder("??"));
}
#[test]
fn test_expr_to_name_identifier() {
let expr = Expr::Identifier(Ident::new("my_column"));
assert_eq!(expr_to_name(&expr), "my_column");
}
#[test]
fn test_expr_to_name_compound() {
let expr = Expr::CompoundIdentifier(vec![Ident::new("t"), Ident::new("my_col")]);
assert_eq!(expr_to_name(&expr), "my_col");
}
#[test]
fn test_expr_to_name_function() {
use sqlparser::ast::{Function, FunctionArguments};
let func = Function {
name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("count"))]),
args: FunctionArguments::None,
filter: None,
over: None,
null_treatment: None,
within_group: Vec::new(),
parameters: FunctionArguments::None,
uses_odbc_syntax: false,
};
let expr = Expr::Function(func);
assert_eq!(expr_to_name(&expr), "count");
}
#[test]
fn test_expr_to_name_nested() {
let inner = Expr::Identifier(Ident::new("x"));
let expr = Expr::Nested(Box::new(inner));
assert_eq!(expr_to_name(&expr), "x");
}
#[test]
fn test_expr_to_name_placeholder() {
let vws = ValueWithSpan {
value: Value::Placeholder("$3".to_string()),
span: Span::empty(),
};
let expr = Expr::Value(vws);
assert_eq!(expr_to_name(&expr), "p3");
}
#[test]
fn test_expr_to_name_unknown_fallback() {
let vws = ValueWithSpan {
value: Value::Null,
span: Span::empty(),
};
let expr = Expr::Value(vws);
assert_eq!(expr_to_name(&expr), "unknown");
}
#[test]
fn test_pluralize_regular() {
assert_eq!(pluralize("user"), "users");
assert_eq!(pluralize("post"), "posts");
assert_eq!(pluralize("comment"), "comments");
}
#[test]
fn test_pluralize_s_x_sh_ch() {
assert_eq!(pluralize("status"), "statuses");
assert_eq!(pluralize("box"), "boxes");
assert_eq!(pluralize("wish"), "wishes");
assert_eq!(pluralize("match"), "matches");
}
#[test]
fn test_pluralize_y_ending() {
assert_eq!(pluralize("category"), "categories");
assert_eq!(pluralize("city"), "cities");
assert_eq!(pluralize("key"), "keys");
assert_eq!(pluralize("day"), "days");
assert_eq!(pluralize("boy"), "boys");
}
#[test]
fn test_is_integer_type() {
assert!(is_integer_type("int16"));
assert!(is_integer_type("int32"));
assert!(is_integer_type("int64"));
assert!(!is_integer_type("float32"));
assert!(!is_integer_type("string"));
}
#[test]
fn test_is_float_type() {
assert!(is_float_type("float32"));
assert!(is_float_type("float64"));
assert!(!is_float_type("int64"));
assert!(!is_float_type("decimal"));
}
#[test]
fn test_sum_result_type() {
assert_eq!(sum_result_type("int16"), "int64");
assert_eq!(sum_result_type("int32"), "int64");
assert_eq!(sum_result_type("int64"), "decimal");
assert_eq!(sum_result_type("decimal"), "decimal");
assert_eq!(sum_result_type("float32"), "float32");
assert_eq!(sum_result_type("float64"), "float64");
}
#[test]
fn test_avg_result_type() {
assert_eq!(avg_result_type("int16"), "decimal");
assert_eq!(avg_result_type("int32"), "decimal");
assert_eq!(avg_result_type("int64"), "decimal");
assert_eq!(avg_result_type("decimal"), "decimal");
assert_eq!(avg_result_type("float32"), "float64");
assert_eq!(avg_result_type("float64"), "float64");
}
#[test]
fn test_neutral_to_sql_label() {
assert_eq!(neutral_to_sql_label("int32"), "integer");
assert_eq!(neutral_to_sql_label("int64"), "bigint");
assert_eq!(neutral_to_sql_label("string"), "text");
assert_eq!(neutral_to_sql_label("bool"), "boolean");
assert_eq!(neutral_to_sql_label("datetime_tz"), "timestamptz");
assert_eq!(neutral_to_sql_label("uuid"), "uuid");
assert_eq!(neutral_to_sql_label("custom_type"), "custom_type");
}
#[test]
fn test_derive_param_name_from_comparison_no_function() {
let col_side = Expr::Identifier(Ident::new("age"));
let param_side = Expr::Identifier(Ident::new("dummy"));
assert_eq!(
derive_param_name_from_comparison("age", &col_side, ¶m_side, Some(&BinaryOperator::Gt)),
"age"
);
}
#[test]
fn test_derive_param_name_from_comparison_function_gt() {
use sqlparser::ast::{Function, FunctionArguments};
let func = Function {
name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("count"))]),
args: FunctionArguments::None,
filter: None,
over: None,
null_treatment: None,
within_group: Vec::new(),
parameters: FunctionArguments::None,
uses_odbc_syntax: false,
};
let col_side = Expr::Function(func);
let param_side = Expr::Identifier(Ident::new("dummy"));
assert_eq!(
derive_param_name_from_comparison("count", &col_side, ¶m_side, Some(&BinaryOperator::Gt)),
"min_count"
);
assert_eq!(
derive_param_name_from_comparison("count", &col_side, ¶m_side, Some(&BinaryOperator::GtEq)),
"min_count"
);
}
#[test]
fn test_derive_param_name_from_comparison_function_lt() {
use sqlparser::ast::{Function, FunctionArguments};
let func = Function {
name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("count"))]),
args: FunctionArguments::None,
filter: None,
over: None,
null_treatment: None,
within_group: Vec::new(),
parameters: FunctionArguments::None,
uses_odbc_syntax: false,
};
let col_side = Expr::Function(func);
let param_side = Expr::Identifier(Ident::new("dummy"));
assert_eq!(
derive_param_name_from_comparison("count", &col_side, ¶m_side, Some(&BinaryOperator::Lt)),
"max_count"
);
assert_eq!(
derive_param_name_from_comparison("count", &col_side, ¶m_side, Some(&BinaryOperator::LtEq)),
"max_count"
);
}
#[test]
fn test_derive_param_name_from_comparison_function_eq() {
use sqlparser::ast::{Function, FunctionArguments};
let func = Function {
name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("count"))]),
args: FunctionArguments::None,
filter: None,
over: None,
null_treatment: None,
within_group: Vec::new(),
parameters: FunctionArguments::None,
uses_odbc_syntax: false,
};
let col_side = Expr::Function(func);
let param_side = Expr::Identifier(Ident::new("dummy"));
assert_eq!(
derive_param_name_from_comparison("count", &col_side, ¶m_side, Some(&BinaryOperator::Eq)),
"count"
);
}
#[test]
fn test_is_not_null_guard_matching() {
let inner = Expr::Identifier(Ident::new("bio"));
let condition = Expr::IsNotNull(Box::new(inner));
let result = Expr::Identifier(Ident::new("bio"));
assert!(is_not_null_guard(&condition, &result, &single_source_scope()));
}
#[test]
fn test_is_not_null_guard_non_matching() {
let inner = Expr::Identifier(Ident::new("bio"));
let condition = Expr::IsNotNull(Box::new(inner));
let result = Expr::Identifier(Ident::new("name"));
assert!(!is_not_null_guard(&condition, &result, &single_source_scope()));
}
#[test]
fn test_is_not_null_guard_not_is_not_null() {
let condition = Expr::Identifier(Ident::new("bio"));
let result = Expr::Identifier(Ident::new("bio"));
assert!(!is_not_null_guard(&condition, &result, &single_source_scope()));
}
#[test]
fn test_is_not_null_guard_different_tables_same_bare_name_is_not_a_guard() {
let inner = compound("u", "deleted_at");
let condition = Expr::IsNotNull(Box::new(inner));
let result = compound("o", "deleted_at");
assert!(!is_not_null_guard(&condition, &result, &two_source_scope()));
}
#[test]
fn test_is_not_null_guard_same_qualified_column_is_a_guard() {
let inner = compound("u", "deleted_at");
let condition = Expr::IsNotNull(Box::new(inner));
let result = compound("u", "deleted_at");
assert!(is_not_null_guard(&condition, &result, &two_source_scope()));
}
#[test]
fn test_is_not_null_guard_unqualified_in_multi_table_scope_is_not_a_guard() {
let inner = Expr::Identifier(Ident::new("deleted_at"));
let condition = Expr::IsNotNull(Box::new(inner));
let result = compound("o", "deleted_at");
assert!(!is_not_null_guard(&condition, &result, &two_source_scope()));
}
#[test]
fn test_is_literal() {
let num = Expr::Value(ValueWithSpan {
value: Value::Number("42".to_string(), false),
span: Span::empty(),
});
assert!(is_literal(&num));
let s = Expr::Value(ValueWithSpan {
value: Value::SingleQuotedString("hello".to_string()),
span: Span::empty(),
});
assert!(is_literal(&s));
let b = Expr::Value(ValueWithSpan {
value: Value::Boolean(true),
span: Span::empty(),
});
assert!(is_literal(&b));
let n = Expr::Value(ValueWithSpan {
value: Value::Null,
span: Span::empty(),
});
assert!(!is_literal(&n));
let ident = Expr::Identifier(Ident::new("x"));
assert!(!is_literal(&ident));
}
#[test]
fn test_object_name_to_string_single() {
let name = ObjectName(vec![ObjectNamePart::Identifier(Ident::new("users"))]);
assert_eq!(object_name_to_string(&name), "users");
}
#[test]
fn test_object_name_to_string_qualified() {
let name = ObjectName(vec![
ObjectNamePart::Identifier(Ident::new("public")),
ObjectNamePart::Identifier(Ident::new("users")),
]);
assert_eq!(object_name_to_string(&name), "public.users");
}
#[test]
fn test_value_is_string() {
let vws = ValueWithSpan {
value: Value::SingleQuotedString("hello".to_string()),
span: Span::empty(),
};
assert!(value_is_string(&vws));
}
#[test]
fn test_value_is_boolean() {
let vws = ValueWithSpan {
value: Value::Boolean(true),
span: Span::empty(),
};
assert!(value_is_boolean(&vws));
}
#[test]
fn test_value_is_null() {
let vws = ValueWithSpan {
value: Value::Null,
span: Span::empty(),
};
assert!(value_is_null(&vws));
}
#[test]
fn test_value_is_placeholder() {
let vws = ValueWithSpan {
value: Value::Placeholder("$1".to_string()),
span: Span::empty(),
};
assert_eq!(value_is_placeholder(&vws), Some("$1"));
let vws2 = ValueWithSpan {
value: Value::Number("1".to_string(), false),
span: Span::empty(),
};
assert_eq!(value_is_placeholder(&vws2), None);
}
fn disambiguated(names: &[&str]) -> Vec<String> {
let mut names: Vec<String> = names.iter().map(|n| (*n).to_string()).collect();
disambiguate_duplicate_names(names.iter_mut());
names
}
#[test]
fn test_disambiguate_leaves_unique_names_untouched() {
assert_eq!(disambiguated(&["id", "name", "email"]), ["id", "name", "email"]);
}
#[test]
fn test_disambiguate_suffixes_every_occurrence_in_positional_order() {
assert_eq!(disambiguated(&["id", "id"]), ["id_1", "id_2"]);
assert_eq!(
disambiguated(&["id", "name", "id", "id"]),
["id_1", "name", "id_2", "id_3"]
);
}
#[test]
fn test_disambiguate_handles_two_independent_collisions() {
assert_eq!(
disambiguated(&["id", "total", "id", "total"]),
["id_1", "total_1", "id_2", "total_2"]
);
}
#[test]
fn test_disambiguate_skips_suffixes_that_already_exist() {
assert_eq!(disambiguated(&["id", "id", "id_1"]), ["id_2", "id_3", "id_1"]);
}
#[test]
fn test_unresolved_type_error_maps_each_marker_to_its_user_facing_code() {
use crate::errors::ErrorCode;
let ambiguous =
unresolved_type_error(&format!("{AMBIGUOUS_COLUMN_MARKER}id"), "column", "id").expect("marker recognised");
assert_eq!(ambiguous.code, ErrorCode::AmbiguousColumn);
assert!(ambiguous.message.contains("id"), "got: {}", ambiguous.message);
let unknown_col = unresolved_type_error(&format!("{UNKNOWN_COLUMN_MARKER}nosuchcol"), "column", "nosuchcol")
.expect("marker recognised");
assert_eq!(unknown_col.code, ErrorCode::UnknownColumn);
assert!(
unknown_col.message.contains("nosuchcol"),
"got: {}",
unknown_col.message
);
let unknown_func =
unresolved_type_error(&format!("{UNKNOWN_FUNCTION_MARKER}my_weird_func"), "column", "result")
.expect("marker recognised");
assert_eq!(unknown_func.code, ErrorCode::UnknownFunction);
assert!(
unknown_func.message.contains("my_weird_func"),
"got: {}",
unknown_func.message
);
let ambiguous_func = unresolved_type_error(&format!("{AMBIGUOUS_FUNCTION_MARKER}choose"), "column", "result")
.expect("marker recognised");
assert_eq!(ambiguous_func.code, ErrorCode::AmbiguousFunction);
let record = unresolved_type_error(&format!("{UNTYPEABLE_RECORD_MARKER}jsonb_each"), "column", "kv")
.expect("marker recognised");
assert_eq!(record.code, ErrorCode::UnresolvedType);
assert!(record.message.contains("kv"), "got: {}", record.message);
assert!(record.message.contains("jsonb_each"), "got: {}", record.message);
let row = unresolved_type_error(UNTYPEABLE_ROW_MARKER, "column", "row_col").expect("marker recognised");
assert_eq!(row.code, ErrorCode::UnresolvedType);
assert!(row.message.contains("row_col"), "got: {}", row.message);
let generic = unresolved_type_error(UNRESOLVED_EXPR_MARKER, "column", "mystery").expect("marker recognised");
assert_eq!(generic.code, ErrorCode::UnresolvedType);
assert!(generic.message.contains("mystery"), "got: {}", generic.message);
}
#[test]
fn test_unresolved_type_error_finds_marker_wrapped_in_array() {
use crate::errors::ErrorCode;
let wrapped_row = unresolved_type_error(&format!("array<{UNTYPEABLE_ROW_MARKER}>"), "column", "rows")
.expect("a payload-free marker must be recognised when wrapped in array<...>");
assert_eq!(wrapped_row.code, ErrorCode::UnresolvedType);
assert!(wrapped_row.message.contains("rows"), "got: {}", wrapped_row.message);
let wrapped_unknown_func =
unresolved_type_error(&format!("array<{UNKNOWN_FUNCTION_MARKER}row>"), "column", "rows")
.expect("a payload-carrying marker must be recognised when wrapped in array<...>");
assert_eq!(wrapped_unknown_func.code, ErrorCode::UnknownFunction);
assert!(
wrapped_unknown_func.message.contains("row"),
"got: {}",
wrapped_unknown_func.message
);
}
#[test]
fn test_unresolved_type_error_ignores_ordinary_neutral_types() {
for neutral in ["int32", "array<string>", "json_nested<array<QRowX>>", "unknown"] {
assert!(
unresolved_type_error(neutral, "column", "col").is_none(),
"{neutral} must not be treated as an unresolved marker"
);
}
}
}