use std::collections::HashMap;
use std::sync::Mutex;
use oracledb_protocol::thin::ColumnMetadata;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ColumnShapeEntry {
name: String,
ora_type_num: u8,
csfrm: u8,
precision: i8,
scale: i8,
max_size: u32,
nulls_allowed: bool,
vector_format: u8,
vector_flags: u8,
vector_dimensions: Option<u32>,
}
impl ColumnShapeEntry {
fn from_column(column: &ColumnMetadata) -> Self {
Self {
name: column.name().to_string(),
ora_type_num: column.ora_type_num(),
csfrm: column.csfrm(),
precision: column.precision(),
scale: column.scale(),
max_size: column.max_size(),
nulls_allowed: column.nulls_allowed(),
vector_format: column.vector_format(),
vector_flags: column.vector_flags(),
vector_dimensions: column.vector_dimensions(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ColumnShape {
columns: Vec<ColumnShapeEntry>,
}
impl ColumnShape {
pub fn from_columns(columns: &[ColumnMetadata]) -> Self {
Self {
columns: columns.iter().map(ColumnShapeEntry::from_column).collect(),
}
}
pub fn len(&self) -> usize {
self.columns.len()
}
pub fn is_empty(&self) -> bool {
self.columns.is_empty()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ShapeObservation {
pub generation: u64,
pub self_healed: bool,
pub first_seen: bool,
}
impl ShapeObservation {
pub fn requires_rebind(&self) -> bool {
self.self_healed
}
}
#[derive(Clone, Debug)]
struct CachedShape {
shape: ColumnShape,
generation: u64,
}
#[derive(Debug, Default)]
pub struct StatementShapeCache {
inner: Mutex<HashMap<String, CachedShape>>,
}
impl StatementShapeCache {
pub fn new() -> Self {
Self::default()
}
pub fn observe(&self, sql: &str, columns: &[ColumnMetadata]) -> ShapeObservation {
let shape = ColumnShape::from_columns(columns);
if shape.is_empty() {
return ShapeObservation {
generation: 0,
self_healed: false,
first_seen: false,
};
}
let key = normalize_sql(sql);
let mut map = self.lock();
match map.get_mut(&key) {
None => {
map.insert(
key,
CachedShape {
shape,
generation: 1,
},
);
ShapeObservation {
generation: 1,
self_healed: false,
first_seen: true,
}
}
Some(entry) if entry.shape == shape => ShapeObservation {
generation: entry.generation,
self_healed: false,
first_seen: false,
},
Some(entry) => {
entry.shape = shape;
entry.generation += 1;
ShapeObservation {
generation: entry.generation,
self_healed: true,
first_seen: false,
}
}
}
}
pub fn current(&self, sql: &str) -> Option<(u64, ColumnShape)> {
let key = normalize_sql(sql);
let map = self.lock();
map.get(&key)
.map(|entry| (entry.generation, entry.shape.clone()))
}
pub fn invalidate(&self, sql: &str) -> bool {
let key = normalize_sql(sql);
self.lock().remove(&key).is_some()
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.lock().is_empty()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, CachedShape>> {
self.inner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
}
pub fn normalize_sql(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
let mut pending_space = false;
while let Some(&c) = chars.peek() {
if c.is_ascii_whitespace() {
chars.next();
} else {
break;
}
}
while let Some(c) = chars.next() {
if c == '\'' || c == '"' {
if pending_space {
out.push(' ');
pending_space = false;
}
let quote = c;
out.push(quote);
while let Some(inner) = chars.next() {
out.push(inner);
if inner == quote {
if chars.peek() == Some("e) {
out.push(quote);
chars.next();
} else {
break;
}
}
}
} else if c.is_ascii_whitespace() {
pending_space = true;
} else {
if pending_space {
out.push(' ');
pending_space = false;
}
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use oracledb_protocol::thin::{ORA_TYPE_NUM_NUMBER, ORA_TYPE_NUM_VARCHAR};
fn col(name: &str, ty: u8, precision: i8, scale: i8, max_size: u32) -> ColumnMetadata {
ColumnMetadata::new(name, ty)
.with_precision(precision)
.with_scale(scale)
.with_max_size(max_size)
.with_nulls_allowed(true)
}
fn shape_v1() -> Vec<ColumnMetadata> {
vec![
col("ID", ORA_TYPE_NUM_NUMBER, 9, 0, 22),
col("NAME", ORA_TYPE_NUM_VARCHAR, 0, 0, 50),
]
}
fn shape_v2() -> Vec<ColumnMetadata> {
vec![
col("ID", ORA_TYPE_NUM_NUMBER, 9, 0, 22),
col("NAME", ORA_TYPE_NUM_VARCHAR, 0, 0, 200),
col("EMAIL", ORA_TYPE_NUM_VARCHAR, 0, 0, 100),
]
}
#[test]
fn first_observation_records_shape_without_self_heal() {
let cache = StatementShapeCache::new();
let obs = cache.observe("select id, name from t", &shape_v1());
assert!(obs.first_seen);
assert!(!obs.self_healed);
assert_eq!(obs.generation, 1);
assert_eq!(cache.len(), 1);
}
#[test]
fn identical_reobservation_is_stable() {
let cache = StatementShapeCache::new();
cache.observe("select id, name from t", &shape_v1());
let obs = cache.observe("select id, name from t", &shape_v1());
assert!(!obs.first_seen);
assert!(!obs.self_healed);
assert_eq!(obs.generation, 1, "same shape must not bump the generation");
}
#[test]
fn cross_connection_ddl_shape_change_self_heals_and_never_serves_stale() {
let shared = StatementShapeCache::new();
let sql = "select id, name from t";
let a = shared.observe(sql, &shape_v1());
assert!(a.first_seen && !a.self_healed);
let b = shared.observe(sql, &shape_v2());
assert!(b.self_healed, "shape change must self-heal");
assert!(b.requires_rebind());
assert_eq!(
b.generation, 2,
"self-heal bumps the generation exactly once"
);
let (gen, shape) = shared.current(sql).expect("recorded");
assert_eq!(gen, 2);
assert_eq!(shape, ColumnShape::from_columns(&shape_v2()));
assert_ne!(
shape,
ColumnShape::from_columns(&shape_v1()),
"the stale v1 shape must be gone after self-heal"
);
let a2 = shared.observe(sql, &shape_v2());
assert!(!a2.self_healed);
assert_eq!(a2.generation, 2);
}
#[test]
fn self_heal_only_ever_heals_down_never_loosens() {
let cache = StatementShapeCache::new();
let sql = "select id, name from t";
cache.observe(sql, &shape_v1());
let to_v2 = cache.observe(sql, &shape_v2());
assert!(to_v2.self_healed);
let (_, after_v2) = cache.current(sql).unwrap();
assert_eq!(after_v2, ColumnShape::from_columns(&shape_v2()));
let back_to_v1 = cache.observe(sql, &shape_v1());
assert!(back_to_v1.self_healed);
assert_eq!(back_to_v1.generation, 3, "generation is monotonic");
let (_, after_back) = cache.current(sql).unwrap();
assert_eq!(
after_back,
ColumnShape::from_columns(&shape_v1()),
"healed exactly to v1, not a v1+v2 union"
);
assert_eq!(after_back.len(), 2, "no phantom widened/unioned columns");
}
#[test]
fn precision_or_scale_change_alone_triggers_self_heal() {
let cache = StatementShapeCache::new();
let sql = "select amount from t";
cache.observe(sql, &[col("AMOUNT", ORA_TYPE_NUM_NUMBER, 9, 2, 22)]);
let obs = cache.observe(sql, &[col("AMOUNT", ORA_TYPE_NUM_NUMBER, 12, 4, 22)]);
assert!(obs.self_healed, "precision/scale change must self-heal");
}
#[test]
fn empty_shape_never_overwrites_a_recorded_query_shape() {
let cache = StatementShapeCache::new();
let sql = "select id, name from t";
cache.observe(sql, &shape_v1());
let dml = cache.observe(sql, &[]);
assert!(!dml.self_healed);
assert!(!dml.first_seen);
let (gen, shape) = cache.current(sql).unwrap();
assert_eq!(gen, 1);
assert_eq!(shape, ColumnShape::from_columns(&shape_v1()));
}
#[test]
fn normalize_collapses_incidental_whitespace_outside_literals() {
assert_eq!(
normalize_sql(" select id,\n\t name from t "),
"select id, name from t"
);
assert_eq!(
normalize_sql("select 'a b' from dual"),
"select 'a b' from dual"
);
assert_eq!(
normalize_sql("select 1 from dual"),
normalize_sql("select 1 from\tdual")
);
assert_ne!(
normalize_sql("select 'x' from dual"),
normalize_sql("select 'y' from dual")
);
}
#[test]
fn normalize_preserves_escaped_quote_inside_literal() {
assert_eq!(
normalize_sql("select 'it''s here' from dual"),
"select 'it''s here' from dual"
);
}
#[test]
fn whitespace_only_difference_shares_cache_entry() {
let cache = StatementShapeCache::new();
cache.observe("select id, name from t", &shape_v1());
let obs = cache.observe("select id, name from t", &shape_v1());
assert!(
!obs.first_seen,
"whitespace variant must hit the same entry"
);
assert!(!obs.self_healed);
assert_eq!(cache.len(), 1);
}
#[test]
fn invalidate_forces_first_seen_again() {
let cache = StatementShapeCache::new();
let sql = "select id, name from t";
cache.observe(sql, &shape_v1());
assert!(cache.invalidate(sql));
let obs = cache.observe(sql, &shape_v1());
assert!(obs.first_seen);
assert_eq!(obs.generation, 1);
}
}