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}