use sqlparser::ast::{Statement, Visit};
use super::column_backed;
use super::slots::{InferenceContext, InferredParamType};
use crate::catalog::SqlCatalog;
use crate::parser::array_stmt::try_parse_array_statement;
use crate::parser::preprocess;
use crate::parser::statement::parse_sql;
pub fn infer_placeholder_types(
sql: &str,
catalog: &dyn SqlCatalog,
) -> Vec<Option<InferredParamType>> {
let Some(statements) = parse_best_effort(sql) else {
return Vec::new();
};
let mut ctx = InferenceContext::default();
for statement in &statements {
let _ = statement.visit(&mut ctx);
column_backed::infer_from_statement(&mut ctx, catalog, statement);
}
ctx.finish()
}
fn parse_best_effort(sql: &str) -> Option<Vec<Statement>> {
if let Ok(Some(_)) = try_parse_array_statement(sql) {
return None;
}
let preprocessed = preprocess::preprocess(sql).ok().flatten();
let effective_sql = preprocessed.as_ref().map_or(sql, |p| p.sql.as_str());
parse_sql(effective_sql).ok()
}
#[cfg(test)]
mod tests {
use nodedb_types::DatabaseId;
use nodedb_types::columnar::{FloatWidth, IntWidth};
use super::infer_placeholder_types;
use crate::catalog::{SqlCatalog, SqlCatalogError};
use crate::placeholder_types::slots::{InferredParamType, parse_placeholder_body};
use crate::types::{CollectionInfo, ColumnInfo, EngineType};
use crate::types_expr::SqlDataType;
fn col(name: &str, data_type: SqlDataType, int_width: Option<IntWidth>) -> ColumnInfo {
ColumnInfo {
name: name.into(),
data_type,
nullable: true,
is_primary_key: false,
default: None,
raw_type: None,
int_width,
float_width: None,
}
}
fn float_col(name: &str, float_width: FloatWidth) -> ColumnInfo {
ColumnInfo {
float_width: Some(float_width),
..col(name, SqlDataType::Float64, None)
}
}
fn collection(name: &str, columns: Vec<ColumnInfo>) -> CollectionInfo {
CollectionInfo {
name: name.into(),
engine: EngineType::DocumentStrict,
columns,
primary_key: Some("id".into()),
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: false,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
}
}
struct TestCatalog;
impl SqlCatalog for TestCatalog {
fn get_collection(
&self,
_: DatabaseId,
name: &str,
) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
let info = match name {
"t" => Some(collection(
"t",
vec![
col("id", SqlDataType::String, None),
col("n", SqlDataType::Int64, Some(IntWidth::I32)),
col("big", SqlDataType::Int64, Some(IntWidth::I64)),
col("label", SqlDataType::String, None),
col("flag", SqlDataType::Bool, None),
float_col("ratio", FloatWidth::F32),
float_col("wide", FloatWidth::F64),
],
)),
"lefty" => Some(collection(
"lefty",
vec![
col("shared", SqlDataType::String, None),
col("only_left", SqlDataType::Int64, Some(IntWidth::I32)),
],
)),
"righty" => Some(collection(
"righty",
vec![
col("shared", SqlDataType::Int64, Some(IntWidth::I64)),
col("only_right", SqlDataType::Bool, None),
],
)),
"loose" => Some(collection("loose", Vec::new())),
_ => None,
};
Ok(info)
}
}
fn infer(sql: &str) -> Vec<Option<InferredParamType>> {
infer_placeholder_types(sql, &TestCatalog)
}
fn from_sql(data_type: SqlDataType) -> Option<InferredParamType> {
Some(InferredParamType {
data_type,
int_width: None,
float_width: None,
})
}
fn from_col(data_type: SqlDataType, int_width: Option<IntWidth>) -> Option<InferredParamType> {
Some(InferredParamType {
data_type,
int_width,
float_width: None,
})
}
fn from_float_col(float_width: FloatWidth) -> Option<InferredParamType> {
Some(InferredParamType {
data_type: SqlDataType::Float64,
int_width: None,
float_width: Some(float_width),
})
}
#[test]
fn limit_placeholder_is_int64() {
assert_eq!(
infer("SELECT id FROM t LIMIT $1"),
vec![from_sql(SqlDataType::Int64)]
);
}
#[test]
fn offset_placeholder_is_int64() {
assert_eq!(
infer("SELECT id FROM t LIMIT 10 OFFSET $1"),
vec![from_sql(SqlDataType::Int64)]
);
}
#[test]
fn limit_and_offset_placeholders_are_both_int64() {
assert_eq!(
infer("SELECT id FROM t LIMIT $1 OFFSET $2"),
vec![from_sql(SqlDataType::Int64), from_sql(SqlDataType::Int64)]
);
}
#[test]
fn double_colon_cast_resolves_target_type() {
assert_eq!(infer("SELECT $1::INT"), vec![from_sql(SqlDataType::Int64)]);
}
#[test]
fn cast_as_syntax_resolves_target_type() {
assert_eq!(
infer("SELECT CAST($1 AS TEXT)"),
vec![from_sql(SqlDataType::String)]
);
}
#[test]
fn cast_target_types_cover_each_mapped_family() {
let cases: &[(&str, SqlDataType)] = &[
("BIGINT", SqlDataType::Int64),
("SMALLINT", SqlDataType::Int64),
("DOUBLE PRECISION", SqlDataType::Float64),
("REAL", SqlDataType::Float64),
("VARCHAR(10)", SqlDataType::String),
("BOOLEAN", SqlDataType::Bool),
("BYTEA", SqlDataType::Bytes),
("TIMESTAMP", SqlDataType::Timestamp),
("TIMESTAMPTZ", SqlDataType::Timestamptz),
];
for (name, expected) in cases {
assert_eq!(
infer(&format!("SELECT CAST($1 AS {name})")),
vec![from_sql(expected.clone())],
"cast to {name} must resolve to {expected:?}"
);
}
}
#[test]
fn unmapped_cast_target_stays_none() {
assert_eq!(infer("SELECT $1::NUMERIC"), vec![None]);
assert_eq!(infer("SELECT $1::UUID"), vec![None]);
}
#[test]
fn out_of_order_indices_land_in_correct_slots() {
assert_eq!(
infer("SELECT id FROM t WHERE unknown_col = $2 LIMIT $1"),
vec![from_sql(SqlDataType::Int64), None]
);
}
#[test]
fn repeated_index_resolved_once_keeps_its_type() {
assert_eq!(
infer("SELECT id FROM t WHERE unknown_col = $1 LIMIT $1"),
vec![from_sql(SqlDataType::Int64)]
);
}
#[test]
fn conflicting_types_for_one_index_stay_none() {
assert_eq!(infer("SELECT $1::TEXT FROM t LIMIT $1"), vec![None]);
}
#[test]
fn same_logical_type_but_different_width_conflicts() {
assert_eq!(infer("SELECT id FROM t WHERE n = $1 LIMIT $1"), vec![None]);
}
#[test]
fn result_is_sized_to_highest_index() {
let inferred = infer("SELECT id FROM t WHERE unknown_col = $3 LIMIT $1");
assert_eq!(inferred.len(), 3);
assert_eq!(inferred[0], from_sql(SqlDataType::Int64));
assert_eq!(inferred[1], None);
assert_eq!(inferred[2], None);
}
#[test]
fn statement_without_placeholders_is_empty() {
assert!(infer("SELECT id FROM t").is_empty());
}
#[test]
fn unparseable_sql_returns_empty() {
assert!(infer("this is not sql at all $1").is_empty());
assert!(infer("").is_empty());
assert!(infer("SELECT FROM WHERE $1 $2").is_empty());
}
#[test]
fn malformed_placeholder_bodies_claim_no_slot() {
assert!(parse_placeholder_body("$").is_none());
assert!(parse_placeholder_body("$0").is_none());
assert!(parse_placeholder_body("$abc").is_none());
assert!(parse_placeholder_body("?").is_none());
assert!(parse_placeholder_body("").is_none());
assert_eq!(parse_placeholder_body("$7"), Some(7));
}
#[test]
fn positionless_placeholder_reports_nothing() {
assert!(infer("SELECT id FROM t WHERE n = ?").is_empty());
}
#[test]
fn update_limit_placeholder_is_int64() {
assert_eq!(
infer("UPDATE t SET label = 'x' WHERE id > '0' LIMIT $1"),
vec![from_sql(SqlDataType::Int64)]
);
}
#[test]
fn subquery_limit_placeholder_is_int64() {
assert_eq!(
infer("SELECT * FROM (SELECT id FROM t LIMIT $1) s WHERE s.id = $2"),
vec![from_sql(SqlDataType::Int64), None]
);
}
#[test]
fn cte_cast_placeholder_resolves() {
assert_eq!(
infer("WITH x AS (SELECT $1::BIGINT AS v) SELECT v FROM x"),
vec![from_sql(SqlDataType::Int64)]
);
}
#[test]
fn where_comparison_resolves_column_type_and_width() {
assert_eq!(
infer("SELECT id FROM t WHERE n = $1"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))]
);
assert_eq!(
infer("SELECT id FROM t WHERE big = $1"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I64))]
);
assert_eq!(
infer("SELECT id FROM t WHERE label = $1"),
vec![from_col(SqlDataType::String, None)]
);
assert_eq!(
infer("SELECT id FROM t WHERE flag = $1"),
vec![from_col(SqlDataType::Bool, None)]
);
}
#[test]
fn where_comparison_resolves_declared_float_width() {
assert_eq!(
infer("SELECT id FROM t WHERE ratio = $1"),
vec![from_float_col(FloatWidth::F32)]
);
assert_eq!(
infer("SELECT id FROM t WHERE wide = $1"),
vec![from_float_col(FloatWidth::F64)]
);
}
#[test]
fn reversed_operand_order_resolves() {
assert_eq!(
infer("SELECT id FROM t WHERE $1 = n"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))]
);
}
#[test]
fn every_comparison_operator_resolves() {
for op in ["=", "<>", "!=", "<", "<=", ">", ">="] {
assert_eq!(
infer(&format!("SELECT id FROM t WHERE n {op} $1")),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))],
"`n {op} $1` must resolve to the column's type"
);
}
}
#[test]
fn conjunction_resolves_each_side_independently() {
assert_eq!(
infer("SELECT id FROM t WHERE n = $1 AND label = $2"),
vec![
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
from_col(SqlDataType::String, None),
]
);
}
#[test]
fn in_list_types_every_placeholder() {
assert_eq!(
infer("SELECT id FROM t WHERE n IN ($1, $2)"),
vec![
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
]
);
}
#[test]
fn between_types_both_bounds() {
assert_eq!(
infer("SELECT id FROM t WHERE n BETWEEN $1 AND $2"),
vec![
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
]
);
}
#[test]
fn having_clause_resolves_against_the_same_scope() {
assert_eq!(
infer("SELECT label FROM t GROUP BY label HAVING label = $1"),
vec![from_col(SqlDataType::String, None)]
);
}
#[test]
fn update_set_resolves_target_column_type() {
assert_eq!(
infer("UPDATE t SET n = $1"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))]
);
assert_eq!(
infer("UPDATE t SET label = $1 WHERE n = $2"),
vec![
from_col(SqlDataType::String, None),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
]
);
}
#[test]
fn delete_predicate_resolves() {
assert_eq!(
infer("DELETE FROM t WHERE n = $1"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))]
);
}
#[test]
fn insert_values_map_positionally_to_the_column_list() {
assert_eq!(
infer("INSERT INTO t (id, n) VALUES ($1, $2)"),
vec![
from_col(SqlDataType::String, None),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
]
);
}
#[test]
fn insert_multi_row_values_map_each_row() {
assert_eq!(
infer("INSERT INTO t (id, n) VALUES ($1, $2), ($3, $4)"),
vec![
from_col(SqlDataType::String, None),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
from_col(SqlDataType::String, None),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
]
);
}
#[test]
fn insert_without_column_list_maps_against_declared_order() {
assert_eq!(
infer("INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7)"),
vec![
from_col(SqlDataType::String, None),
from_col(SqlDataType::Int64, Some(IntWidth::I32)),
from_col(SqlDataType::Int64, Some(IntWidth::I64)),
from_col(SqlDataType::String, None),
from_col(SqlDataType::Bool, None),
from_float_col(FloatWidth::F32),
from_float_col(FloatWidth::F64),
]
);
}
#[test]
fn insert_without_column_list_and_mismatched_arity_stays_none() {
assert_eq!(infer("INSERT INTO t VALUES ($1, $2)"), vec![None, None]);
}
#[test]
fn insert_positional_into_schemaless_collection_stays_none() {
assert_eq!(infer("INSERT INTO loose VALUES ($1, $2)"), vec![None, None]);
}
#[test]
fn bare_column_in_two_joined_relations_stays_none() {
assert_eq!(
infer(
"SELECT only_left FROM lefty JOIN righty ON lefty.only_left = righty.only_right \
WHERE shared = $1"
),
vec![None]
);
}
#[test]
fn bare_column_unique_across_join_resolves() {
assert_eq!(
infer(
"SELECT only_left FROM lefty JOIN righty ON lefty.only_left = righty.only_right \
WHERE only_right = $1"
),
vec![from_col(SqlDataType::Bool, None)]
);
}
#[test]
fn qualified_column_resolves_in_a_join() {
assert_eq!(
infer(
"SELECT only_left FROM lefty JOIN righty ON lefty.only_left = righty.only_right \
WHERE righty.shared = $1"
),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I64))]
);
}
#[test]
fn table_alias_is_the_qualifier() {
assert_eq!(
infer("SELECT id FROM t AS x WHERE x.n = $1"),
vec![from_col(SqlDataType::Int64, Some(IntWidth::I32))]
);
}
#[test]
fn column_absent_from_the_catalog_stays_none() {
assert_eq!(infer("SELECT id FROM t WHERE nope = $1"), vec![None]);
}
#[test]
fn unknown_table_stays_none() {
assert_eq!(infer("SELECT id FROM missing WHERE n = $1"), vec![None]);
assert_eq!(
infer("SELECT id FROM t JOIN missing ON t.id = missing.id WHERE n = $1"),
vec![None]
);
}
#[test]
fn computed_operand_stays_none() {
assert_eq!(infer("SELECT id FROM t WHERE n + 1 = $1"), vec![None]);
assert_eq!(infer("SELECT id FROM t WHERE abs(n) = $1"), vec![None]);
}
#[test]
fn unlisted_forms_stay_none() {
assert_eq!(infer("SELECT $1 FROM t"), vec![None]);
assert_eq!(infer("SELECT id FROM t WHERE abs($1) = n"), vec![None]);
}
}