Concept ยท OrionBelt® Analytics

OBQC: Ontology-Based Query Check

A deterministic, rule-based SQL validator that checks every generated query against the ontology before it reaches the database. Errors block execution. No LLM in the loop.

Definition

OBQC stands for Ontology-Based Query Check. It is the deterministic safety layer in OrionBelt® Analytics (OBA), and it validates SQL against the loaded RDF/OWL ontology before the query is allowed to reach the database.

There is no LLM inside OBQC and no probabilistic reasoning. It parses the SQL with sqlglot, compares what the query actually does against the ontology's oba: annotations (tables, columns, SQL types, primary and foreign keys, join conditions, relationship direction), and hands back structured errors or warnings the calling assistant can act on.

In one sentence: OBQC gives LLM-generated SQL a hard structural check against the ontology. Errors block execution before the query reaches the database, warnings travel back with the result.

The Problem: Plausible SQL That Is Still Wrong

An LLM will happily write SQL that reads perfectly and computes the wrong thing. The same handful of failures keep coming back, and all of them are structural:

The first three fail loudly sooner or later. The last one never does. It gives you a confident, wrong answer that looks exactly like a correct one, and that is where a deterministic check earns its keep, because a language model cannot help you here.

How OBQC Works

OBQC runs inside the session, against the ontology OBA generated or loaded for the connected database:

  1. OBA connects to a database and discovers the schema.
  2. generate_ontology() or load_my_ontology() creates or loads an ontology carrying oba: annotations for tables, columns, SQL types, primary keys, foreign keys, join conditions, and relationship direction.
  3. OBA creates a session-local validator from that ontology.
  4. When execute_sql_query() receives SQL, OBQC parses it with sqlglot.
  5. OBQC extracts the referenced tables, columns, joins, aggregations, aliases, and join anchors.
  6. Those parts are validated against the ontology cache.
  7. If there are no blocking errors, OBA executes the query and attaches any warnings to the response.
connect_database()
  -> discover_schema()
  -> generate_ontology()
  -> execute_sql_query()
       -> OBQC parses SQL
       -> OBQC checks ontology rules
       -> errors block, warnings attach
       -> database execution only if valid

execute_sql_query() is the only entry point that runs OBQC. If OBQC returns an error, OBA answers with an obqc_error response instead of executing anything. The database never sees the query.

Severity: What Blocks and What Warns

Severity Effect on execution
Error Query is blocked and never sent to the database. The response carries "success": false and the issue details.
Warning Query executes normally. Warnings are attached to the response so the assistant can self-correct or tell the user.
Info Informational note, no effect on execution.

The overall is_valid flag follows one rule: any ERROR-level issue makes the result invalid.

What OBQC Checks

Check Severity Purpose
Table existenceErrorEvery table in FROM and JOIN must exist in the ontology.
Column existenceErrorQualified and unqualified columns must resolve to real columns.
Ambiguous columnWarningAn unqualified name that exists in more than one referenced table.
Missing join conditionErrorMultiple tables in one SELECT with no JOIN ... ON, which is a Cartesian product.
Non-matching joinWarningA join condition that matches no declared foreign-key relationship.
Type compatibilityWarningWHERE and ON comparisons should use compatible types.
Aggregation correctnessErrorNon-aggregated selected columns must appear in GROUP BY.
Fan-trapErrorA measure aggregated across a one-to-many join, disjoint sibling facts, or two or more fan-out joins in one aggregating SELECT.
Conditional row countWarningA conditional count over a table the join repeats; the SQL does not say which count was meant.

Table and column existence

When a table is not found, OBQC lists the table names the ontology does know about, so the assistant can correct the name instead of guessing again.

-- ERROR: Table 'cusotmers' not found in ontology
SELECT * FROM cusotmers;

-- ERROR: Column 'emial' not found in table 'customers'
SELECT emial FROM customers;

Qualified references (table.column) are checked against that specific table. Unqualified references resolve within their own scope: the SELECT they appear in, plus any enclosing ones, so a correlated subquery can still reach the outer query's tables. It does not work the other way round. A subquery's tables never resolve names in the outer query.

Join validity

-- ERROR: Multiple tables without explicit JOIN (Cartesian product)
SELECT * FROM customers, orders;

-- WARNING: JOIN condition may not match declared FK relationship
-- Suggested: orders.customer_id = customers.id
SELECT * FROM customers JOIN orders ON customers.name = orders.note;

The Cartesian-product rule is judged per SELECT. That makes SELECT id FROM customers WHERE id IN (SELECT customer_id FROM orders) perfectly fine, since each scope only ever sees one table.

Aggregation correctness

-- ERROR: Column 'name' in SELECT with aggregation but no GROUP BY
SELECT name, COUNT(*) FROM customers;

-- ERROR: Column 'status' not in GROUP BY clause
SELECT status, region, SUM(amount) FROM orders GROUP BY region;

Grouping by an alias satisfies the check on the column behind it. The rule is again evaluated per SELECT, so an aggregate inside a subquery belongs to that subquery and nowhere else.

Fan-Trap Protection

A fan-trap happens when a query aggregates a measure after a join has repeated its rows. The SQL runs fine. The total is simply too high, because each row was counted once per matching row on the other side of the join. A wrong answer is worse than no answer, so a provable fan-trap blocks execution.

One join is enough. If the ontology puts shipments on the many side of sales, summing a sales amount across that join counts each sale once per shipment:

-- ERROR: Fan-trap: aggregating 'sales' across the join to 'shipments',
-- which the ontology puts on the many side of 'sales'.
SELECT SUM(s.amount)
FROM sales s
JOIN shipments sh ON sh.sale_id = s.id;

Which table sits in FROM makes no difference, because FROM shipments JOIN sales produces the same rows. A measure taken from the many side is fine, since the repeated rows are its own: SUM(sh.cost) per sale counts each shipment once.

OBQC reports four kinds of finding, strongest first:

FindingSeverityWhat it means
measure_across_fan_outErrorA measure read across a join that repeats its rows.
disjoint_factsErrorTwo sibling fact tables the ontology declares owl:disjointWith, at different grains, sharing a dimension.
multiple_fan_out_joinsErrorTwo or more fan-out joins in one aggregating SELECT, the fallback for ontologies without disjointness axioms.
conditional_row_countWarningA conditional count such as SUM(CASE WHEN o.total > 100 THEN 1 ELSE 0 END) over a table the join repeats. Reported, never blocked: the same shape is a correct idiom in a star join, and the SQL does not say which count was meant.

Aggregates that repeated rows cannot change are left alone. MIN, MAX and COUNT(DISTINCT ...) give the same answer however many times a row appears. COUNT(*) is a middle case: it names no table, so a single join never triggers the measure rule, but across two fan-out joins it returns the product of both children and the other rules still apply. Within an aggregate, only the columns that produce its value count as measures; a column tested in a CASE condition only filters.

Direction is what keeps the check precise. OBQC judges fan-out per join, against the table that join's ON condition attaches to, rather than asking whether a table sits on the many side of some relationship elsewhere in the schema. Walking a chain of many-to-one lookups (sales to clients to countries) is never flagged, however many foreign keys those dimensions carry.

The fix OBQC points to is to pre-aggregate the fanning table in a CTE and join the one row per key that produces, or to aggregate each fact separately and combine with UNION ALL. That is the same correction the Composite Fact Layer (CFL) applies automatically in the OrionBelt® Semantic Layer, where such queries come out correct by construction rather than being checked after the fact.

The structured verdict

Every execute_sql_query response carries obqc_fan_trap, whatever the outcome, so an agent or an automated flow can act on it without reading prose:

{
  "evaluated": true,
  "detected": true,
  "blocking": true,
  "findings": [
    {
      "kind": "measure_across_fan_out",
      "measure_table": "sales",
      "fan_out_table": "shipments",
      "tables": ["sales", "shipments"]
    }
  ]
}

A clean query reports detected: false, so "no fan-trap" is an answer you can read. evaluated: false means the rules never ran: no ontology loaded, an ontology without oba: annotations, or a request that failed before validation. Treat it as unknown, not as clean. A query can run and return inflated numbers with detected: false when there was nothing to check it against.

Running one anyway, with the user's consent

Sometimes the multiplied rows are what you want, or a relationship is one-to-one in practice despite its declared cardinality. The model can then pass allow_fan_out: true, which downgrades the finding to a warning and runs the query. The finding is still reported, and blocking becomes false.

The override is the model's to request, but the decision is the user's. When it actually downgrades a blocking finding, a client that supports elicitation asks the user first, in plain words and naming the tables, whether to accept inflated totals. A no, or a dismissed question, withdraws the override: the query fails with the normal blocking verdict, and the model is told to restructure the query rather than try again. A yes is recorded in the warning as accepted by the user. What OBQC detects is the same either way.

What OBQC Deliberately Does Not Flag

Three things sit outside the rules, because treating them as violations blocked SQL that was perfectly correct:

A validator that cries wolf gets switched off within a week. These exemptions exist so that every issue OBQC does report is worth reading.

How the AI Agent Uses OBQC

OBQC results come back as structured data in the tool response. They are not rendered for the user. The calling LLM receives:

When a query is blocked, the agent gets the error details and the suggestions, revises the SQL and tries again, usually without the user ever seeing the failed attempt. When warnings come back alongside a successful result, it is up to the agent whether to surface them ("this count may be counting order lines rather than orders").

Requirements

OBQC relies on oba: namespace annotations in the ontology. generate_ontology writes them automatically, and an ontology you edit or author yourself keeps working as long as it carries them:

If a loaded ontology lacks these annotations, a generic OWL ontology for instance, OBQC reports ontology_compatible: false and falls back to basic syntax checking only. The full annotation vocabulary is published at the oba: namespace.

An ontology uploaded with load_my_ontology has to pass an activation check before OBQC uses it: it must map tables, columns and joins with these annotations. One that does not is still loaded for SPARQL, the previously active ontology stays in force, and the response lists what is missing. An activated upload applies to the session that loaded it only. Join discovery without declared keys shows where an uploaded ontology fits in.

Related OrionBelt® Concepts

Frequently Asked Questions

What does OBQC stand for?

Ontology-Based Query Check. It is the deterministic safety layer in OrionBelt® Analytics (OBA), and it checks SQL against the loaded RDF/OWL ontology before the query reaches the database.

Does OBQC use an LLM?

No. OBQC makes no LLM calls and does no probabilistic reasoning. It parses the SQL with sqlglot, compares the structure against the ontology, and returns structured errors and warnings. The guarantees it gives are structural, which is exactly what LLM-generated SQL lacks.

What does OBQC check?

Table existence, column existence, ambiguous unqualified columns, join validity (missing join conditions and joins that do not match a declared foreign key), type compatibility in WHERE and ON comparisons, aggregation correctness, and fan-traps: a measure aggregated across a one-to-many join, sibling facts declared disjoint, or two or more fan-out joins in one aggregating query. Fan-traps block execution.

What is the difference between an error and a warning?

An error blocks execution: the query is never sent to the database and the tool returns the issue details instead. A warning lets the query run and is attached to the response, so the agent can revise it or explain the caveat. Info issues are informational only.

How does OBQC detect a fan-trap?

The ontology says which side of each join is the many side. One join is enough: summing sales while joined to shipments is blocked. OBQC also blocks sibling fact tables declared owl:disjointWith, and two or more fan-out joins in one aggregating SELECT. Fan-out is judged per join, so a chain of many-to-one lookups is never flagged, and MIN, MAX and COUNT(DISTINCT ...) are left alone.

Can a fan-trap block be overridden?

Yes, with allow_fan_out: true, when the multiplied rows are intended. Where the client supports it, the user is asked first, naming the tables, whether to accept inflated totals. Declining withdraws the override and the query stays blocked.

What does OBQC need in order to run?

An ontology carrying oba: namespace annotations, produced by generate_ontology or loaded with load_my_ontology. Without one, or with a generic OWL ontology lacking those annotations, OBQC reports ontology_compatible: false and falls back to basic syntax checking only. An uploaded ontology must pass an activation check before OBQC uses it.

Wrong SQL never reaches your database

OBQC ships with OrionBelt® Analytics, the ontology-based MCP server for Text-to-SQL across 8 database engines.

OrionBelt® Analytics Docs GitHub oba: Namespace Contact RALFORION