Skip to main content

ontocore_query/
sql.rs

1use crate::schema::list_sql_tables;
2use crate::QueryError;
3use ontocore_catalog::OntologyCatalog;
4use ontocore_core::{
5    limits::MAX_QUERY_BYTES, limits::MAX_SQL_RESULT_ROWS, EntityKind, AXIOM_KIND_DISJOINT_CLASS,
6    AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF,
7};
8use serde::Serialize;
9use sqlparser::ast::{
10    Expr, GroupByExpr, Select, SelectItem, SetExpr, Statement, TableFactor, Value,
11};
12use sqlparser::dialect::GenericDialect;
13use sqlparser::parser::Parser;
14use std::collections::{BTreeMap, HashSet};
15
16pub type Result<T> = std::result::Result<T, QueryError>;
17
18type Row = BTreeMap<String, String>;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct QueryResult {
22    pub columns: Vec<String>,
23    pub rows: Vec<Row>,
24    pub truncated: bool,
25}
26
27pub fn run_sql(catalog: &OntologyCatalog, sql: &str) -> Result<QueryResult> {
28    if sql.len() > MAX_QUERY_BYTES {
29        return Err(QueryError::Sql(format!(
30            "query exceeds maximum length of {MAX_QUERY_BYTES} bytes"
31        )));
32    }
33
34    let dialect = GenericDialect {};
35    let statements =
36        Parser::parse_sql(&dialect, sql).map_err(|e| QueryError::Sql(e.to_string()))?;
37
38    if statements.len() > 1 {
39        return Err(QueryError::Sql("only a single SQL statement is supported".to_string()));
40    }
41
42    let statement =
43        statements.into_iter().next().ok_or_else(|| QueryError::Sql("empty query".to_string()))?;
44
45    match statement {
46        Statement::Query(query) => {
47            if query.order_by.is_some() {
48                return Err(QueryError::Sql("ORDER BY is not supported".to_string()));
49            }
50            if query.limit.is_some() || query.offset.is_some() {
51                return Err(QueryError::Sql("LIMIT and OFFSET are not supported".to_string()));
52            }
53            let select = match *query.body {
54                SetExpr::Select(select) => select,
55                _ => return Err(QueryError::Sql("only SELECT queries are supported".to_string())),
56            };
57            execute_select(catalog, select)
58        }
59        _ => Err(QueryError::Sql("only SELECT queries are supported".to_string())),
60    }
61}
62
63fn execute_select(catalog: &OntologyCatalog, select: Box<Select>) -> Result<QueryResult> {
64    if select.distinct.is_some() {
65        return Err(QueryError::Sql("DISTINCT is not supported".to_string()));
66    }
67    if group_by_present(&select.group_by) {
68        return Err(QueryError::Sql("GROUP BY is not supported".to_string()));
69    }
70    if select.having.is_some() {
71        return Err(QueryError::Sql("HAVING is not supported".to_string()));
72    }
73    if select.from.len() > 1 || select.from.iter().any(|t| !t.joins.is_empty()) {
74        return Err(QueryError::Sql("JOIN is not supported".to_string()));
75    }
76    let table_name = table_name_from_select(&select)?;
77    let known_columns = known_columns_for_table(&table_name)?;
78    validate_projection(&select.projection, &known_columns)?;
79    if let Some(selection) = &select.selection {
80        if matches!(selection, Expr::Identifier(_)) {
81            return Err(QueryError::Sql(
82                "bare column names are not supported in WHERE; use column = 'value'".to_string(),
83            ));
84        }
85        validate_filter(selection, &known_columns)?;
86    }
87
88    let mut rows = Vec::new();
89    let mut truncated = false;
90    for row in table_row_iter(catalog, &table_name)? {
91        if let Some(selection) = &select.selection {
92            if !evaluate_filter(selection, &row)? {
93                continue;
94            }
95        }
96        if rows.len() >= MAX_SQL_RESULT_ROWS {
97            truncated = true;
98            break;
99        }
100        rows.push(row);
101    }
102
103    let (columns, projected_rows) = project_rows(&select.projection, &rows)?;
104    Ok(QueryResult { columns, rows: projected_rows, truncated })
105}
106
107fn known_columns_for_table(table: &str) -> Result<HashSet<String>> {
108    list_sql_tables()
109        .into_iter()
110        .find(|t| t.name == table)
111        .map(|t| t.columns.into_iter().map(|c| c.name).collect())
112        .ok_or_else(|| QueryError::Sql(format!("unknown table: {table}")))
113}
114
115fn ensure_known_column(known: &HashSet<String>, name: &str) -> Result<()> {
116    if known.contains(name) {
117        Ok(())
118    } else {
119        Err(QueryError::Sql(format!("unknown column: {name}")))
120    }
121}
122
123fn validate_projection(projection: &[SelectItem], known: &HashSet<String>) -> Result<()> {
124    if projection.len() == 1 && matches!(projection[0], SelectItem::Wildcard(_)) {
125        return Ok(());
126    }
127    for col in projection_columns(projection)? {
128        ensure_known_column(known, &col.source)?;
129    }
130    Ok(())
131}
132
133fn table_name_from_select(select: &Select) -> Result<String> {
134    let from =
135        select.from.first().ok_or_else(|| QueryError::Sql("missing FROM clause".to_string()))?;
136
137    match &from.relation {
138        TableFactor::Table { name, .. } => Ok(name.to_string().to_ascii_lowercase()),
139        _ => Err(QueryError::Sql("unsupported table expression".to_string())),
140    }
141}
142
143fn table_row_iter<'a>(
144    catalog: &'a OntologyCatalog,
145    table: &str,
146) -> Result<Box<dyn Iterator<Item = Row> + 'a>> {
147    let data = catalog.data();
148    match table {
149        "ontologies" => Ok(Box::new(data.documents.iter().map(|doc| {
150            let mut row = BTreeMap::new();
151            row.insert("id".into(), doc.id.clone());
152            row.insert("path".into(), doc.path.display().to_string());
153            row.insert("format".into(), doc.format.as_str().to_string());
154            row.insert("base_iri".into(), doc.base_iri.clone().unwrap_or_default());
155            row.insert("parse_status".into(), doc.parse_status.as_str().to_string());
156            row.insert("content_hash".into(), doc.content_hash.clone());
157            row.insert("modified_time".into(), doc.modified_time.to_string());
158            row
159        }))),
160        "classes" => entity_row_iter(catalog, EntityKind::Class),
161        "object_properties" => entity_row_iter(catalog, EntityKind::ObjectProperty),
162        "data_properties" => entity_row_iter(catalog, EntityKind::DataProperty),
163        "annotation_properties" => entity_row_iter(catalog, EntityKind::AnnotationProperty),
164        "individuals" => entity_row_iter(catalog, EntityKind::Individual),
165        "entities" => Ok(Box::new(data.entities.iter().map(entity_to_row))),
166        "annotations" => Ok(Box::new(data.annotations.iter().map(|a| {
167            let mut row = BTreeMap::new();
168            row.insert("subject".into(), a.subject.clone());
169            row.insert("predicate".into(), a.predicate.clone());
170            row.insert("object".into(), a.object.clone());
171            row.insert("ontology_id".into(), a.ontology_id.clone());
172            row
173        }))),
174        "axioms" => Ok(Box::new(data.axioms.iter().map(|a| {
175            let mut row = BTreeMap::new();
176            row.insert("id".into(), a.id.clone());
177            row.insert("ontology_id".into(), a.ontology_id.clone());
178            row.insert("subject".into(), a.subject.clone());
179            row.insert("predicate".into(), a.predicate.clone());
180            row.insert("object".into(), a.object.clone());
181            row.insert("axiom_kind".into(), a.axiom_kind.clone());
182            row
183        }))),
184        "namespaces" => Ok(Box::new(data.namespaces.iter().map(|n| {
185            let mut row = BTreeMap::new();
186            row.insert("prefix".into(), n.prefix.clone());
187            row.insert("iri".into(), n.iri.clone());
188            row.insert("ontology_id".into(), n.ontology_id.clone());
189            row
190        }))),
191        "imports" => Ok(Box::new(data.imports.iter().map(|i| {
192            let mut row = BTreeMap::new();
193            row.insert("ontology_id".into(), i.ontology_id.clone());
194            row.insert("import_iri".into(), i.import_iri.clone());
195            row
196        }))),
197        "diagnostics" => Ok(Box::new(data.diagnostics.iter().map(|d| {
198            let mut row = BTreeMap::new();
199            row.insert("code".into(), d.code.as_str().to_string());
200            row.insert("severity".into(), d.severity.as_str().to_string());
201            row.insert("message".into(), d.message.clone());
202            row.insert("file".into(), d.file.display().to_string());
203            row.insert("line".into(), d.range.line.map(|l| l.to_string()).unwrap_or_default());
204            row.insert("column".into(), d.range.column.map(|c| c.to_string()).unwrap_or_default());
205            row.insert("entity_iri".into(), d.entity_iri.clone().unwrap_or_default());
206            row
207        }))),
208        "equivalent_class_axioms" => {
209            axiom_kind_rows(catalog, AXIOM_KIND_EQUIVALENT_CLASS, "class_iri", "expression")
210        }
211        "disjoint_class_axioms" => {
212            axiom_kind_rows(catalog, AXIOM_KIND_DISJOINT_CLASS, "class_iri", "disjoint_with")
213        }
214        "domain_axioms" => axiom_kind_rows(catalog, AXIOM_KIND_DOMAIN, "property_iri", "domain"),
215        "range_axioms" => axiom_kind_rows(catalog, AXIOM_KIND_RANGE, "property_iri", "range"),
216        "restrictions" => restriction_rows(catalog),
217        "properties" => {
218            let mut iter: Box<dyn Iterator<Item = Row>> = Box::new(std::iter::empty());
219            for kind in [
220                EntityKind::ObjectProperty,
221                EntityKind::DataProperty,
222                EntityKind::AnnotationProperty,
223            ] {
224                let next = entity_row_iter(catalog, kind)?;
225                iter = Box::new(iter.chain(next));
226            }
227            Ok(iter)
228        }
229        other => Err(QueryError::Sql(format!("unknown table: {other}"))),
230    }
231}
232
233fn entity_row_iter(
234    catalog: &OntologyCatalog,
235    kind: EntityKind,
236) -> Result<Box<dyn Iterator<Item = Row> + '_>> {
237    Ok(Box::new(catalog.data().entities.iter().filter(move |e| e.kind == kind).map(entity_to_row)))
238}
239
240fn axiom_kind_rows<'a>(
241    catalog: &'a OntologyCatalog,
242    kind: &str,
243    col_a: &str,
244    col_b: &str,
245) -> Result<Box<dyn Iterator<Item = Row> + 'a>> {
246    let col_a = col_a.to_string();
247    let col_b = col_b.to_string();
248    let kind = kind.to_string();
249    Ok(Box::new(catalog.data().axioms.iter().filter(move |a| a.axiom_kind == kind).map(move |a| {
250        let mut row = BTreeMap::new();
251        row.insert(col_a.clone(), a.subject.clone());
252        row.insert(col_b.clone(), a.object.clone());
253        row
254    })))
255}
256
257fn restriction_rows<'a>(
258    catalog: &'a OntologyCatalog,
259) -> Result<Box<dyn Iterator<Item = Row> + 'a>> {
260    Ok(Box::new(
261        catalog
262            .data()
263            .axioms
264            .iter()
265            .filter(|a| a.axiom_kind == AXIOM_KIND_SUB_CLASS_OF && is_restriction_expr(&a.object))
266            .map(|a| {
267                let (property_iri, restriction_kind) = parse_restriction_header(&a.object);
268                let mut row = BTreeMap::new();
269                row.insert("class_iri".into(), a.subject.clone());
270                row.insert("property_iri".into(), property_iri);
271                row.insert("restriction_kind".into(), restriction_kind);
272                row.insert("filler".into(), a.object.clone());
273                row
274            }),
275    ))
276}
277
278fn is_restriction_expr(expr: &str) -> bool {
279    let lower = expr.to_ascii_lowercase();
280    lower.contains(" some ")
281        || lower.contains(" only ")
282        || lower.contains(" value ")
283        || lower.contains(" min ")
284        || lower.contains(" max ")
285        || has_self_restriction_token(&lower)
286}
287
288/// Match a `Self` / `ObjectHasSelf` restriction token, not IRI local names like `Myself`.
289fn has_self_restriction_token(lower: &str) -> bool {
290    lower == "self"
291        || lower.starts_with("self ")
292        || lower.ends_with(" self")
293        || lower.contains(" self ")
294        || lower.contains("objecthasself")
295}
296
297fn parse_restriction_header(expr: &str) -> (String, String) {
298    let trimmed = expr.trim();
299    for kind in ["some", "only", "value", "min", "max", "self"] {
300        let needle = format!(" {kind} ");
301        if let Some(idx) = trimmed.to_ascii_lowercase().find(&needle) {
302            let property = trimmed[..idx].trim().to_string();
303            return (property, kind.to_string());
304        }
305        if trimmed.to_ascii_lowercase().ends_with(" self") && kind == "self" {
306            let property = trimmed[..trimmed.len() - " self".len()].trim().to_string();
307            return (property, kind.to_string());
308        }
309    }
310    (String::new(), "complex".to_string())
311}
312
313fn entity_to_row(entity: &ontocore_core::Entity) -> Row {
314    let mut row = BTreeMap::new();
315    row.insert("iri".into(), entity.iri.clone());
316    row.insert("short_name".into(), entity.short_name.clone());
317    row.insert("kind".into(), entity.kind.as_str().to_string());
318    row.insert("ontology_id".into(), entity.ontology_id.clone());
319    row.insert("labels".into(), entity.labels.join("; "));
320    row.insert("comments".into(), entity.comments.join("; "));
321    row.insert("deprecated".into(), entity.deprecated.to_string());
322    row.insert("obo_id".into(), entity.obo_id.clone().unwrap_or_default());
323    row
324}
325
326struct ProjectionCol {
327    name: String,
328    source: String,
329}
330
331fn projection_columns(projection: &[SelectItem]) -> Result<Vec<ProjectionCol>> {
332    if projection.len() == 1 && matches!(projection[0], SelectItem::Wildcard(_)) {
333        return Ok(Vec::new());
334    }
335
336    let mut columns = Vec::new();
337    for item in projection {
338        match item {
339            SelectItem::UnnamedExpr(Expr::Identifier(ident)) => {
340                let col = ident.value.to_ascii_lowercase();
341                columns.push(ProjectionCol { name: col.clone(), source: col });
342            }
343            SelectItem::ExprWithAlias { expr, alias, .. } => {
344                let source = match expr {
345                    Expr::Identifier(ident) => ident.value.to_ascii_lowercase(),
346                    _ => {
347                        return Err(QueryError::Sql(
348                            "only simple column projections are supported".to_string(),
349                        ));
350                    }
351                };
352                columns.push(ProjectionCol { name: alias.value.to_ascii_lowercase(), source });
353            }
354            SelectItem::Wildcard(_) => {
355                return Err(QueryError::Sql("wildcard projection must be used alone".to_string()));
356            }
357            _ => {
358                return Err(QueryError::Sql(
359                    "only simple column projections are supported".to_string(),
360                ));
361            }
362        }
363    }
364    Ok(columns)
365}
366
367fn project_rows(projection: &[SelectItem], rows: &[Row]) -> Result<(Vec<String>, Vec<Row>)> {
368    if projection.len() == 1 && matches!(projection[0], SelectItem::Wildcard(_)) {
369        let columns = rows.first().map(|r| r.keys().cloned().collect()).unwrap_or_default();
370        return Ok((columns, rows.to_vec()));
371    }
372
373    let columns_spec = projection_columns(projection)?;
374    let columns: Vec<String> = columns_spec.iter().map(|c| c.name.clone()).collect();
375
376    let projected = rows
377        .iter()
378        .map(|row| {
379            let mut out = BTreeMap::new();
380            for col in &columns_spec {
381                out.insert(col.name.clone(), row.get(&col.source).cloned().unwrap_or_default());
382            }
383            out
384        })
385        .collect();
386
387    Ok((columns, projected))
388}
389
390fn group_by_present(group_by: &GroupByExpr) -> bool {
391    match group_by {
392        GroupByExpr::All(_) => true,
393        GroupByExpr::Expressions(exprs, _) => !exprs.is_empty(),
394    }
395}
396
397fn validate_filter(expr: &Expr, known: &HashSet<String>) -> Result<()> {
398    match expr {
399        Expr::BinaryOp { left, op, right } => {
400            use sqlparser::ast::BinaryOperator;
401            match op {
402                BinaryOperator::Eq
403                | BinaryOperator::NotEq
404                | BinaryOperator::And
405                | BinaryOperator::Or => {
406                    validate_filter(left, known)?;
407                    validate_filter(right, known)?;
408                    Ok(())
409                }
410                other => Err(QueryError::Sql(format!("unsupported WHERE operator: {other:?}"))),
411            }
412        }
413        Expr::Identifier(ident) => ensure_known_column(known, &ident.value.to_ascii_lowercase()),
414        Expr::Value(_) => Ok(()),
415        other => Err(QueryError::Sql(format!("unsupported WHERE expression: {other:?}"))),
416    }
417}
418
419fn evaluate_filter(expr: &Expr, row: &Row) -> Result<bool> {
420    match expr {
421        Expr::BinaryOp { left, op, right } => {
422            use sqlparser::ast::BinaryOperator;
423            match op {
424                BinaryOperator::Eq => Ok(eval_expr(left, row)? == eval_expr(right, row)?),
425                BinaryOperator::NotEq => Ok(eval_expr(left, row)? != eval_expr(right, row)?),
426                BinaryOperator::And => {
427                    Ok(evaluate_filter(left, row)? && evaluate_filter(right, row)?)
428                }
429                BinaryOperator::Or => {
430                    Ok(evaluate_filter(left, row)? || evaluate_filter(right, row)?)
431                }
432                other => Err(QueryError::Sql(format!("unsupported WHERE operator: {other:?}"))),
433            }
434        }
435        Expr::Identifier(ident) => {
436            let key = ident.value.to_ascii_lowercase();
437            match row.get(&key) {
438                Some(v) => Ok(v == "true"),
439                None => Err(QueryError::Sql(format!("unknown column: {key}"))),
440            }
441        }
442        Expr::Value(Value::Boolean(b)) => Ok(*b),
443        other => Err(QueryError::Sql(format!("unsupported WHERE expression: {other:?}"))),
444    }
445}
446
447fn eval_expr(expr: &Expr, row: &Row) -> Result<String> {
448    match expr {
449        Expr::Identifier(ident) => {
450            let key = ident.value.to_ascii_lowercase();
451            // Schema columns (e.g. obo_id) are always present; missing values are empty.
452            Ok(row.get(&key).cloned().unwrap_or_default())
453        }
454        Expr::Value(Value::SingleQuotedString(s) | Value::DoubleQuotedString(s)) => Ok(s.clone()),
455        Expr::Value(Value::Boolean(b)) => Ok(b.to_string()),
456        Expr::Value(Value::Number(n, _)) => Ok(n.clone()),
457        other => Err(QueryError::Sql(format!("unsupported expression: {other:?}"))),
458    }
459}
460
461pub fn to_csv(result: &QueryResult) -> Result<String> {
462    let mut writer = csv::Writer::from_writer(Vec::new());
463    writer.write_record(&result.columns).map_err(|e| crate::QueryError::Export(e.to_string()))?;
464    for row in &result.rows {
465        let values: Vec<String> =
466            result.columns.iter().map(|c| row.get(c).cloned().unwrap_or_default()).collect();
467        writer.write_record(&values).map_err(|e| crate::QueryError::Export(e.to_string()))?;
468    }
469    let bytes = writer.into_inner().map_err(|e| crate::QueryError::Export(e.to_string()))?;
470    String::from_utf8(bytes).map_err(|e| crate::QueryError::Export(e.to_string()))
471}
472
473pub fn to_json(result: &QueryResult) -> Result<String> {
474    let rows: Vec<Vec<String>> = result
475        .rows
476        .iter()
477        .map(|row| result.columns.iter().map(|c| row.get(c).cloned().unwrap_or_default()).collect())
478        .collect();
479    serde_json::to_string_pretty(&serde_json::json!({
480        "columns": result.columns,
481        "rows": rows,
482        "truncated": result.truncated,
483    }))
484    .map_err(|e| crate::QueryError::Export(e.to_string()))
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use ontocore_catalog::OntologyCatalog;
491    use ontocore_core::limits::MAX_QUERY_BYTES;
492    use std::path::PathBuf;
493
494    fn fixture_catalog() -> OntologyCatalog {
495        let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
496        ontocore_catalog::IndexBuilder::new().workspace(fixtures).build().expect("index fixtures")
497    }
498
499    #[test]
500    fn where_and_filters_rows() {
501        let catalog = fixture_catalog();
502        let result = run_sql(
503            &catalog,
504            "SELECT short_name FROM classes WHERE short_name = 'Person' AND deprecated = 'false'",
505        )
506        .expect("and filter");
507        assert_eq!(result.rows.len(), 1);
508        assert_eq!(result.rows[0].get("short_name").map(String::as_str), Some("Person"));
509    }
510
511    #[test]
512    fn where_or_filters_rows() {
513        let catalog = fixture_catalog();
514        let result = run_sql(
515            &catalog,
516            "SELECT short_name FROM classes WHERE short_name = 'Person' OR short_name = 'Thing'",
517        )
518        .expect("or filter");
519        let names: Vec<_> =
520            result.rows.iter().filter_map(|r| r.get("short_name").cloned()).collect();
521        assert!(names.contains(&"Person".to_string()));
522        assert!(names.contains(&"Thing".to_string()));
523    }
524
525    #[test]
526    fn where_not_eq_excludes_matching_row() {
527        let catalog = fixture_catalog();
528        let result =
529            run_sql(&catalog, "SELECT short_name FROM classes WHERE short_name != 'Person'")
530                .expect("not eq filter");
531        assert!(!result
532            .rows
533            .iter()
534            .any(|r| r.get("short_name").map(String::as_str) == Some("Person")));
535        assert!(!result.rows.is_empty());
536    }
537
538    #[test]
539    fn unsupported_like_returns_error() {
540        let catalog = fixture_catalog();
541        let err = run_sql(&catalog, "SELECT short_name FROM classes WHERE short_name LIKE 'Per%'")
542            .unwrap_err();
543        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("unsupported WHERE")));
544    }
545
546    #[test]
547    fn unsupported_limit_returns_error() {
548        let catalog = fixture_catalog();
549        let err = run_sql(&catalog, "SELECT short_name FROM classes LIMIT 1").unwrap_err();
550        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("LIMIT")));
551    }
552
553    #[test]
554    fn unsupported_having_returns_error() {
555        let catalog = fixture_catalog();
556        let err = run_sql(&catalog, "SELECT short_name FROM classes HAVING short_name = 'Person'")
557            .unwrap_err();
558        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("HAVING")));
559    }
560
561    #[test]
562    fn unsupported_join_keyword_returns_error() {
563        let catalog = fixture_catalog();
564        let err =
565            run_sql(&catalog, "SELECT short_name FROM classes JOIN ontologies ON 1=1").unwrap_err();
566        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("JOIN")));
567    }
568
569    #[test]
570    fn unsupported_comma_join_returns_error() {
571        let catalog = fixture_catalog();
572        let err = run_sql(&catalog, "SELECT * FROM classes, ontologies").unwrap_err();
573        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("JOIN")));
574    }
575
576    #[test]
577    fn rejects_oversized_query() {
578        let catalog = fixture_catalog();
579        let padding = "x".repeat(MAX_QUERY_BYTES);
580        let sql = format!("SELECT short_name FROM classes WHERE short_name = '{padding}'");
581        let err = run_sql(&catalog, &sql).unwrap_err();
582        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("maximum length")));
583    }
584
585    #[test]
586    fn select_alias_uses_source_column() {
587        let catalog = fixture_catalog();
588        let result =
589            run_sql(&catalog, "SELECT short_name AS name FROM classes WHERE short_name = 'Person'")
590                .expect("alias projection");
591        assert_eq!(result.rows.len(), 1);
592        assert_eq!(result.rows[0].get("name").map(String::as_str), Some("Person"));
593    }
594
595    #[test]
596    fn unknown_select_column_returns_error() {
597        let catalog = fixture_catalog();
598        let err = run_sql(&catalog, "SELECT nonexistent FROM classes").unwrap_err();
599        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("unknown column")));
600    }
601
602    #[test]
603    fn unknown_where_column_returns_error() {
604        let catalog = fixture_catalog();
605        let err =
606            run_sql(&catalog, "SELECT short_name FROM classes WHERE nonexistent = ''").unwrap_err();
607        assert!(matches!(err, crate::QueryError::Sql(msg) if msg.contains("unknown column")));
608    }
609
610    #[test]
611    fn optional_obo_id_column_is_allowed() {
612        let catalog = fixture_catalog();
613        let result = run_sql(&catalog, "SELECT obo_id FROM classes").expect("obo_id projection");
614        assert!(!result.rows.is_empty());
615        assert!(result.rows.iter().all(|r| r.contains_key("obo_id")));
616    }
617
618    #[test]
619    fn select_star_always_includes_obo_id_column() {
620        let catalog = fixture_catalog();
621        let result = run_sql(&catalog, "SELECT * FROM entities").expect("select *");
622        assert!(result.columns.iter().any(|c| c == "obo_id"));
623        assert!(result.rows.iter().all(|r| r.contains_key("obo_id")));
624    }
625
626    #[test]
627    fn self_substring_class_names_are_not_restrictions() {
628        assert!(!is_restriction_expr("ex:Myself"));
629        assert!(!is_restriction_expr("http://example.org/self#Thing"));
630        assert!(!is_restriction_expr("ex:selfish"));
631        assert!(is_restriction_expr("ex:partOf Self"));
632        assert!(is_restriction_expr("ex:partOf some ex:Organ"));
633        assert!(is_restriction_expr("ObjectHasSelf(ex:partOf)"));
634    }
635
636    #[test]
637    fn json_export_preserves_truncated_flag() {
638        let result = QueryResult {
639            columns: vec!["iri".into()],
640            rows: vec![BTreeMap::from([("iri".into(), "http://example.org/A".into())])],
641            truncated: true,
642        };
643        let json = to_json(&result).expect("json");
644        assert!(json.contains("\"truncated\": true"));
645    }
646}