Rules for the Intermediate Query Language (IQL)
Overview
Before SQL is generated, every KQL query is transformed into an Intermediate Query Language (IQL) — an intermediate object model that sits between KQL and SQL. KQL expresses intent; IQL makes the structural decisions explicit (grouping, having, join order); SQL is the final, dialect-specific output:
KQL → IQL → SQL
All transformation rules operate exclusively on the IQL object model. After rule processing is complete, the final SQL statement is generated from the transformed IQL.
Transformation Rules
1. Push Logical Expressions to Outer-Joined Tables
Rule: PushLogicalExpressionRule
Optional linked tables may contain additional filter conditions.
In SQL, such conditions must be placed inside the ON clause of the OUTER JOIN.
If they are placed in the WHERE clause, the OUTER JOIN semantics are destroyed and effectively converted into an INNER JOIN.
PushLogicalExpressionRule moves logical expressions from the SELECT filter or having to the appropriate joined table.
Example
Move the expression
o.order_date BETWEEN "2023-01-01" AND "2023-12-31"
to table orders.
// give customers and optional count of orders in 2023
FIND customers c, c+orders o
FILTER o.order_date BETWEEN "2023-01-01" AND "2023-12-31"
FETCH c.company_name, count(o)Expected SQL
SELECT
c.company_name
, count(o.order_id)
FROM
customers c
LEFT OUTER JOIN orders o ON
c.customer_id = o.customer_id
AND
o.order_date >= DATE '2023-01-01'
AND
o.order_date < DATE '2024-01-01'
GROUP BY
c.company_nameIncorrect SQL (Without Rule Application)
SELECT
c.company_name
, count(o.order_id)
FROM
customers c
LEFT OUTER JOIN orders o ON
c.customer_id = o.customer_id
WHERE
o.order_date >= DATE '2023-01-01'
AND
o.order_date < DATE '2024-01-01'
GROUP BY
c.company_nameThis invalid transformation destroys OUTER JOIN behavior.
The user explicitly requested an optional count of orders; therefore, the LEFT OUTER JOIN semantics must be preserved.
2. Add GROUP Expressions for Non-Aggregate OUT Expressions
Rule: GroupRule
If at least one aggregate expression appears in the OUT clause, SQL requires all non-aggregate expressions to be included in the GROUP BY clause.
In the previous example, count(o) is detected as an aggregate expression.
Therefore, c.company_name is automatically added to the GROUP BY clause.
3. Push Aggregate Filter Expressions to HAVING Clause
Rule: HavingRule
Filter expressions that contain aggregate functions must not appear in the WHERE clause.
Instead, they must be placed in the HAVING clause.
Example
// Find customers who have placed more than 10 orders in 2023,
// return companyname and count, sort by count.
FIND customers c, c orders o
FILTER count(o) > 10 AND o.order_date BETWEEN "2023-01-01" AND "2023-12-31"
FETCH c.company_name, count(o) DESCThis query uses an INNER JOIN.
The expression count(o) > 10 is an aggregate expression.
Expected SQL
SELECT
c.company_name
, count(o.order_id)
FROM
customers c
INNER JOIN orders o ON
c.customer_id = o.customer_id
WHERE
o.order_date >= DATE '2023-01-01'
AND
o.order_date < DATE '2024-01-01'
GROUP BY
c.company_name
HAVING
count(o.order_id) > 10
ORDER BY
count(o.order_id) DESCPlacing count(o) > 10 inside the WHERE clause would result in invalid SQL.
4. Push Expressions to Tables
Expressions are classified as:
- Homogeneous expressions — reference at most one table.
- Heterogeneous expressions — reference two or more tables.
Homogeneous expressions are assigned to their referenced table.
Heterogeneous expressions remain at the SELECT level.
4.1 Push OUT Expressions
Rule: PushOutRule
Homogeneous expressions in the OUT clause of SELECT are pushed down to the referenced table.
4.2 Push GROUP Expressions
Rule: PushGroupRule
Homogeneous expressions in the GROUP of SELECT clause are pushed down to the referenced table.
4.3 Push ORDER Expressions
Rule: PushOrderRule
Homogeneous expressions in the ORDER clause of SELECT are pushed down to the referenced table.
5. Identity Rule
Rule: IdentityRule
KQL supports intuitive expressions such as:
count(o)This differs from SQL, where a column reference (e.g., count(o.order_id)) or count(*) is required.
The IdentityRule transforms entity-based count expressions into valid SQL column-based count expressions.
Both table-count and column-count semantics are supported.
What an entity resolves to, and why it depends on the question
Naming an entity means the rows, and that is a shorthand — it holds exactly as far as there is one unambiguous way to resolve it. There are two questions for which there is:
| resolves to | why | |
|---|---|---|
count(o) |
the first key column | a key is never null, so counting one column counts rows. Under an outer join it is also the better choice: count(o.order_id) counts 0 for an unmatched row, where COUNT(*) would count 1 |
count_distinct(o) |
every key column | two rows differ by their whole key, not by part of it |
Everything else is refused (IdentityUseValidator): there is no smallest order line, so min(od)
is not a question with an answer.
The composite key is what makes the distinction visible
Where a key is one column the two rows of that table coincide — the same column is both "any non-null column" and "the whole key" — and the rule got away with resolving an entity to the first key column always. A composite key separates them, and the second reading was missing:
FIND order_details od FETCH count_distinct(od) norder_details is keyed by (order_id, product_id). Resolving to order_id alone made this count
orders: 830 rows where the answer is 2155, silently, because every line of one order collapsed
into one. The same held through a query block, and for employee_territories (9 instead of 49).
So the rule now asks which function it stands in, and count_distinct expands to the full key. How
that reaches SQL is the dialect's business — see docs/functions/aggregate.md: a row constructor on
DuckDB, PostgreSQL and Trino, a comma list on MariaDB and Snowflake, and a refusal naming the
concatenation workaround on Oracle, SQL Server and SQLite.
An entity never outlives this rule. Both ExpressionTypeResolver and SqlSelectRenderer refuse
an identity outright — "identity is not allowed here". Neither guesses a type or a column for it,
so a composite key cannot be half-answered somewhere downstream: either the rule resolved the entity,
or nothing did and the guard says so. Those two throws are internal invariants rather than user
errors, which is why they carry no position: what the author can get wrong is caught earlier, by
IdentityUseValidator, with one.
6. Infer OUT Expressions for Query Blocks
Rule: InferJoinColumnsToBlockRule
KQL abstracts join columns and does not require them to be explicitly returned.
When query blocks (CTEs) are linked to other tables, required join columns must be added automatically to the intermediate model to ensure valid SQL generation.
InferJoinColumnsToBlockRule performs this enrichment.
Example
// find the customer with the highest sales: the sum of unit_price * quantity of its orders,
// order by sum and limit the result to the first row
WITH sales AS (
FIND orders o, o order_details d
FETCH sum(d.unit_price * d.quantity) total DESC
LIMIT 1
)
FIND customers c, c sales s
FETCH c.company_name, s.totalThe sales query block is linked to customers.
Therefore, the join column o.customer_id must be added automatically to the block.
The inferred column is held to the same rule as any other: it has to be an attribute of the entity in the
model. A model that leaves a foreign key out (the English northwind model has no employee_id on
orders) does not expose that join, and a block that would need it is refused with
schema.unknown-column, at the entity the block starts from. That is intended: the model decides which
columns a query may name, also the ones that a rule adds on its behalf. To join through such a column,
return it in the block, or give the entity the attribute.
Expected SQL
WITH sales (total, customer_id) AS (
SELECT
sum(d.unit_price * d.quantity) AS total
, o.customer_id
FROM
orders o
INNER JOIN order_details d ON
o.order_id = d.order_id
GROUP BY
o.customer_id
ORDER BY
sum(d.unit_price * d.quantity) DESC
FETCH FIRST 1 ROWS ONLY
)
SELECT
c.company_name
, s.total
FROM
customers c
INNER JOIN sales s ON
c.customer_id = s.customer_idWithout automatic inference of the join column, the JOIN condition in the outer query would be invalid.
7. Selector Names
Rule: SelectorNamesRule
A selector names a field of a struct, and a model can give the fields the words of its language:
t.address@breitengrad under the German model is the field latitude of the column. The rule runs after
the IdentityRule and rewrites each field name from the language of the model to the key of the column's
concept, so that the validators and the renderers speak physical keys only.
The base of the selector may be a field of a table, of a block (the rule follows it to the column the
block projects) or of a source inside an EXISTS. A name the model does not know stays as written, and so
does any other base, a function result or a computed output: a name the language does not translate is a
physical key, which is what a query wrote before models had member names. The same holds for the
fields of the alias of an unnest (i.artikel for i.sku): the rows are typed from the list, and the
rule translates the word with the concept of the element. See
Members and notions.
Summary
The intermediate model enables deterministic, rule-based SQL generation. All transformation rules operate on the structured object representation of the KQL query to ensure:
- Preservation of join semantics
- Correct aggregate handling
- Valid SQL clause placement
- Proper grouping and ordering
- Automatic join column inference for query blocks
This layered approach ensures that generated SQL remains syntactically valid and semantically consistent with the original KQL intent.