Skip to main content

ontocore_query/
sql.rs

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