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
288fn 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 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}