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