#![allow(clippy::result_large_err)]
use crate::catalog::CatalogQuery;
use crate::catalog::error::CatalogError;
use crate::catalog::rows::Row;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::text_search::TsDictionary;
const Q: CatalogQuery = CatalogQuery::TsDictionaries;
pub(super) fn assemble_ts_dictionaries(rows: &[Row]) -> Result<Vec<TsDictionary>, CatalogError> {
let mut out = Vec::with_capacity(rows.len());
for row in rows {
out.push(decode_row(row)?);
}
Ok(out)
}
fn decode_row(row: &Row) -> Result<TsDictionary, CatalogError> {
let schema_name = row.get_text(Q, "schema_name")?;
let name = row.get_text(Q, "name")?;
let qname = QualifiedName::new(ident(&schema_name, "schema_name")?, ident(&name, "name")?);
let template_schema = row.get_text(Q, "template_schema")?;
let template_name = row.get_text(Q, "template_name")?;
let template = QualifiedName::new(
ident(&template_schema, "template_schema")?,
ident(&template_name, "template_name")?,
);
let options_raw = row.get_opt_text(Q, "options")?;
let options = match options_raw.as_deref() {
None | Some("") => Vec::new(),
Some(s) => parse_dict_options(s),
};
let owner_str = row.get_text(Q, "owner")?;
let owner = if owner_str.is_empty() {
None
} else {
Some(ident(&owner_str, "owner")?)
};
let comment = match row.get_opt_text(Q, "comment")? {
Some(s) if !s.is_empty() => Some(s),
_ => None,
};
Ok(TsDictionary {
qname,
template,
options,
owner,
comment,
})
}
fn parse_dict_options(s: &str) -> Vec<(String, String)> {
let parts = split_options(s);
let mut out = Vec::with_capacity(parts.len());
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(eq_pos) = part.find('=') {
let key = part[..eq_pos].trim().to_string();
let raw_val = part[eq_pos + 1..].trim();
let value = strip_and_unescape_quotes(raw_val);
if !key.is_empty() {
out.push((key, value));
}
}
}
out
}
fn split_options(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0usize;
let mut in_quotes = false;
let bytes = s.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' if in_quotes => {
if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
i += 2;
continue;
}
in_quotes = false;
}
b'\'' => {
in_quotes = true;
}
b',' if !in_quotes => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
i += 1;
}
parts.push(&s[start..]);
parts
}
fn strip_and_unescape_quotes(s: &str) -> String {
if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
s[1..s.len() - 1].replace("''", "'")
} else {
s.to_string()
}
}
fn ident(s: &str, column: &str) -> Result<Identifier, CatalogError> {
Identifier::from_unquoted(s).map_err(|e| CatalogError::BadColumnType {
query: Q,
column: column.to_string(),
message: format!("invalid identifier {s:?}: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::rows::Value;
fn dict_row() -> Row {
Row::new()
.with("schema_name", Value::Text("app".to_string()))
.with("name", Value::Text("english_stem".to_string()))
.with("template_schema", Value::Text("pg_catalog".to_string()))
.with("template_name", Value::Text("snowball".to_string()))
.with(
"options",
Value::Text("language = 'english', stopwords = 'english'".to_string()),
)
.with("owner", Value::Text("app_owner".to_string()))
.with("comment", Value::Null)
}
#[test]
fn decode_dict_with_template_and_two_options() {
let dict = assemble_ts_dictionaries(&[dict_row()]).unwrap();
assert_eq!(dict.len(), 1);
let d = &dict[0];
assert_eq!(d.qname.to_string(), "app.english_stem");
assert_eq!(d.template.to_string(), "pg_catalog.snowball");
assert_eq!(d.options.len(), 2);
assert_eq!(
d.options[0],
("language".to_string(), "english".to_string())
);
assert_eq!(
d.options[1],
("stopwords".to_string(), "english".to_string())
);
assert_eq!(d.owner.as_ref().map(Identifier::as_str), Some("app_owner"));
assert!(d.comment.is_none());
}
#[test]
fn parse_options_comma_inside_quotes_is_single_entry() {
let opts = parse_dict_options("stopwords = 'a,b'");
assert_eq!(opts.len(), 1);
assert_eq!(opts[0], ("stopwords".to_string(), "a,b".to_string()));
}
#[test]
fn parse_options_multiple_entries_with_comma_in_value() {
let opts = parse_dict_options("language = 'english', stopwords = 'a,b'");
assert_eq!(opts.len(), 2);
assert_eq!(opts[0], ("language".to_string(), "english".to_string()));
assert_eq!(opts[1], ("stopwords".to_string(), "a,b".to_string()));
}
#[test]
fn parse_options_empty_string_yields_no_options() {
assert!(parse_dict_options("").is_empty());
}
#[test]
fn decode_dict_null_options_yields_empty_vec() {
let mut row = dict_row();
row.insert("options", Value::Null);
let dict = assemble_ts_dictionaries(&[row]).unwrap();
assert!(dict[0].options.is_empty());
}
#[test]
fn decode_dict_empty_options_yields_empty_vec() {
let mut row = dict_row();
row.insert("options", Value::Text(String::new()));
let dict = assemble_ts_dictionaries(&[row]).unwrap();
assert!(dict[0].options.is_empty());
}
#[test]
fn decode_dict_with_owner_and_comment() {
let mut row = dict_row();
row.insert(
"comment",
Value::Text("snowball english stemmer".to_string()),
);
let dict = assemble_ts_dictionaries(&[row]).unwrap();
let d = &dict[0];
assert_eq!(d.owner.as_ref().map(Identifier::as_str), Some("app_owner"));
assert_eq!(d.comment.as_deref(), Some("snowball english stemmer"));
}
#[test]
fn decode_dict_null_owner_yields_none() {
let mut row = dict_row();
row.insert("owner", Value::Text(String::new()));
let dict = assemble_ts_dictionaries(&[row]).unwrap();
assert!(dict[0].owner.is_none());
}
#[test]
fn decode_empty_rows_returns_empty_vec() {
assert!(assemble_ts_dictionaries(&[]).unwrap().is_empty());
}
#[test]
fn parse_options_unescapes_double_single_quotes() {
let opts = parse_dict_options("key = 'it''s'");
assert_eq!(opts.len(), 1);
assert_eq!(opts[0], ("key".to_string(), "it's".to_string()));
}
#[test]
fn template_qname_is_schema_qualified() {
let dict = assemble_ts_dictionaries(&[dict_row()]).unwrap();
assert_eq!(dict[0].template.schema.as_str(), "pg_catalog");
assert_eq!(dict[0].template.name.as_str(), "snowball");
}
}