#![allow(clippy::result_large_err)]
use std::collections::BTreeMap;
use crate::catalog::CatalogQuery;
use crate::catalog::error::CatalogError;
use crate::catalog::rows::Row;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::text_search::{TsConfiguration, TsMapping};
const Q_CFG: CatalogQuery = CatalogQuery::TsConfigurations;
const Q_MAP: CatalogQuery = CatalogQuery::TsConfigMappings;
pub(super) fn assemble_ts_configurations(
config_rows: &[Row],
mapping_rows: &[Row],
) -> Result<Vec<TsConfiguration>, CatalogError> {
let mut configs: Vec<TsConfiguration> = Vec::with_capacity(config_rows.len());
for row in config_rows {
configs.push(decode_config_row(row)?);
}
let mut mappings_by_cfg: BTreeMap<String, BTreeMap<String, Vec<QualifiedName>>> =
BTreeMap::new();
for row in mapping_rows {
let cfg_schema = row.get_text(Q_MAP, "config_schema")?;
let cfg_name = row.get_text(Q_MAP, "config_name")?;
let token_type = row.get_text(Q_MAP, "token_type")?;
let dict_schema = row.get_text(Q_MAP, "dict_schema")?;
let dict_name = row.get_text(Q_MAP, "dict_name")?;
let cfg_key = format!("{cfg_schema}.{cfg_name}");
let dict = qname_from_strings(&dict_schema, "dict_schema", &dict_name, "dict_name")?;
mappings_by_cfg
.entry(cfg_key)
.or_default()
.entry(token_type)
.or_default()
.push(dict);
}
for cfg in &mut configs {
let cfg_key = format!("{}.{}", cfg.qname.schema.as_str(), cfg.qname.name.as_str());
if let Some(token_map) = mappings_by_cfg.remove(&cfg_key) {
cfg.mappings = token_map
.into_iter()
.map(|(token_type, dictionaries)| TsMapping {
token_type,
dictionaries,
})
.collect();
}
}
Ok(configs)
}
fn decode_config_row(row: &Row) -> Result<TsConfiguration, CatalogError> {
let schema_name = row.get_text(Q_CFG, "schema_name")?;
let name = row.get_text(Q_CFG, "name")?;
let qname = QualifiedName::new(ident(&schema_name, "schema_name")?, ident(&name, "name")?);
let parser_schema = row.get_text(Q_CFG, "parser_schema")?;
let parser_name = row.get_text(Q_CFG, "parser_name")?;
let parser = QualifiedName::new(
ident(&parser_schema, "parser_schema")?,
ident(&parser_name, "parser_name")?,
);
let owner_str = row.get_text(Q_CFG, "owner")?;
let owner = if owner_str.is_empty() {
None
} else {
Some(ident(&owner_str, "owner")?)
};
let comment = match row.get_opt_text(Q_CFG, "comment")? {
Some(s) if !s.is_empty() => Some(s),
_ => None,
};
Ok(TsConfiguration {
qname,
parser,
mappings: Vec::new(),
owner,
comment,
})
}
fn ident(s: &str, column: &str) -> Result<Identifier, CatalogError> {
Identifier::from_unquoted(s).map_err(|e| CatalogError::BadColumnType {
query: Q_CFG,
column: column.to_string(),
message: format!("invalid identifier {s:?}: {e}"),
})
}
fn qname_from_strings(
schema: &str,
schema_col: &str,
name: &str,
name_col: &str,
) -> Result<QualifiedName, CatalogError> {
let s = Identifier::from_unquoted(schema).map_err(|e| CatalogError::BadColumnType {
query: Q_MAP,
column: schema_col.to_string(),
message: format!("invalid identifier {schema:?}: {e}"),
})?;
let n = Identifier::from_unquoted(name).map_err(|e| CatalogError::BadColumnType {
query: Q_MAP,
column: name_col.to_string(),
message: format!("invalid identifier {name:?}: {e}"),
})?;
Ok(QualifiedName::new(s, n))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::rows::Value;
fn config_row(schema: &str, name: &str, parser_schema: &str, parser_name: &str) -> Row {
Row::new()
.with("schema_name", Value::Text(schema.to_string()))
.with("name", Value::Text(name.to_string()))
.with("parser_schema", Value::Text(parser_schema.to_string()))
.with("parser_name", Value::Text(parser_name.to_string()))
.with("owner", Value::Text("app_owner".to_string()))
.with("comment", Value::Null)
}
fn mapping_row(
cfg_schema: &str,
cfg_name: &str,
token_type: &str,
dict_schema: &str,
dict_name: &str,
seqno: i64,
) -> Row {
Row::new()
.with("config_schema", Value::Text(cfg_schema.to_string()))
.with("config_name", Value::Text(cfg_name.to_string()))
.with("token_type", Value::Text(token_type.to_string()))
.with("dict_schema", Value::Text(dict_schema.to_string()))
.with("dict_name", Value::Text(dict_name.to_string()))
.with("mapseqno", Value::Integer(seqno))
}
#[test]
fn decode_config_with_parser_owner_comment() {
let mut row = config_row("app", "english_cfg", "pg_catalog", "default");
row.insert(
"comment",
Value::Text("English full-text search".to_string()),
);
let cfgs = assemble_ts_configurations(&[row], &[]).unwrap();
assert_eq!(cfgs.len(), 1);
let c = &cfgs[0];
assert_eq!(c.qname.to_string(), "app.english_cfg");
assert_eq!(c.parser.schema.as_str(), "pg_catalog");
assert_eq!(c.parser.name.as_str(), "default");
assert_eq!(c.owner.as_ref().map(Identifier::as_str), Some("app_owner"));
assert_eq!(c.comment.as_deref(), Some("English full-text search"));
assert!(c.mappings.is_empty());
}
#[test]
fn decode_config_null_owner_yields_none() {
let mut row = config_row("app", "cfg", "pg_catalog", "default");
row.insert("owner", Value::Text(String::new()));
let cfgs = assemble_ts_configurations(&[row], &[]).unwrap();
assert!(cfgs[0].owner.is_none());
}
#[test]
fn decode_config_null_comment_yields_none() {
let row = config_row("app", "cfg", "pg_catalog", "default");
let cfgs = assemble_ts_configurations(&[row], &[]).unwrap();
assert!(cfgs[0].comment.is_none());
}
#[test]
fn config_with_no_mapping_rows_has_empty_mappings() {
let row = config_row("app", "minimal_cfg", "pg_catalog", "default");
let cfgs = assemble_ts_configurations(&[row], &[]).unwrap();
assert_eq!(cfgs.len(), 1);
assert!(cfgs[0].mappings.is_empty());
}
#[test]
fn single_token_type_two_dicts_seqno_order_preserved() {
let cfg_row = config_row("app", "english_cfg", "pg_catalog", "default");
let map_rows = vec![
mapping_row("app", "english_cfg", "word", "app", "english_stem", 1),
mapping_row("app", "english_cfg", "word", "pg_catalog", "simple", 2),
];
let cfgs = assemble_ts_configurations(&[cfg_row], &map_rows).unwrap();
assert_eq!(cfgs.len(), 1);
let c = &cfgs[0];
assert_eq!(c.mappings.len(), 1);
let m = &c.mappings[0];
assert_eq!(m.token_type, "word");
assert_eq!(m.dictionaries.len(), 2);
assert_eq!(m.dictionaries[0].to_string(), "app.english_stem");
assert_eq!(m.dictionaries[1].to_string(), "pg_catalog.simple");
}
#[test]
fn two_token_types_both_attached() {
let cfg_row = config_row("app", "english_cfg", "pg_catalog", "default");
let map_rows = vec![
mapping_row("app", "english_cfg", "asciiword", "app", "english_stem", 1),
mapping_row("app", "english_cfg", "word", "app", "english_stem", 1),
mapping_row("app", "english_cfg", "word", "pg_catalog", "simple", 2),
];
let cfgs = assemble_ts_configurations(&[cfg_row], &map_rows).unwrap();
let c = &cfgs[0];
assert_eq!(c.mappings.len(), 2);
let asciiword = c
.mappings
.iter()
.find(|m| m.token_type == "asciiword")
.unwrap();
assert_eq!(asciiword.dictionaries.len(), 1);
assert_eq!(asciiword.dictionaries[0].to_string(), "app.english_stem");
let word = c.mappings.iter().find(|m| m.token_type == "word").unwrap();
assert_eq!(word.dictionaries.len(), 2);
assert_eq!(word.dictionaries[0].to_string(), "app.english_stem");
assert_eq!(word.dictionaries[1].to_string(), "pg_catalog.simple");
}
#[test]
fn multi_dict_seqno_order_is_preserved_when_rows_in_order() {
let cfg_row = config_row("app", "cfg", "pg_catalog", "default");
let map_rows = vec![
mapping_row("app", "cfg", "word", "app", "dict_a", 1),
mapping_row("app", "cfg", "word", "app", "dict_b", 2),
mapping_row("app", "cfg", "word", "app", "dict_c", 3),
];
let cfgs = assemble_ts_configurations(&[cfg_row], &map_rows).unwrap();
let m = &cfgs[0].mappings[0];
assert_eq!(m.dictionaries[0].name.as_str(), "dict_a");
assert_eq!(m.dictionaries[1].name.as_str(), "dict_b");
assert_eq!(m.dictionaries[2].name.as_str(), "dict_c");
}
#[test]
fn two_configs_mappings_split_correctly() {
let row_a = config_row("app", "cfg_a", "pg_catalog", "default");
let row_b = config_row("app", "cfg_b", "pg_catalog", "default");
let map_rows = vec![
mapping_row("app", "cfg_a", "word", "app", "dict1", 1),
mapping_row("app", "cfg_b", "asciiword", "pg_catalog", "simple", 1),
];
let cfgs = assemble_ts_configurations(&[row_a, row_b], &map_rows).unwrap();
assert_eq!(cfgs.len(), 2);
let a = cfgs
.iter()
.find(|c| c.qname.name.as_str() == "cfg_a")
.unwrap();
assert_eq!(a.mappings.len(), 1);
assert_eq!(a.mappings[0].token_type, "word");
let b = cfgs
.iter()
.find(|c| c.qname.name.as_str() == "cfg_b")
.unwrap();
assert_eq!(b.mappings.len(), 1);
assert_eq!(b.mappings[0].token_type, "asciiword");
}
#[test]
fn empty_config_rows_returns_empty_vec() {
let cfgs = assemble_ts_configurations(&[], &[]).unwrap();
assert!(cfgs.is_empty());
}
#[test]
fn empty_mapping_rows_all_configs_have_empty_mappings() {
let rows = vec![
config_row("app", "a", "pg_catalog", "default"),
config_row("app", "b", "pg_catalog", "default"),
];
let cfgs = assemble_ts_configurations(&rows, &[]).unwrap();
assert!(cfgs.iter().all(|c| c.mappings.is_empty()));
}
#[test]
fn parser_qname_is_schema_qualified() {
let row = config_row("app", "cfg", "pg_catalog", "default");
let cfgs = assemble_ts_configurations(&[row], &[]).unwrap();
assert_eq!(cfgs[0].parser.schema.as_str(), "pg_catalog");
assert_eq!(cfgs[0].parser.name.as_str(), "default");
}
}