Skip to main content

strixonomy_query/
sql.rs

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