TableCore

SQL to MongoDB

Every SQL query TableCore runs becomes exactly one MongoDB command, or is refused. This page shows what that command looks like for the shapes people actually write, so you can check the translation instead of trusting it.

Every command below is a golden fixture from the test suite: it is pinned in tests/Core/OfflinePlanGoldenFixtures.cs, and an opt-in suite against a real MongoDB asserts that the connected engine sends exactly this. If the engine changes how it builds a pipeline, the fixture has to change in the same commit.

Where to see it yourself

Three places show you the command, and they all come from the same capture, so they cannot disagree:

  • As code — the query rendered for mongosh, the C# driver and C# LINQ. See Query as code.
  • Convert to a MongoDB query — replaces the SQL in your editor with the shell chain, to edit and run.
  • Explain — the plan the server produced for that exact command. See Explain plans.

It is the command, not a second translation

The text you read is captured from the run rather than rebuilt from the parsed SQL. That includes the resolved collection name and any rewrites made from a sample of the collection's shape. A generated description that took its own path to the answer would eventually describe a different query than the one that runs.

WHERE, ORDER BY, LIMIT and OFFSET

A SELECT from one collection with no grouping is a find.

SELECT * FROM users WHERE Age > 30 ORDER BY Name DESC LIMIT 10 OFFSET 5
{
  "find": "users",
  "filter": { "Age": { "$gt": 30 } },
  "sort": { "Name": -1 },
  "skip": 5,
  "limit": 10
}

A condition of the shape field against constant stays an ordinary filter that MongoDB can serve from an index. A condition that shape cannot express in full is compiled as $expr instead — correct, but $expr uses no index. The order those two are tried in is what decides whether your query starts from an index or from a scan.

Projections and aliases

SELECT Name AS FullName, Age FROM users WHERE Active = true
{
  "find": "users",
  "filter": { "Active": true },
  "projection": { "_id": 1, "FullName": "$Name", "Age": 1 }
}

_id is kept because it is what makes the result editable in the grid. An alias becomes a computed projection, which is why an aliased column is read-only — the grid knows which field it came from, but writing back through a rename is a different feature.

IN

SELECT Name FROM users WHERE Status IN ('active', 'hold')
{
  "find": "users",
  "filter": { "Status": { "$in": ["active", "hold"] } },
  "projection": { "_id": 1, "Name": 1 }
}

LIKE

LIKE becomes an anchored regular expression, with % and _ translated and ESCAPE honoured.

SELECT Name FROM products WHERE Code LIKE 'A!_%' ESCAPE '!'
{
  "find" : "products",
  "filter" : { "Code" : /^A_.*/ },
  "projection" : { "_id" : 1, "Name" : 1 }
}

The filter carries a BSON regular expression, not a string — which is why this one is shown in shell notation rather than JSON. !_ escaped the _, so it matches a literal underscore; the unescaped % became .*.

IS NULL

SQL's NULL covers two states MongoDB keeps apart — a field that is null and a field that is not there — so IS NULL has to ask for both.

SELECT Name FROM users WHERE DeletedAt IS NULL
{
  "find": "users",
  "filter": {
    "$or": [
      { "DeletedAt": { "$exists": false } },
      { "DeletedAt": null }
    ]
  },
  "projection": { "_id": 1, "Name": 1 }
}

GROUP BY

Grouping moves the query to aggregate.

SELECT Status, COUNT(*) AS Cnt, SUM(Total) AS TotalSum FROM orders GROUP BY Status
{
  "aggregate": "orders",
  "pipeline": [
    { "$match": {} },
    { "$group": {
        "_id": "$Status",
        "Cnt": { "$sum": 1 },
        "TotalSum": { "$sum": "$Total" },
        "__tc_sum_seen_TotalSum": { "$sum": { "$cond": [
          { "$and": [
            { "$ne": ["$Total", null] },
            { "$ne": [{ "$type": "$Total" }, "missing"] }
          ] }, 1, 0 ] } }
    } },
    { "$project": {
        "_id": 0,
        "Status": "$_id",
        "Cnt": 1,
        "TotalSum": { "$cond": [
          { "$gt": ["$__tc_sum_seen_TotalSum", 0] }, "$TotalSum", null
        ] }
    } }
  ],
  "cursor": {}
}

That extra __tc_sum_seen_TotalSum accumulator is the witness that makes SUM behave like SQL's: MongoDB's $sum answers 0 for an empty set, SQL answers "unknown". Zero says the values added up to nothing; null says there were none. The witness counts real values and the $project turns a count of zero back into null. It never reaches you — the projection is an including list.

HAVING

HAVING is a $match after the group and the projection, which is exactly where SQL puts it.

SELECT Status, COUNT(*) AS Cnt FROM orders GROUP BY Status HAVING COUNT(*) > 1
{
  "aggregate": "orders",
  "pipeline": [
    { "$match": {} },
    { "$group": { "_id": "$Status", "Cnt": { "$sum": 1 } } },
    { "$project": { "_id": 0, "Status": "$_id", "Cnt": 1 } },
    { "$match": { "Cnt": { "$gt": 1 } } }
  ],
  "cursor": {}
}

JOIN

INNER JOIN is $lookup followed by $unwind that drops non-matches.

SELECT u.Name, o.Total FROM Users u INNER JOIN Orders o ON u._id = o.UserId
{
  "aggregate": "Users",
  "pipeline": [
    { "$lookup": {
        "from": "Orders", "localField": "_id", "foreignField": "UserId", "as": "o"
    } },
    { "$unwind": { "path": "$o", "preserveNullAndEmptyArrays": false } },
    { "$project": { "_id": 1, "Name": 1, "Total": "$o.Total" } }
  ],
  "cursor": {}
}

LEFT JOIN is the same pipeline with preserveNullAndEmptyArrays set to true. That one flag is the whole difference, and it is the same difference SQL draws.

A WHERE over the joined side becomes a $match between the $unwind and the $project, because those are the rows SQL says the condition sees.

An unindexed $lookup is the most expensive thing an aggregation can do

It reads the foreign collection once per input document, and it does not show up as a collection scan in the outer plan. TableCore's explain detects it anyway. If a join is slow, index the foreign field — see Indexes.

DISTINCT

SELECT DISTINCT Status FROM orders
{
  "aggregate": "orders",
  "pipeline": [
    { "$match": {} },
    { "$group": { "_id": "$Status" } },
    { "$project": { "_id": 0, "Status": "$_id" } }
  ],
  "cursor": {}
}

Computed columns

An expression in the SELECT list becomes a computed projection, and every operand is converted with an explicit onError/onNull.

SELECT Name, Price * Quantity AS LineTotal FROM items
{
  "find": "items",
  "filter": {},
  "projection": {
    "_id": 1,
    "Name": 1,
    "LineTotal": { "$multiply": [
      { "$convert": { "input": "$Price", "to": "double", "onError": null, "onNull": null } },
      { "$convert": { "input": "$Quantity", "to": "double", "onError": null, "onNull": null } }
    ] }
  }
}

Those conversions are not decoration. MongoDB's arithmetic operators abort the whole query when an argument is the wrong type, so a single row with text in a numeric field would otherwise cost you every other row in the result.

Where this does not reach

A query that is not one command — a set operator, a subquery read while the plan is being built — is not translated to one of its commands. It is named, with the steps listed in the order the engine runs them. See SQL support.

The same translation in your browser

The converter runs this engine compiled to WebAssembly, entirely client-side, with no account and no database. It has no collection samples, so the handful of rewrites that depend on knowing a collection's shape are not available there — everything on this page is.

Going deeper

The published articles work through individual shapes in more detail: