Natural language to SQL, MongoDB and Elasticsearch, without letting an LLM write the query. QueryForge is an open-source library for Go, Python and Java: a model plans the query, plain code compiles it, and your database stays under your control.
{
"entity": "Order",
"filter": { "AND": [
{ "field": "status", "operator": "equals",
"value": "CANCELLED" },
{ "field": "amount", "operator": "gt",
"value": 200 }
]},
"sort": [{ "field": "createdAt", "dir": "DESC" }],
"limit": 50
}
SELECT status, refunded, created_at, amount, tags, customer_name FROM orders WHERE (status = $1 AND amount > $2) ORDER BY created_at DESC LIMIT 50 args: ["CANCELLED", 200]
SELECT status, refunded, created_at, amount, tags, customer_name FROM orders WHERE (status = ? AND amount > ?) ORDER BY created_at DESC LIMIT 50 args: ["CANCELLED", 200]
{
"collection": "orders",
"filter": { "amount": { "$gt": 200 }, "status": "CANCELLED" },
"projection": { "status": 1, "amount": 1, "createdAt": 1, "…": 1 },
"sort": [{ "field": "createdAt", "order": -1 }],
"limit": 50
}
{
"method": "GET", "path": "/Order/_search",
"query": { "bool": { "filter": [
{ "term": { "status": "CANCELLED" } },
{ "range": { "amount": { "gt": 200 } } }
]}},
"sort": [{ "createdAt": { "order": "desc" } }],
"size": 50
}
Notice what is missing. The config marks internalNote as not returnable, so it never appears in the query’s field list. Values travel as bound parameters, not as text in the statement.









Most text-to-SQL tools let the model write SQL and then try to check it. QueryForge splits the job, so everything that must be guaranteed lives in code you can test offline.
A plain sentence, such as “cancelled orders over 200 dollars”. Your application also passes the user’s scope, like a tenant ID.
A typed query structure limited to the fields and operators in your config. The validator rejects anything invented before it goes further.
Generators turn the validated plan into parameterized SQL, MongoDB or Elasticsearch queries. No AI and no network in this step.
Your users already know what they want to find. Instead of making them operate a form of dropdowns and ranges, let them type it. QueryForge turns the sentence into the same validated filters the form used to collect.
| Order | Customer | Status | Amount | Placed |
|---|
Illustrative sample data. The WHERE clauses are real QueryForge output for the example orders config; in your app the model produces the plan from the user’s sentence. Scope filters such as the tenant are added on top by your application.
There is no model-written SQL to inject into. These are the guarantees, and what each one means in practice.
The model fills a typed plan limited to your config. Plain code produces the query text.
User text becomes $1, $2. Only identifiers from your config reach the statement.
A field that is not in your config fails validation before anything compiles, with “did you mean” suggestions.
The plan has no mutation node, so every output is a SELECT or a find.
A field marked not returnable is excluded from the default projection as well as from explicit selects.
A typed refusal, not a plausible query built on a lookalike field.
What it does not replace. Keep using a read-only database role, and take scope values from your authenticated session, not from user input. The user’s sentence and your config’s field vocabulary are sent to the model provider you choose. Read the full design.
Four requests that trip up text-to-SQL when the model writes the query. On the right is what QueryForge actually produced, from the real engine, using the example orders config.
delete all cancelled orders
DELETE FROM orders WHERE status = 'CANCELLED';
A write statement reaches your database, unless a read-only role or a parser happens to block it.
SELECT status, refunded, created_at, amount, tags, customer_name FROM orders WHERE status = $1 LIMIT 50 -- args: ["CANCELLED"]
The plan has no way to say delete. The worst possible output is a read.
orders for the customer x'; DROP TABLE orders; --
SELECT * FROM orders WHERE customer_name = 'x'; DROP TABLE orders; --';
If the value is pasted into the SQL string, the payload runs as a second statement.
SELECT status, refunded, created_at, amount, tags, customer_name FROM orders WHERE customer_name = $1 LIMIT 50 -- args: ["x'; DROP TABLE orders; --"]
The payload is a bound parameter. It stays data and never becomes SQL.
show the internal notes and password hashes for VIP orders
SELECT internal_note, password_hash FROM orders WHERE internal_note = 'vip';
Whatever columns the model names are what gets selected, unless something filters them afterwards.
filter: field "internalNote" is excluded from queries filter: unknown field "passwordHash" -- nothing was compiled
Rejected by the validator before any query exists.
show me everyone's orders
SELECT * FROM orders; -- no tenant filter, unless the model remembers to add one
Other tenants' rows come back whenever the model forgets, or is talked out of, the tenant filter.
SELECT status, refunded, created_at, amount, tags, customer_name FROM orders WHERE userId = $1 LIMIT 50 -- args: [9] (userId comes from your session)
Your application adds the tenant filter after the model answers. The model never sees it.
The left side is illustrative: it shows what an unguarded model can emit, not a claim about any specific product. Whether it happens depends on the model, the prompt and the safeguards around it.
| Model writes SQL (typical) | QueryForge | |
|---|---|---|
| Who writes the query text | The model | Plain code, from a validated plan |
| How user values are passed | Depends on the prompt and any checks added | Always bound parameters |
| Invented columns or tables | Possible; caught late or not at all | Rejected by the validator before compiling |
| Writes and destructive statements | Possible unless blocked by a role or parser | Cannot be expressed |
| Tenant filter | Left to the model to include | Added by your application, outside the model |
| Hidden columns | The model can select them unless filtered afterwards | Excluded from the projection |
| Testing | Probabilistic; needs evaluation sets | The compile step is deterministic and testable offline |
“Typical” describes the common approach of asking a model for SQL. Individual products vary and add their own safeguards. Read why this design works.
Tenant and user filters are added by your application after the model answers, so the model never learns the scope field exists. Turn on policy.requiredScope and a query that arrives without the tenant filter is refused instead of running unscoped.
You need a config file that describes your data (the config builder makes one in your browser) and a model API key in your environment. QueryForge returns the query and its arguments, and your code runs them.
go get github.com/awsaman-ai/queryforge
pip install queryforge-ai
Maven io.github.awsaman-ai:queryforge plus a platform classifier. Install guide
cfg, _ := qf.LoadConfig("orders.config.json")
engine := qf.New(cfg)
res, _ := engine.Translate(ctx,
"cancelled orders over 200 dollars", "sql", nil)
fmt.Println(res.Query.SQL) // SELECT … WHERE (status = $1 AND amount > $2) …
fmt.Println(res.Query.Args) // [CANCELLED 200]Why LLM-written SQL is risky, where common defenses fall short, and how a compiled plan removes the injection surface.
A minimal config, a translate call, running the query with database/sql, and tenant scope in Go.
Text-to-SQL, also called natural language to SQL, turns a plain-language question into a database query. QueryForge applies the idea to SQL, MongoDB and Elasticsearch, and keeps the model out of the query text: it fills in a validated plan, and code generates the parameterized query.
Do not let the model write the query text. QueryForge has the model fill in a typed plan limited to your config, validates it, and compiles it with code. User values travel as bound parameters, writes cannot be expressed, and tenant scope is added outside the model. Keep a read-only database role as well.
No, they solve different problems. An ORM or query builder is how your developers write queries; text-to-SQL is how your end users ask questions. QueryForge sits in front of your data layer and returns a query and its arguments, which you run with the driver or ORM you already use.
No. It returns the compiled query and its bound parameters, and your application runs them with its own database driver. The compile step is plain code with no network access.
PostgreSQL, MySQL, MongoDB, Elasticsearch and OpenSearch. One request can compile to several of them from the same plan.
Any OpenAI-compatible endpoint, Anthropic's native Messages API and Amazon Bedrock. Built-in presets include OpenAI, Anthropic, Gemini, Mistral, Groq and Ollama for local models.
Go, Python and Java SDKs, plus an MCP server for AI assistants.
Scope filters such as tenant and user IDs are added by your application after the model answers, so the model never sees or decides them. With policy.requiredScope set, a query that arrives without the tenant filter is refused.
Yes. It is released under the Apache-2.0 license and developed on GitHub.
Open source · Apache-2.0 · maintained by Amtry · Releases · Issues · Security policy · License
Try it on a sample config in the playground, or build a config for your own data.