← Back to list

My SQL Engine Finally Started Saying “No” | Stage 3

We’re back again.

zoolpher · 2026-05-25 14:39 · 0 claps · 3.7 min read
#cpp #google #microsoft #faang #sql
Open on Medium ↗

My SQL Engine Finally Started Saying “No” | Stage 3

We’re back again.

Stage 1 was the Lexer — breaking raw SQL strings into tokens.

Stage 2 was the Parser — taking those tokens and building an AST so the query actually had structure.

And now I’ve reached Stage 3: The Semantic Analyzer.

This is the stage where my SQL engine finally stopped trusting the query blindly.

Because until now, something like this:

SELECT power_level FROM users;

would happily pass through the lexer and parser even if power_level wasn't a real column.

The syntax is valid. The structure is valid.

But the query itself makes absolutely no sense.

That’s exactly what semantic analysis is for.

So What Does A Semantic Analyzer Actually Do?

The semantic analyzer validates the query against the database schema.

Basically:

  • does the table exist?
  • do the columns exist?
  • is the WHERE clause referencing real columns?

This is the first stage where my engine actually compares the query against something real instead of just checking grammar and structure.

And to make that possible, I needed something new: a Catalog.

The Catalog — Giving The Engine Memory

Up until now, my engine had no idea what tables or columns even existed.

The parser just built AST nodes mechanically and moved on.

So I added a tiny in-memory schema registry called the Catalog.

struct ColumnSchema {
    std::string col_name;
    std::string col_type;
};
struct TableSchema {
    std::string table_name;
    std::vector<ColumnSchema> columns;
};
struct Catalog {
    std::vector<TableSchema> tables;

    TableSchema* getTable(std::string name);
};

This is basically the engine’s memory.

It stores:

  • table names
  • column names
  • column types

Now the semantic analyzer can finally validate queries against actual schemas instead of blindly assuming everything exists.

The Semantic Analyzer

One thing I found interesting here was that the analyzer naturally split itself into multiple passes.

At first I tried validating everything in one traversal and it became messy very quickly.

So I separated it into three passes.

Each pass has exactly one job.

Pass 1 — Validate The Table

The analyzer first walks through the AST looking for the table name.

if (FromNode* f = dynamic_cast<FromNode*>(ast_node)) {
    table = catalog.getTable(f->table);
    if (table == nullptr) {
        throw std::runtime_error(
            "Error: table '" + f->table + "' does not exist"
        );
    }
}

So this query:

SELECT name FROM users;

passes.

But this:

SELECT name FROM definitely_not_real;

fails immediately.

This was the first moment where my engine actually started checking queries against reality.

Pass 2 — Validate SELECT Columns

Once the table is validated, the analyzer checks every selected column.

for (const std::string& col : s->columns) {
    bool found = false;
    for (const ColumnSchema& c : table->columns) {
        if (c.col_name == col) {
            found = true;
            break;
        }
    }
    if (!found) {
        throw std::runtime_error(
            "Error: column '" + col +
            "' does not exist in table '" +
            table->table_name + "'"
        );
    }
}

So this works:

SELECT age FROM users;

But this does not:

SELECT chakra_level FROM users;

Before this stage, column names were just strings sitting inside AST nodes.

Now they actually mean something.

Pass 3 — Validate WHERE Conditions

The third pass validates columns used inside the WHERE clause.

if (WhereNode* w = dynamic_cast<WhereNode*>(ast_node)) {
    const std::string& col = w->condition.left;
    bool found = false;
    for (const ColumnSchema& c : table->columns) {
        if (c.col_name == col) {
            found = true;
            break;
        }
    }
    if (!found) {
        throw std::runtime_error(
            "Error: column '" + col +
            "' does not exist in table '" +
            table->table_name + "'"
        );
    }
}

So this query:

SELECT name FROM users WHERE age > 18;

passes.

But this:

SELECT name FROM users WHERE bank_balance > 9999999;

fails immediately.

The parser already proved the query was structurally correct.

The semantic analyzer proves whether it logically makes sense.

The Bug I Found

And yes. There was another bug.

Take this query:

SELECT name FROM users;

Completely valid query.

But remember the parser issue from Stage 2?

The parser still always attaches a WhereNode even when no WHERE clause exists.

Which means the semantic analyzer still tries validating the WHERE condition even when it’s empty.

So this:

w->condition.left

becomes:

""

An empty string.

And then the analyzer starts searching the schema for a column literally named "".

Which obviously fails.

Meaning my semantic analyzer throws an error… on a perfectly valid query.

That one took me a minute to figure out because at first the semantic analyzer looked correct.

The real issue was inherited from Stage 2.

Different stage. Same bug chain.

The Fix

The fix ended up being pretty simple:

if (w->condition.left.empty()) {
    ast_node = ast_node->child;
    continue;
}

If the condition is empty, skip validation entirely.

Don’t validate something that doesn’t exist.

One Thing This Project Keeps Teaching Me

Every stage of this project keeps reinforcing the same engineering lesson:

Silent structural problems don’t disappear. They just show up later in worse ways.

Stage 1 had malformed strings quietly passing through the lexer.

Stage 2 had empty WhereNodes silently attached to queries.

And now Stage 3 inherited that exact problem downstream.

Different layer. Same principle.

What’s Next

Stage 4 is the Query Planner.

Right now my engine can:

  • tokenize queries
  • build structure
  • validate semantics

But it still doesn’t know how to execute anything.

The planner is where the query finally starts becoming executable.

That’s where things start getting genuinely database-y.

Same deal as always: building in public, bugs included.

📂 GitHub — https://github.com/zoolpher/sql-engine

🐦 X — https://x.com/aryanmh0

🎥 YouTube — https://youtu.be/geoK6kt073M

Built by zoolpher — B.Tech CS, systems engineering track.


메타데이터
post_id
1ff3df634089
slug
my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
url
https://medium.com/@zoolpher/my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
canonical_url
https://medium.com/@zoolpher/my-sql-engine-finally-started-saying-no-stage-3-1ff3df634089
author_url
https://medium.com/@zoolpher
status
ok
fetched_at
2026-06-09 15:37:30