Every key a config file understands.
A QueryForge config is one JSON file. It names your data, tells the model what it's allowed to ask about, and tells the compiler how to turn a validated question into real SQL or a Mongo query. This page explains every key in that file — what it does, why it exists, and a working example of it — in plain language, matching exactly what the loader itself checks. No key here is guessed; each one traces back to the library's own source.
Go from nothing to a working query
Every language does the same three things: install the package, point it at a config file, and ask a question in plain English. Two things to have ready first:
1. A config file
One JSON file that names your data and what's allowed to be asked about it. Don't write it by hand — use the Config Builder and download the file it gives you. The smallest valid one is tiny; see the shape of a config file just below.
2. A model API key
A key for whichever AI provider your config's model block names (Gemini, OpenAI, Anthropic, Groq…). Export it under the exact name your config's apiKeyEnv gives — never paste the key itself into the config.
export QF_API_KEY="your-real-key-here"Go
No third-party dependencies — standard library only.
go get github.com/awsaman-ai/queryforge
package main
import (
"context"
"fmt"
qf "github.com/awsaman-ai/queryforge"
)
func main() {
cfg, err := qf.LoadConfig("orders.config.json")
if err != nil {
panic(err)
}
engine := qf.New(cfg)
res, err := engine.Translate(context.Background(), "cancelled orders over 200 dollars", "sql", nil)
if err != nil {
panic(err)
}
fmt.Println(res.Query.SQL) // SELECT ... WHERE (status = $1 AND amount > $2)
fmt.Println(res.Query.Args) // [CANCELLED 200]
fmt.Println(res.Explain) // plain-English readback of what it understood
}
Save this as main.go next to your config file and run go run main.go.
Python
The engine ships inside the wheel — no Go toolchain to install.
pip install queryforge-ai
Installs as queryforge-ai, imports as queryforge.
from queryforge import QueryForge
qf = QueryForge.postgres("orders.config.json")
pending = qf.query("cancelled orders over $200")
print(pending.to_sql()) # SELECT ... WHERE (status = $1 AND amount > $2)
print(pending.to_args()) # ('CANCELLED', 200)
print(pending.explain()) # plain-English readback of what it understood
Save this as app.py next to your config file and run python app.py.
Java
Two Maven dependencies: the classes, and the engine binary for the platform you run on (see the platform table for the classifier to use — e.g. darwin-arm64 for Apple Silicon, linux-amd64 for most servers). Why two?
<dependency>
<groupId>io.github.awsaman-ai</groupId>
<artifactId>queryforge</artifactId>
<version>LATEST</version>
</dependency>
<dependency>
<groupId>io.github.awsaman-ai</groupId>
<artifactId>queryforge</artifactId>
<version>LATEST</version>
<classifier>darwin-arm64</classifier>
</dependency>
import io.queryforge.QueryForge;
import io.queryforge.PendingQuery;
import java.nio.file.Paths;
public class App {
public static void main(String[] args) {
QueryForge forge = QueryForge.postgres(Paths.get("orders.config.json"));
PendingQuery pending = forge.query("cancelled orders over $200");
System.out.println(pending.toSql()); // SELECT ... WHERE (status = $1 AND amount > $2)
System.out.println(pending.toArgs()); // [CANCELLED, 200]
System.out.println(pending.explain()); // plain-English readback of what it understood
}
}
Requires Java 11 or later. Zero runtime dependencies — not even a JSON library.
Putting Python or Java behind a web server? Read Running in production for timeouts, logging, error codes and how to cap the number of queries running at once.
The shape of a config file
Every config is one JSON object with the same nine possible top-level keys. Only two are ever required: entity and fields. Everything else has a sensible default, so the smallest valid config is genuinely tiny.
{
"entity": "Order",
"fields": [
{ "name": "status", "type": "string" }
]
}
This loads. No model, no backend, no capability flags — everything not shown here falls back to a type-aware default, explained section by section below.
| Key | Required? | What it's for |
|---|---|---|
entity | required | The name of the thing you're querying — "Order", "Customer". Every incoming query must name this exactly. |
fields | required | The whole vocabulary. At least one field. Anything not listed here can't be asked about. |
version | optional | Your own revision number — for your logs, not read by the loader. |
model | optional | Which AI service turns a sentence into a query plan. |
models | optional | A fallback chain of extra models, tried in order if the first is unreachable. |
backends | optional | The real table, collection, or index each database uses. |
defaults | optional | Page size when a question doesn't say, and the hard ceiling on any explicit page size. |
policy | optional | Safety limits — how deep filters may nest, which fields refuse pattern search, field rules ("using this also needs that"), and whether the tenant filter is compulsory. |
timezone | optional | The zone your users live in, so "today" and "last month" start at their midnight. Defaults to UTC. See dates & periods. |
How it's loaded
In Go, the file is read once at startup:
cfg, err := qf.LoadConfig("orders.config.json")
engine := qf.New(cfg)
res, err := engine.Translate(ctx, "cancelled orders over 200 dollars", "sql", nil)
fmt.Println(res.Query.SQL) // SELECT * FROM orders WHERE (status = $1 AND amount > $2)
fmt.Println(res.Query.Args) // [CANCELLED 200]
LoadConfig time, before your service ever starts handling questions — not discovered later on a live request.entity & version
These boxes just name the file (and, optionally, set the timezone). They carry no query logic of their own. Each arrow below points at one box and says what it means, with an example.
Swipe the picture sideways to see all of it.
- Name
entityWhat it means. What one record is called. Every question and every query you send must use exactly this word, so it is also the name you pass in code.
Example.
Order. A query built for"Orders"(plural) is rejected: the match is exact. - Version number
versionWhat it means. Your own revision number for the file. QueryForge stores it and never acts on it. It is there so you can tell two deployed configs apart in a log line or a code review.
Example.
2. Bump it when you ship a meaningfully different config. Nothing enforces this. - Timezone
timezoneWhat it means. The zone your users live in, so "today" and "last month" start at their midnight and not at UTC midnight. Leave it blank for UTC.
Example.
Asia/Kolkata. See dates & periods for every accepted value.
"entity": "Order",
"version": 2,
"timezone": "Asia/Kolkata"
model & models[]
The model block tells QueryForge which AI service turns a typed sentence into a query plan. Skip it entirely if you'll only ever send ready-made queries through GenerateFrom — that path never calls a model.
1. Pick the service and the model
Start with the provider cards, then the model and the key. The lines below are what the builder writes into model.
Swipe the picture sideways to see all of it.
- Provider card
provider · baseURLWhat it means. Picking a card fills in the provider label and the API address for you. Anthropic and Amazon Bedrock have their own native clients; every other card uses the OpenAI-compatible client, so Gemini, Groq, Ollama and most hosts work.
Example. Google Gemini writes
"provider": "gemini"and the GeminibaseURL. Use Custom endpoint for anything else. - Model
modelWhat it means. The model id exactly as the provider spells it. Swapping models later is a config change, never a code change.
Example.
gemini-3.5-flash - Variable name
apiKeyEnvWhat it means. The name of the environment variable that holds your key, not the key itself. The key stays wherever you run your service and never travels with the file. A real key pasted here is rejected when the config loads.
Example.
QF_API_KEY - API key: variable or paste
apiKeyEnv · apiKeyWhat it means. Choose one way to give QueryForge the key. "Use an environment variable" writes
apiKeyEnv(preferred). "Paste my key" writesapiKeyinto the file instead. The two are never allowed together.Example. See the next picture for what "Paste my key" shows.
- The line to run
(not saved)What it means. The builder shows the exact
exportline to run where your app runs. It is shown for your convenience and is never written to the config.Example.
export QF_API_KEY=<paste your key here>
Pasting the key instead
Choosing Paste my key swaps the variable name for a password box. Use it for local testing only.
Swipe the picture sideways to see all of it.
- Your API key
apiKeyWhat it means. The key itself, written into the config file. Use it for local testing, or where no environment variable is available. It is never printed in logs, errors or re-serialised JSON, but it does travel with the file.
Example. Keep the file out of version control. Not allowed together with
apiKeyEnv. - The warning
What it means. A reminder that the key is now inside the file you download. The Config Builder never uploads anything, but the file you save is as secret as the key.
Example. Prefer the environment-variable choice above.
2. Advanced settings: how it answers
Open Advanced settings to change the label, the address and how the model behaves. Every box here has a default, so leave one blank and its key stays out of the file.
Swipe the picture sideways to see all of it.
- Provider label
providerWhat it means. A label that picks which client talks to the service.
anthropicandbedrockhave their own native clients; any other label uses the OpenAI-compatible client, so most hosts (Gemini, Groq, Ollama and more) work under whatever name you give them.Example.
gemini - API address
baseURLWhat it means. The API root, without
/chat/completions, which is added for you. Required for every provider exceptanthropicandbedrock, which work out their own address.Example.
https://generativelanguage.googleapis.com/v1beta/openai - Creativity
temperatureWhat it means. How random the model is allowed to be. Set it to
0so the same question compiles the same way every time. Left blank, the provider default is used.Example.
0 - Reply length limit
maxTokensWhat it means. A ceiling on the length of the reply. Too low, and a reasoning model's hidden thinking can use up the whole budget and cut the JSON off halfway through an object.
Example.
4096 - Force JSON replies
jsonModeWhat it means. Sends
response_format=json_objectwith every request. It is off by default, because some OpenAI-compatible endpoints (Gemini's, measurably) return broken JSON with it on. Turn it on only for an endpoint you have tested.Example. default (off) → leave the key out of the file.
3. More connection settings
Timeouts and retries. Open More connection settings inside Advanced settings. The grey numbers are the defaults, used when a box is left empty.
Swipe the picture sideways to see all of it.
- Wire format
protocolWhat it means. The way QueryForge talks to the service, for the rare case where the URL alone does not say. Leave it on auto unless you point at an Anthropic-compatible gateway on a host name that does not give it away. Accepts
openai,anthropicorbedrock.Example. auto (leave the key out)
- Timeout, seconds
timeoutSecondsWhat it means. The ceiling on one request to the provider. It counts per attempt, not per translate call, so with retries the whole call can take longer.
Example. default
30 - Retry attempts
maxRetriesWhat it means. Extra tries for a failure that waiting could fix (a rate limit or a 5xx). A bad key or a malformed request is never retried, because the next attempt would send the same thing. Set
0to fall through to the next backup model straight away.Example. default
2 - Retry wait, ms
retryBackoffMsWhat it means. The delay before the first retry. It doubles after that, with a little random jitter. If the provider sends its own
Retry-Afterheader, that wins.Example. default
250(a quarter of a second)
aws (region and credentials) is only used when the provider is Amazon Bedrock, and is ignored by every other provider. See Amazon Bedrock below.
"model": {
"provider": "gemini",
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-3.5-flash",
"apiKeyEnv": "QF_API_KEY",
"temperature": 0,
"maxTokens": 4096
}
apiKeyEnv: it takes the name of an environment variable — export QF_API_KEY=<your key> — so the secret stays wherever you run the service, not wherever this file travels. If you do put a key in the file, use the separate apiKey field; the loader rejects a key pasted into apiKeyEnv (Google, OpenAI, Anthropic, Groq and GitHub prefixes) so the two are never confused.Amazon Bedrock
"provider": "bedrock" talks to Bedrock's Converse API, one request shape for every model family it hosts (Claude, Nova, Llama, Mistral and more). The address is built from your AWS region, so there is no baseURL to set. There is no default model either: Bedrock ids differ by region and many need an inference-profile id (a us. or eu. prefix), so copy the exact id from the Bedrock console into model.
"model": {
"provider": "bedrock",
"model": "us.anthropic.claude-sonnet-4-6",
"aws": { "region": "us-east-1", "bearerTokenEnv": "AWS_BEARER_TOKEN_BEDROCK" }
}
Everything in aws is optional. With no aws block at all, QueryForge reads the same variables the AWS CLI does: AWS_REGION, then AWS_BEARER_TOKEN_BEDROCK (a Bedrock API key) or AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN for temporary credentials).
Swipe the picture sideways to see all of it.
- AWS region
aws.regionWhat it means. The AWS region that hosts your model. The request address is built from it, so there is no API address to set. Leave it blank to read
AWS_REGION. For a VPC or FIPS endpoint, setbaseURLinstead; it wins.Example.
us-east-1 - Authentication
bearerToken · accessKeyId …What it means. Which credentials to use. Standard AWS environment writes nothing and reads
AWS_BEARER_TOKEN_BEDROCKor the usualAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY. Bedrock API key asks for one bearer token. IAM access keys asks for the pair below (plus a session token for temporary credentials). A config uses a bearer token or IAM keys, never both.Example. IAM access keys, as shown.
- Access key ID
aws.accessKeyId · accessKeyIdEnvWhat it means. Half of the IAM key pair. With "From an environment variable" this box holds the name of the variable (and writes
accessKeyIdEnv). With "Write the value in this file" it holds the value itself (and writesaccessKeyId). The secret below works the same way, assecretAccessKey/secretAccessKeyEnv. Half a key pair is rejected when the config loads.Example.
AWS_ACCESS_KEY_ID - Where does it come from?
Env or plain nameWhat it means. Chooses between the two ways to give a credential: the
Envform holds the name of an environment variable (safer), the plain form holds the value itself and travels with the file. Use one per credential, never both. QueryForge redacts inline values from every log line and error, and adds a warning to each result that names the field, never the value.Example. From an environment variable.
- Secret access key
aws.secretAccessKey · secretAccessKeyEnvWhat it means. The other half of the pair. Together with the access key ID it signs every request.
Example.
AWS_SECRET_ACCESS_KEY - Session token
aws.sessionToken · sessionTokenEnvWhat it means. Needed only for temporary credentials (STS, AssumeRole, SSO), and it must belong to the access key beside it. Temporary credentials expire, so pass a fresh set instead of caching one for the life of the process. Leave it empty for long-lived keys.
Example.
AWS_SESSION_TOKEN(optional) - The lines to run
(not saved)What it means. The
exportlines to run where your app runs. Anything you chose to write into the file is not listed here. This is a convenience and is never written to the config.Example.
export AWS_SECRET_ACCESS_KEY=<value>
Two ways to give each credential. The plain name (secretAccessKey) holds the value itself; the Env name (secretAccessKeyEnv) holds the name of an environment variable. Use one per credential, never both. A config picks one authentication mode, a bearer token or IAM keys, and half a key pair is rejected at load.
Env form and the export lines the Config Builder shows you.credentials={...}, Java .withCredentials(map)). Temporary credentials expire, so supply a fresh set rather than caching one for the life of the process.models[] — the fallback chain
An ordered list of backup models, same shape as model. QueryForge tries model first, then each entry here in order, and uses whichever answers first — so a rate limit, quota block or outage on one falls through to the next instead of failing the request. Entries may mix providers freely.
Swipe the picture sideways to see all of it.
- Backup #1
models[0]What it means. One backup model. QueryForge tries the main model first, then each backup in the order shown, and uses whichever answers first. A rate limit, a quota block or an outage on one falls through to the next instead of failing the request.
Example. If Gemini is rate-limited today, Groq answers, and the result reports
ProviderUsed: "groq". - Move earlier / later / remove
orderWhat it means. The arrows change the order the backups are tried in. The cross removes the backup. Entries may mix providers freely.
Example. Put your cheapest or fastest backup first.
- Provider
providerWhat it means. The same label as on the main model: the client to use. Anything other than
anthropicorbedrockuses the OpenAI-compatible client.Example.
groq - Model id
modelWhat it means. The model id exactly as that provider spells it.
Example.
llama-3.3-70b-versatile - API address
baseURLWhat it means. The API root of the backup service, without
/chat/completions.Example.
https://api.groq.com/openai/v1 - Key variable
apiKeyEnvWhat it means. The name of the environment variable that holds this backup's key. Each model has its own, so a leaked or expired key on one never breaks the others.
Example.
GROQ_API_KEY
"models": [
{ "provider": "groq", "baseURL": "https://api.groq.com/openai/v1",
"model": "llama-3.3-70b-versatile", "apiKeyEnv": "GROQ_API_KEY" },
{ "provider": "ollama", "baseURL": "http://localhost:11434/v1",
"model": "qwen2.5" }
]
If Gemini is rate-limited today, Groq answers instead, and the response reports ProviderUsed: "groq" so you can see which one actually ran.
backends
Points your entity's name at the real table or collection in each database you use. Skip it and the entity name (Order) is used as-is. Every key here is a physical name, not a query rule — the actual compiling is done by the generator for that backend id.
Swipe the picture sideways to see all of it.
- Database
sql · mysql · mongoWhat it means. Which database this row is for. The dropdown shows the id each choice writes. "sql" means PostgreSQL specifically (numbered placeholders
$1, double-quoted names).mysqlis its own choice (?placeholders, backticks; MySQL 8.0+ or MariaDB 10.5+), andmongois MongoDB.Example. PostgreSQL —
sql - Physical table name
table · collectionWhat it means. The real table in that database. Skip the whole step and the entity name from Step 1 is used as it is. For MongoDB the same box is called Physical collection name and writes
collection. The entity name in every question staysOrder; only the compiled query uses this name.Example.
orders_v2→db.orders_v2.find(…)for Mongo,SELECT * FROM orders_v2 …for SQL. - Elasticsearch / OpenSearch
elasticsearch · opensearchWhat it means. A search backend is not a single physical name: it points at an index, an alias, or a rule that picks between indexes. It also cannot be mixed with the three databases above, so this button stays greyed out until the other rows are removed. Its own panel is described below.
Example. Remove the rows above to enable it.
"backends": {
"sql": { "table": "orders" },
"mysql": { "table": "orders_tbl" },
"mongo": { "collection": "orders_v2" }
}
With the mapping above: entity "Order" + collection "orders_v2" compiles Mongo calls to db.orders_v2.find(…), while the entity name in every question and every AST stays just "Order".
sql, mysql, mongo, elasticsearch or opensearch) still loads fine — it's just a mapping namespace your own code can read. Nothing in QueryForge compiles a query to it.fields[] — the basics
This is the whole vocabulary the model is allowed to use. Anything listed here can be asked about; anything left out cannot be invented — the model is told to say so rather than guess. At least one field is required.
Swipe the picture sideways to see all of it.
- Name
nameWhat it means. The logical name used in the query tree and in every question. It never has to match the real column: that is what the physical names below are for. Required.
Example.
status - Kind of value
typeWhat it means. One of six kinds (see the next section). It decides which comparisons and which value shapes are legal for this field. Required.
Example.
enum— one of a fixed list - Physical name, per database
mappingWhat it means. The real column (or, for MongoDB, the document path) behind this field, written once per database you picked in Step 3. A database you leave blank falls back to the logical name.
Example.
"mapping": { "sql": "status", "mongo": "status" } - MongoDB physical name
mapping.mongoWhat it means. Dot paths are fine here, because a Mongo document can nest values inside other values.
Example.
customer.address.city - Allowed values
valuesWhat it means. The complete list for an
enumfield, separated by commas. Required for an enum, and the loader rejects an empty list. A value outside it is refused before any query is built.Example.
PLACED, DELIVERED, CANCELLED, REFUNDED - Other ways to say it
synonymsWhat it means. Other phrasings that should mean this field. Put in what people actually type, because this is what makes ordinary questions land on the right field.
Example.
state, order status, delivery status: "what's the state of my order" now resolves tostatus. - Allowed comparisons
operatorsWhat it means. The whitelist of comparisons this field accepts. Leave the "usual set" ticked and the field takes its kind's defaults. Untick it to choose exactly which boxes apply. A dot marks a comparison that no value of this kind could ever satisfy, listed only so you can see what is ruled out.
Example. Here only
equals,notEquals,inandnotInare allowed onstatus.
For a field of kind array, one more box appears: List holds (itemType).
Swipe the picture sideways to see all of it.
- Kind of value
typeWhat it means. Choose
arraywhen one record holds a list of values, such as a list of tags.Example.
array— a list of values - List holds
itemTypeWhat it means. The kind of each item in the list. Values are checked against this, not against
"array". An array of enums also needs its allowed values, or every element fails validation.Example.
string: a tags list of words.
{
"name": "status",
"type": "enum",
"values": ["PLACED", "DELIVERED", "CANCELLED", "REFUNDED"],
"synonyms": ["state", "order status", "delivery status"],
"mapping": { "sql": "status", "mongo": "status" }
}
With synonyms set like this, "what's the state of my order" and "delivery status" both resolve to status just as well as the word "status" itself.
Importing fields from a schema
This is a config builder feature, not a config file key — a real schema has dozens of fields, and adding each one by hand is the slow part. Open the builder, go to Step 4 (Describe your fields), and expand Import fields from a schema. Pick your database, paste its own schema artifact, and the builder turns it into ordinary fields[] entries — the exact same shape "Add a field" produces, just filled in for you.
Pick the database you are importing from. Each tab shows what the builder asks you to paste, and how to get it.
Swipe the picture sideways to see all of it.
- Source
(builder only)What it means. Which database your schema comes from. Each choice expects a different kind of text and parses it its own way.
Example. Elasticsearch / OpenSearch: A
_mappingresponse. - How to get it
What it means. The one read-only command to run yourself, shown as soon as you pick a source. Nothing here connects to a database: the page is static, so it can only read text you paste, never a host name, a port or a password.
Example.
GET /<index>/_mapping - Paste it here
What it means. Paste what the command printed. A
_mappingresponse. The gray example inside the box shows the expected shape, and parsing happens entirely in your browser.Example.
{ "properties": { "status": { "type": "keyword" }, … } } - Parse
What it means. Reads the pasted text and shows a review table (next picture). Nothing is added to your config until you confirm the rows you want.
Example. Click Parse, then tick the rows to keep.
Swipe the picture sideways to see all of it.
- Source
(builder only)What it means. Which database your schema comes from. Each choice expects a different kind of text and parses it its own way.
Example. MySQL: A
CREATE TABLEstatement. - How to get it
What it means. The one read-only command to run yourself, shown as soon as you pick a source. Nothing here connects to a database: the page is static, so it can only read text you paste, never a host name, a port or a password.
Example.
SHOW CREATE TABLE <table>; - Paste it here
What it means. Paste what the command printed. A
CREATE TABLEstatement. The gray example inside the box shows the expected shape, and parsing happens entirely in your browser.Example.
CREATE TABLE `orders` ( `id` bigint NOT NULL, … ) - Parse
What it means. Reads the pasted text and shows a review table (next picture). Nothing is added to your config until you confirm the rows you want.
Example. Click Parse, then tick the rows to keep.
Swipe the picture sideways to see all of it.
- Source
(builder only)What it means. Which database your schema comes from. Each choice expects a different kind of text and parses it its own way.
Example. PostgreSQL: A
CREATE TABLEstatement. - How to get it
What it means. The one read-only command to run yourself, shown as soon as you pick a source. Nothing here connects to a database: the page is static, so it can only read text you paste, never a host name, a port or a password.
Example.
pg_dump --schema-only -t <table> - Paste it here
What it means. Paste what the command printed. A
CREATE TABLEstatement. The gray example inside the box shows the expected shape, and parsing happens entirely in your browser.Example.
CREATE TABLE orders ( id uuid NOT NULL, … ) - Parse
What it means. Reads the pasted text and shows a review table (next picture). Nothing is added to your config until you confirm the rows you want.
Example. Click Parse, then tick the rows to keep.
Swipe the picture sideways to see all of it.
- Source
(builder only)What it means. Which database your schema comes from. Each choice expects a different kind of text and parses it its own way.
Example. MongoDB: One or more sample documents.
- How to get it
What it means. The one read-only command to run yourself, shown as soon as you pick a source. Nothing here connects to a database: the page is static, so it can only read text you paste, never a host name, a port or a password.
Example.
db.<collection>.find().limit(5) - Paste it here
What it means. Paste what the command printed. One or more sample documents. The gray example inside the box shows the expected shape, and parsing happens entirely in your browser.
Example.
{ "status": "PLACED", "amount": 42.5, … } - Parse
What it means. Reads the pasted text and shows a review table (next picture). Nothing is added to your config until you confirm the rows you want.
Example. Click Parse, then tick the rows to keep.
Elasticsearch and SQL are read straight off the schema the database itself reports, so the type mapping is exact. Mongo has no fixed schema, so it's an educated guess from the sample's own value shapes — a date-looking string becomes date, a number becomes number, and so on; if two pasted documents disagree on a field's type, the row is flagged rather than silently picked for you.
A few config keys are detected automatically where the source schema states them outright:
keywordMapping— from Elasticsearch's own "text field with a keyword sub-field" convention.nestedPath— from an Elasticsearch mapping type of"nested"(a plain"object"is flattened to a dot-path name instead, with nonestedPath).elemMatch— from a Mongo array whose sample elements are sub-documents, not scalars.indexed— from a SQLPRIMARY KEYorUNIQUEcolumn.values— from a MySQLENUM(...)column's literal list.
Before anything is added, a review table shows every discovered field with an editable type dropdown and a notes column explaining what was detected (or flagged) — an unrecognized column/mapping type is never silently guessed at; it's mapped to string with a note to check it by hand. A field whose name already exists in your config is unchecked by default rather than overwritten. Only the rows you leave checked are added, as real field cards you can keep editing exactly like a hand-added one.
Swipe the picture sideways to see all of it.
- Tick the rows to keep
What it means. Every discovered field is a row, and only the ticked rows are added. They arrive as ordinary field cards you can keep editing, exactly like a hand-added one.
Example.
locationis new, so it is ticked. - already in your fields
What it means. A field whose name already exists in your config is unticked by default, so an import never overwrites something you wrote by hand.
Example.
statusandamountwere already added. - Type
typeWhat it means. The kind each field will get, and you can change it before adding. Elasticsearch and SQL are read straight off the schema, so those types are exact. For MongoDB there is no fixed schema, so the type is an educated guess from the sample values (a date-looking string becomes
date, a number becomesnumber).Example.
doublebecomesnumber. - Notes
What it means. Why a row looks the way it does. A column or mapping type the builder does not recognise is never guessed silently: it becomes
stringwith a note to check it by hand. A few config keys are filled in automatically where the schema states them outright:keywordMapping(a text field with a keyword sub-field),nestedPath(an Elasticsearchnestedtype),elemMatch(a Mongo array of sub-documents),indexed(a SQL primary key or unique column) andvalues(a MySQLENUMlist).Example. "unrecognized type
geo_point— mapped to string, review before use". - Add checked fields
fields[]What it means. Adds the ticked rows to Describe your fields. Discard throws the review away without changing anything.
Example. Add 1 checked field.
The six field types
Every field is exactly one of these. The type decides which operators can even be considered, and what a value has to look like to pass validation.
Click a type to see what it holds and which comparisons it accepts out of the box (the ticked boxes).
Swipe the picture sideways to see all of it.
- Kind of value: string
typeWhat it means. Holds: Free text. Names, notes, ids written as text.
Example.
"type": "string" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, a string field accepts exactly the ticked comparisons:
equals,notEquals,contains,startsWith,endsWith,in,notIn,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
Swipe the picture sideways to see all of it.
- Kind of value: number
typeWhat it means. Holds: Integers or decimals. Prices, counts, quantities.
Example.
"type": "number" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, a number field accepts exactly the ticked comparisons:
equals,notEquals,gt,lt,gte,lte,between,in,notIn,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
Swipe the picture sideways to see all of it.
- Kind of value: boolean
typeWhat it means. Holds:
trueorfalse. Yes/no flags such asrefunded.Example.
"type": "boolean" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, a boolean field accepts exactly the ticked comparisons:
equals,notEquals,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
Swipe the picture sideways to see all of it.
- Kind of value: enum
typeWhat it means. Holds: One value from a fixed, named list (the allowed values). Needs Allowed values: the loader rejects an enum with an empty list. An array of enums needs the same list, or every element fails validation.
Example.
"type": "enum" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, an enum field accepts exactly the ticked comparisons:
equals,notEquals,in,notIn,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
Swipe the picture sideways to see all of it.
- Kind of value: date
typeWhat it means. Holds: A point in time. Timestamps such as
createdAt. Relative phrases like "last month" are covered in dates & periods.Example.
"type": "date" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, a date field accepts exactly the ticked comparisons:
before,after,between,equals,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
Swipe the picture sideways to see all of it.
- Kind of value: array
typeWhat it means. Holds: A list of values that all share one kind (List holds). Needs List holds: values are checked against that, not against "array".
Example.
"type": "array" - Default comparisons
operatorsWhat it means. With "Use the usual set" ticked, an array field accepts exactly the ticked comparisons:
contains,containsAny,containsAll,isNull,isNotNull. Anything else is refused before a query is built. Untick the box to choose your own list.Example. Leave it ticked unless you have a reason to narrow the list.
enum field must list values — the loader rejects an empty enum domain. An array of enums needs the same list, or every element in it fails validation forever.Capability flags
Five booleans that decide exactly what's allowed to happen to a field. Each one defaults sensibly by type, and only the flags you explicitly set are ever written to the file — an untouched field always inherits today's default rather than freezing it.
Swipe the picture sideways to see all of it.
- Can be asked about
queryableWhat it means. Whether the model sees the field at all.
falsehides it from the prompt completely and rejects any query that names it. This is how a tenancy column is declared. Default: true.Example.
internalCostwithfalse: "orders where internalCost is over 50" is refused. - Can be filtered on
filterableWhat it means. Whether the field may appear in a filter (the
WHEREclause, or thefinddocument). Default: true.Example.
false: the field can be shown or sorted but never filtered on. - Can be text-searched
searchableWhat it means. Whether text-search comparisons (
contains,startsWith,endsWith,regex) are allowed. Only means anything onstringandenum. Default: true for a string, otherwise false.Example.
descriptionwithfalse: "description mentions x" is refused, but it can still be shown and sorted. - Can sort results
sortableWhat it means. Whether the field may appear in
sort[]. Ordering by an array is undefined, so arrays default to false. Everything else defaults to true.Example.
false: "sorted by status" is refused. - Shown in results
returnableWhat it means. Whether the field may appear in results.
falsealso removes it from the default projection, soSELECT *becomes an explicit list of allowed columns and the value cannot leak even by omission. Default: true.Example.
salarywithfalse:SELECT *becomesSELECT id, name, dept …
{ "name": "internalCost", "type": "number", "queryable": false }
{ "name": "description", "type": "string", "searchable": false }
{ "name": "salary", "type": "number", "returnable": false }
"orders where internalCost is over 50" → refused, the model never even learns the field exists. "description mentions x" → refused, but description can still be shown and sorted. SELECT * on the salary table becomes SELECT id, name, dept … automatically.
Custom fields — when a name doesn't explain itself
Some databases have field names like txt01 or customField3 — leftovers from a generic or per-tenant schema. The model can't guess what these hold just by reading the name, the way it can guess that status is a status. Check customField on a field like this, and QueryForge makes you explain it: the config simply won't load until you do.
Swipe the picture sideways to see all of it.
- Name
nameWhat it means. A name that explains nothing by itself, such as
txt01orcustomField3: leftovers from a generic or per-tenant schema. The model cannot guess what they hold the way it can guess thatstatusis a status.Example.
txt01 - Label
displayNameWhat it means. A friendlier name shown to the model and used in the plain-English readback (
Explain). The model still writes the realnamein the query; this is a nicer label, not another name for it. Always optional, up to 200 characters.Example.
Passport Country: the readback says "Passport Country is Germany", not "txt01 is Germany". - This is a custom field
customFieldWhat it means. Tick it to say this field's name needs explaining. Querying works exactly the same either way. What changes is what the loader requires: the config will not load until you fill in the two boxes below.
Example. Ordinary fields such as
statusoramountnever need it. - Description
descriptionWhat it means. One short line on what the field means. It is shown to the model. Required once
customFieldis ticked. Up to 500 characters.Example.
ISO country of issuance for the passport - Value hint
valueHintWhat it means. What kind of text usually lives in the field. The model cannot peek at real values the way it can read an enum's allowed values, so this fills that gap. Required for a searchable string once
customFieldis ticked. Up to 1000 characters.Example. Ask "passports issued in Germany" and the model now knows
txt01is the right field.
{
"name": "txt01",
"type": "string",
"customField": true,
"displayName": "Passport Country",
"description": "ISO country of issuance for the passport",
"valueHint": "Free-text notes — usually mentions visa type, renewal status, and issuing country."
}
Ask "passports issued in Germany" and the model now knows txt01 is the right field. The readback also reads better: "Passport Country is Germany" instead of "txt01 is Germany".
status, amount, createdAt — needs no customField, description, valueHint or displayName at all. Those four keys stay optional on every field; checking customField just turns two of them into a requirement, so a confusing name can't ship without an explanation. Length limits, checked at load: displayName up to 200 characters, description up to 500, valueHint up to 1000.Advanced field keys
The rest of what a field can carry — mostly nesting rules for MongoDB and Elasticsearch, plus small correctness and ranking hints.
- elemMatch Mongo only
- Names the Mongo array of sub-documents this field lives inside. Without it, two conditions on the same array are checked independently and can match two different elements — with it, they're folded into one
$elemMatch, meaning one element must satisfy them all.
// items: [{sku:"ABC", price:20}, {sku:"XYZ", price:900}]
// "item ABC costing over 100"
// without elemMatch — WRONG, matches this order:
{"items.sku":"ABC", "items.price":{"$gt":100}}
// with elemMatch: "items" — correct, matches nothing:
{"items": {"$elemMatch": {"sku":"ABC", "price":{"$gt":100}}}}- keywordMapping Elasticsearch / OpenSearch only
- The exact-match "keyword" sub-field backing this string, per product. ES commonly indexes one string twice — an analyzed path for full-text search, and an unanalyzed
keywordpath for exact match, sort and aggregation, because a single field can't do both.containsreads the plainmapping; every other operator preferskeywordMappingwhen set.
"mapping": { "elasticsearch": "customerName" }
"keywordMapping": { "elasticsearch": "customerName.keyword" }
// "customer name contains John" -> match on customerName (text)
// "customer name equals John Smith" -> term on customerName.keyword- nestedPath Elasticsearch / OpenSearch only
- The ES analogue of Mongo's
elemMatch, for the identical reason. Names the "nested" object/array this field lives inside, so two conditions on the same nested array are folded into one nested query instead of being checked independently.
- valueCase
- Forces the letter case of the value the compiler writes into the final query — for a column that stores
SHIPPEDwhile people say "shipped". Only the compiled argument is recased; the model still sees and validates against the case you wrote invalues.
- caseInsensitive string only
- Makes every string comparison on this field ignore case entirely, so "Black", "BLACK" and "black" all match the same row. Mutually exclusive with
valueCase— the two solve the same mismatch from opposite ends.
- indexed
- Tells QueryForge this column is backed by a database index. Never rejects anything — indexed predicates are just ordered first, and filtering on a non-indexed field raises a soft warning.
- priority
- Relative importance. Higher sorts earlier among predicates and earlier in the prompt. A hint only — it gates nothing.
- routingField Elasticsearch / OpenSearch only
- Opts this field into ES business-rule index routing (see below). Deliberately separate from every capability flag — an ordinary filterable field doesn't thereby decide which physical index gets searched.
- validators number fields
- Deterministic
min/maxbounds, checked before any query is built.{"min": 0, "max": 5}onratingrejects "rating above 9" outright, instead of quietly returning nothing.
Operators
Nineteen comparisons exist in total; this is the complete, fixed catalogue — the loader rejects anything not in it. A field's operators list narrows what's allowed on it; leaving it out means "use my type's defaults" from the table above.
| Operator | Means | Typically used on |
|---|---|---|
equals / notEquals | Exact match | any type |
gt / lt / gte / lte | Greater / less than (or equal) | number, date |
between | Inclusive range — or, given a period, the whole calendar range it names | number, date |
in / notIn | Membership in a short list | string, number, enum, array |
contains / startsWith / endsWith | Substring match — needs searchable: true | string, enum |
containsAny / containsAll | Array membership: any / every listed value present | array |
regex | Pattern match — needs searchable: true, blockable via policy.denyRegexOn | string |
before / after | Strictly earlier / later | date |
isNull / isNotNull | Presence check | any type |
contains on a number) loads fine but is dead weight — no value ever passes the type check, so it never fires. The builder flags these; the loader does not.Dates, periods & timezone
A time in a question is one of two quite different things, and QueryForge keeps them apart on purpose. Getting this wrong is the difference between "how many orders today" returning today's orders and returning most of today's orders.
A calendar period
"today", "yesterday", "this week", "last month", "this year", "last 30 days". Boundaries land on calendar lines, so the same question asked at 09:00 and at 23:00 returns the same rows.
"operator": "between",
"value": { "kind": "period",
"unit": "day",
"from": -1, "to": -1 }A span back from right now
"in the last 2 hours", "past 45 minutes". One instant, a fixed distance before this moment — so the answer legitimately moves as the clock does.
"operator": "after",
"value": { "kind": "relative_date",
"unit": "hour",
"amount": -2 }How a period is read
from and to count whole units away from the current one: 0 is the current day, week or month, -1 is the one before it. Both ends are inclusive, and weeks start on Monday. A period is only valid with between, on a date field — it is a range, so "after last month" has no single honest reading.
| Phrase | Period | Asked on 2026-09-15 (UTC), selects |
|---|---|---|
| today | day 0..0 | 2026-09-15 00:00 → 2026-09-16 00:00 |
| yesterday | day -1..-1 | 2026-09-14 00:00 → 2026-09-15 00:00 |
| last 7 days | day -6..0 | 2026-09-09 00:00 → 2026-09-16 00:00 (7 days, today included) |
| last 30 days | day -29..0 | 2026-08-17 00:00 → 2026-09-16 00:00 |
| this week | week 0..0 | Mon 2026-09-14 00:00 → Mon 2026-09-21 00:00 |
| last month | month -1..-1 | 2026-08-01 00:00 → 2026-09-01 00:00 |
| this year | year 0..0 | 2026-01-01 00:00 → 2027-01-01 00:00 |
Every backend compiles a period to a half-open range — start included, end excluded — rather than an inclusive BETWEEN. That way a row stamped 23:59:59.999 on the last day is counted, and midnight of the next day is not, without depending on the column's precision:
SQL (created_at >= $1 AND created_at < $2)
MongoDB { "createdAt": { "$gte": ISODate(start), "$lt": ISODate(end) } }
Elasticsearch { "range": { "createdAt": { "gte": "…", "lt": "…" } } }
On a date-partitioned Elasticsearch setup, a period selects exactly the indices it overlaps — no wider scan than the question asked for.
timezone
Set the top-level timezone to the IANA zone your users live in, and calendar periods are cut on their midnight instead of UTC's.
{
"entity": "Order",
"timezone": "Asia/Kolkata",
"fields": [ … ]
}
Why it is worth setting: a user in Kolkata asks "how many orders today" at 08:30 their time. Left at UTC, "today" runs 05:30 IST to 05:30 IST tomorrow — so every order placed between midnight and 5:30am is missing from the answer. No error, no warning, just a number that is quietly too small. With the zone set, the same question covers their real midnight-to-midnight day.
| Value | Result |
|---|---|
omitted, or "" | UTC — exactly how the library behaved before this key existed. |
"Asia/Kolkata", "America/New_York" | An IANA Region/City name. This is the form to use. |
"IST", "+05:30" | Rejected at load. Abbreviations are ambiguous and an offset is not a zone — neither survives a daylight-saving change. |
"Local" | Rejected at load. It would mean the same config cuts days differently on a laptop and on a server. |
- Daylight saving is handled: the day the clocks move is 23 or 25 hours long, and its boundaries still land on local midnight.
relative_datevalues are unaffected — "3 hours ago" is the same instant in every zone.- Absolute literals such as
{"kind":"date","v":"2026-09-15"}are still read as UTC, zone or no zone. - The explanation returned with a query names the zone, so a user can catch a wrong one before trusting the numbers: "createdAt falls in yesterday (Asia/Kolkata time)".
{"kind":"period","unit":"day","from":-1} is never read as "from -1 to 0". Both offsets default to zero as plain numbers, so a missing to would silently turn "yesterday" into "yesterday and today" and still compile to a perfectly valid query. The validator also refuses a from later than its to, an unknown unit, offsets beyond 1,000,000, and any operator other than between. All of these carry the error code invalid_relative_date.Elasticsearch / OpenSearch
Elasticsearch and OpenSearch share one compiler — the query DSL QueryForge emits is byte-identical either way. A config still names one product (backends.elasticsearch or backends.opensearch), because that's the id you pass to Translate. Elasticsearch/OpenSearch can't be mixed with SQL or Mongo backends in the same config — a config targets one search cluster, not several.
Which index(es) to search — four modes
The Type dropdown in the builder has four choices. Click one to see what it writes into the config and what request it resolves to.
Swipe the picture sideways to see all of it.
- Type: Direct Index
indexWhat it means. Search exactly one real index. The config holds
{ "index": "orders" }.Example. The request becomes
GET /orders/_search. - Index name
indexWhat it means. The physical index to search. Index names must be valid: lowercase, with none of the characters Elasticsearch forbids.
Example.
orders - Resolves to
(preview)What it means. The builder shows the request your choice resolves to. Nothing here runs a request: QueryForge only ever returns the generated query DSL and the resolved index and path.
Example.
GET /orders/_search
Swipe the picture sideways to see all of it.
- Type: Multiple Index
indexesWhat it means. Search several concrete indexes at once. The config holds
{ "indexes": ["orders-2025","orders-2026"] }.Example. Useful when yearly or monthly indexes should be searched together.
- Indexes
indexesWhat it means. One box per index. Use + Add index for another and the cross to remove one.
Example.
orders-2025,orders-2026 - Resolves to
(preview)What it means. The indexes are joined with commas into one request path.
Example.
GET /orders-2025,orders-2026/_search
Swipe the picture sideways to see all of it.
- Type: Alias
aliasWhat it means. Search an index alias. The request looks exactly like a direct index, but the config keeps it separate so a caller can still tell an alias from a real index.
Example.
{ "alias": "orders" } - Alias name
aliasWhat it means. The alias to search. The cluster decides which real indexes it points at.
Example.
orders - Resolves to
(preview)What it means. Same request shape as a direct index.
Example.
GET /orders/_search
Swipe the picture sideways to see all of it.
- Type: Index with Business Rules
routingWhat it means. The index is computed from the question's own value for a routing field, instead of being fixed. The config holds
{ "routing": {…} }. Routing never guesses: when the field is absent or nothing matches, the required default indexes are searched. QueryForge never searches an unrestricted wildcard.Example. Tenant-sharded or date-partitioned indexes.
- Strategy
routing.strategyWhat it means. How the index is chosen: Pattern, Date, Rules or If / Else. The four are compared in the next section.
Example.
pattern - Routing field
routing.fieldWhat it means. The field whose value decides the index. Only a field marked as a routing field can be picked, so an ordinary field cannot be named here by mistake.
Example.
tenantId - Index pattern
routing.indexPatternWhat it means. A template for the index name. The routing field's value is dropped into the
{braces}. For the date strategy the token must match the granularity exactly:{yyyy},{yyyy-MM}or{yyyy-MM-dd}.Example.
tenant-{tenantId}-orderswithtenantId = "acme"searchestenant-acme-orders. - Default (fallback) indexes
routing.defaultWhat it means. Required. Used whenever the routing field is missing from the question or the strategy resolves nothing.
Example.
orders-shared
Business-rule routing — four strategies
Routing never guesses: a routing field absent from the question, or a strategy that resolves nothing, falls back to a required default index list. QueryForge never searches an unrestricted wildcard.
pattern
Substitutes a field's literal value into an index template — for tenant- or region-sharded indexes.
"routing": {
"strategy": "pattern",
"field": "tenantId",
"indexPattern": "tenant-{tenantId}-orders",
"default": ["orders-shared"]
}
// tenantId = "acme" -> tenant-acme-ordersdate
The dedicated date-partition strategy. One value resolves one partition; a range expands to every partition it spans.
"routing": {
"strategy": "date",
"field": "createdAt",
"indexPattern": "orders-{yyyy-MM}",
"granularity": "MONTH",
"default": ["orders-legacy"]
}
// 2025-11-15..2026-02-10 -> 4 monthly indexesrules
An unordered list of when/then conditions. Highest priority match wins; two matches tied at the same priority is a resolution error, not a guess.
"routing": { "strategy": "rules", "rules": [
{ "when": {"field":"amount","operator":"gt","value":"1000"},
"indexes": ["orders-big"], "priority": 1 },
{ "when": {"field":"amount","operator":"gt","value":"500"},
"indexes": ["orders-medium"] }
], "default": ["orders-standard"] }ifElse
Ordered branches, evaluated top to bottom — first match wins. No notEquals: branch order already expresses "everything else."
"routing": { "strategy": "ifElse", "branches": [
{ "if": {"field":"createdAt","operator":"gte","value":"2026-01-01"},
"indexes": ["orders-2026"] },
{ "else": true, "indexes": ["orders-legacy"] }
], "default": ["orders-legacy"] }"routingField": true may be named in a routing strategy — an ordinary field referenced there is rejected at load. The indexPattern token must match the chosen granularity exactly: {yyyy}, {yyyy-MM}, or {yyyy-MM-dd}.How the compiler actually behaves
Everything above is the config. This is what the generator does with it once a question has already been validated — for anyone debugging a query that came back with unexpected results, or reviewing the compiled DSL for correctness. It reflects the actual Go implementation (gen_es.go, source_es.go, elastic_config.go), not just the config shape.
One compiler, read-only, no execution
Elasticsearch and OpenSearch are compiled by the same function. There's no dialect split today because the query-DSL surface QueryForge emits — bool/filter, term/terms/range/match/prefix/wildcard/regexp/exists/nested, sort, _source, size/from — hasn't diverged between the two products. Generate() is a pure function of the query and the config: no network call, nothing executed against a real cluster. Method is always "GET" — QueryForge only ever builds a search request, never anything that could write.
type ESQuery struct {
Method string // always "GET"
Index []string // resolved physical index/alias name(s)
Path string // "/<index0>,<index1>/_search", ready to use
SourceType string // DIRECT_INDEX | MULTIPLE_INDEX | ALIAS | BUSINESS_RULE_INDEX
Query map[string]any
Sort []map[string]any
Source any // projection — omitted means every field
Size, From int
}
A caller can use Path directly as GET <Path> with Query/Sort/_source/size/from as the body, or read Index and SourceType in structured form — both describe exactly the same resolution.
Which physical field a predicate actually touches
Every operator except contains reads a field's exact path — its keywordMapping when one is set, otherwise its ordinary mapping. Only contains on a string reads the plain mapping as analyzed text. This is the concrete mechanics behind the rule stated earlier: a field indexed twice (analyzed text + unanalyzed keyword) is searched on the right twin automatically, per operator — you never choose it yourself in the question.
| Operator | Path used | Compiles to |
|---|---|---|
equals / in | exact | term / terms |
notEquals / notIn | exact | term/terms in must_not, plus an exists guard (below) |
gt / gte / lt / lte / between | exact | range |
before / after | exact | range — inclusive on both, matching every other generator |
startsWith | exact | prefix |
endsWith | exact | wildcard — see the cost note below |
regex | exact | regexp — the raw pattern, gated upstream by policy.denyRegexOn |
contains on a string | text (plain mapping) | match — real full-text search |
contains on an array | exact | term — membership, same reading as equality on a multi-valued field |
containsAny | exact | terms |
containsAll | exact | bool/filter of one term per value (an AND, not an OR) |
isNull / isNotNull | exact | exists, negated for isNull |
endsWith has no native counterpart in Elasticsearch — a suffix match compiles to a leading wildcard (wildcard: "*value"), which can't use the term index and scans every value in the field. It's the correct translation, just an expensive one; treat it the same way you'd treat a filter on a non-indexed column.Case-insensitive comparisons
A field's caseInsensitive: true renders differently depending on the query type, because Elasticsearch's own case_insensitive parameter isn't available everywhere. term, prefix, wildcard and regexp all take it natively. terms (used for in/containsAny) does not — QueryForge renders that case as a bool/should of individually case-insensitive term queries instead, one per value, with minimum_should_match: 1.
Negation and SQL's NULL semantics — the fix behind notEquals/notIn/NOT
notIn/NOTIn SQL, status <> 'CANCELLED' evaluates to NULL — not true — for a row whose status column is NULL, so that row is excluded from the results. A bare Elasticsearch must_not doesn't have that three-valued logic: a document that's missing the field entirely still satisfies must_not, because there's nothing there to contradict it. Left alone, that mismatch means notEquals/notIn/a logical NOT would silently return more rows on Elasticsearch than the identical question returns on Postgres or Mongo — breaking the guarantee that the same question means the same rows on every backend.
QueryForge closes the gap by ANDing an exists filter alongside every such must_not, so a document missing the field is excluded from both the match and its negation — matching how SQL and Mongo already behave. The guard is skipped where it wouldn't mean anything: isNull/isNotNull (already about absence) and a NOT over a nested field (the nested object existing says nothing about the leaf value inside it). This exact gap was caught in review before the backend shipped and is pinned permanently by a dedicated test (TestESNegationRequiresExistence).
// "status" not "CANCELLED" -> without the guard, a document with no
// status field at all would incorrectly match:
{ "term": { "status.keyword": "CANCELLED" } } // naive must_not target
// what QueryForge actually compiles:
{ "bool": {
"must_not": [ { "term": { "status.keyword": "CANCELLED" } } ],
"filter": [ { "exists": { "field": "status.keyword" } } ]
} }
Nested query folding
Two conditions that are direct AND siblings and share the same nestedPath are merged into one nested query wrapping a single bool/filter, instead of becoming two separate nested queries that could each match a different array element. This is the Elasticsearch analogue of Mongo's elemMatch folding, structurally simpler because an ES nested query addresses fields by their full path even from inside the wrapper — no relative-path rewriting is needed, unlike Mongo. A lone predicate on a nested path is still wrapped in nested, just not merged with anything. Folding only ever looks at direct AND children, for the identical reason routing only trusts AND-reachable predicates (below): a condition inside an OR or a NOT isn't guaranteed to hold for the same element.
sku and price both mapped under items, nestedPath: "items". Question: "item ABC costing over 100, and not cancelled"{
"bool": { "filter": [
{ "nested": { "path": "items", "query": { "bool": { "filter": [
{ "term": { "items.sku": "ABC" } },
{ "range": { "items.price": { "gt": 100 } } }
] } } } },
{ "bool": {
"must_not": [ { "term": { "status.keyword": "CANCELLED" } } ],
"filter": [ { "exists": { "field": "status.keyword" } } ]
} }
] }
}
One nested query, not two — so a row with {sku: "ABC", price: 20} and a separate {sku: "XYZ", price: 900} line item correctly does not match, exactly like the Mongo elemMatch example earlier on this page. The negation still gets its exists guard alongside it.
Business-rule routing: what's actually trusted
The routing strategies documented above rest on one rule: only a condition reachable from the filter root through AND alone is ever used to pick an index. A condition inside an OR branch, or inside a NOT, doesn't have to hold for every row the query returns, so it's never trusted for routing — even if it looks like the "obvious" value. Two AND-reachable conditions on the same routing field are just as unusable as none at all: which one would even apply? Both cases fall back to default, exactly like an absent field.
date strategy: bounded, on purpose
Only a single equals (one partition) or a single between (an inclusive range, expanded to every partition it spans) ever resolves to a partition list. An unbounded before/after — "orders before 2020" — deliberately does not resolve: a finite bound the config never promised could span the entire index set, and silently expanding that into "search everything" is exactly the unrestricted-wildcard behavior the library guarantees against. It falls back to default instead.
rules strategy: ties are an error
The highest-priority match wins. Two matching rules tied at the same priority (including the implicit default of 0) is a hard runtime error, not a coin flip — "add distinct priorities to disambiguate." Two rules with the identical field/operator/value and priority are already rejected at load — they could never be told apart at query time.
Load-time checks specific to Elasticsearch
On top of the general field/operator rules from earlier on this page, an Elasticsearch/OpenSearch config is checked against these before it loads:
| Rule | Rejected when |
|---|---|
| One source mode per backend | index, indexes, alias and routing are mutually exclusive — more than one set on the same backend. |
| Valid index names | Any literal index/alias name (index, alias, indexes[], a routing default, a rule's or branch's indexes) isn't lowercase a-z0-9-_.+, starts with -/_/+, is empty, is ./.., or exceeds 255 characters. |
| Exactly one pattern token | indexPattern doesn't contain exactly one {token} placeholder, or — for the date strategy — the token doesn't exactly match the chosen granularity ({yyyy} / {yyyy-MM} / {yyyy-MM-dd}). |
| Routing needs an opted-in field | Any routing strategy references a field not marked routingField: true. |
| Strategy/type mismatch | pattern routing on an array or boolean field; date routing on anything but a date field. |
| Routing condition shape | An operator other than equals/notEquals/gt/gte/lt/lte; or gt/gte/lt/lte on a field that isn't number or date. |
ifElse branch shape | A branch sets both if and else, sets neither, or else isn't the last branch. |
keywordMapping / nestedPath need strings | Set on a field whose values aren't strings — not string/enum, or an array of them. |
nestedPath needs a mapping inside it | No ES mapping on the field actually sits inside the declared nestedPath — e.g. nestedPath: "items" with a mapping of plain "sku" instead of "items.sku". |
defaults & policy
Two small blocks that bound what an otherwise-valid question can do — page sizes and how deep a filter tree may nest.
Swipe the picture sideways to see all of it.
- Default page size
defaults.limitWhat it means. How many rows come back when the question does not say. Without it a query can come back unbounded.
Example.
50 - Hard ceiling on results
defaults.maxLimitWhat it means. The hard ceiling on any explicit limit. A larger request is clamped down, so no phrasing can pull an entire table.
Example.
500: "give me the first 10000 orders" becomes… LIMIT 500. - Max filter nesting
policy.maxNestingDepthWhat it means. How deeply "and" / "or" groups may nest inside each other.
0means unlimited. It bounds the cost of one pathological question.Example.
5 - Block pattern-search on these fields
policy.denyRegexOnWhat it means. Fields on which the
regexoperator is refused. A user-supplied pattern over a free-text column is a denial-of-service risk, so tick the free-text fields a user can steer.Example.
"denyRegexOn": ["customerName", "description"]
"defaults": { "limit": 50, "maxLimit": 500 },
// "give me the first 10000 orders" -> ... LIMIT 500
"policy": {
"maxNestingDepth": 5,
"denyRegexOn": ["customerName", "description"]
}
Field rules — "using this also needs that"
This is a small, optional rule. It says: if the question uses field A, it must also use field B — somewhere. If it doesn't, QueryForge stops and explains why, instead of running a question that would give a confusing or misleading answer.
Why would I need this?
Most of the time, one field is enough on its own. "Orders placed after January 1st" makes sense by itself — no other field is needed to understand it. But sometimes one field, by itself, is not enough to make the question mean something clear. The example that started this feature: a passport's expiry date only means something once you know which country's passport it is. "Passports that are expired" sounds like a real question, and every part of it is a real, working field — but the answer would be wrong or misleading without a country attached to it. A field rule catches that before a query ever runs.
The two words to know
Swipe the picture sideways to see all of it.
- The trigger field
when.fieldWhat it means. The field that switches the rule on. As soon as a question filters on it, the rule checks for its companion. The dropdown only lists fields you have already added, so you cannot mistype a name. It must be a field that exists in this config.
Example.
passportExpiry - The companion field
requireAlsoOneOfWhat it means. The field the rule then looks for. It can appear anywhere in the question; it does not have to sit next to the trigger. In the file this is a list: with more than one name only one of them has to be present, and the two dropdowns give you exactly one, which covers almost every real case.
Example.
country - Custom message (optional)
messageWhat it means. What to tell the user when the rule blocks a question. Leave it empty and QueryForge writes a sensible default for you.
Example.
Passport expiry needs a country to be meaningful - + Add rule
policy.requiresWhat it means. Each rule is one sentence made from two dropdowns. Leave the box empty and nothing changes about your config: the
requireslist only appears in the downloaded file once you have filled a rule in.Example. Completely optional. Most fields never need one.
Building one in the config builder
You never write JSON by hand for this. Open the config builder, go to Step 6 (Guardrails), and open the Field rules box. Each rule is one sentence made from two dropdowns — pick the trigger field, pick the companion field, optionally type your own message. That's it:
When passportExpiry is filtered, also require country
Both dropdowns only ever show fields you've already added to the config — you can't typo a field name into a rule, because there is nothing to type. Leave the box empty and nothing changes about your config; the rule only appears in the downloaded file once you've filled one in.
What it looks like in the JSON file
"policy": {
"requires": [
{
"when": { "field": "passportExpiry" },
"requireAlsoOneOf": ["country"],
"message": "Passport expiry needs a country to be meaningful"
}
]
}
Two worked examples
Example 1 — passports (the one above). Config has passportExpiry and country. The rule says: if passportExpiry is filtered, country must be filtered too.
- "Passports from India expiring this month" → runs normally. Both fields are present.
- "Passports that are expired" → blocked. Only
passportExpiryis present. The app gets back: "Passport expiry needs a country to be meaningful." - "Orders from India" → runs normally.
passportExpirywas never used, so the rule never switches on at all.
Example 2 — refunds. Config has refundReason and orderNumber. The rule says: if refundReason is filtered, orderNumber must be filtered too — a refund reason on its own can span thousands of unrelated orders, which is rarely what someone actually wants.
- "Why was order 4471 refunded" → runs normally.
- "Refunds because the item was damaged" → blocked — no order number anywhere in the question.
How a blocked question is reported
This is not treated as a broken question and it is not treated as an ordinary error either — it gets its own kind of answer, called a policy error, so your app can show a different, more helpful message than "something went wrong". It carries three things: which field triggered the rule, which field(s) it needed, and the message to show the user.
{
"success": false,
"code": "POLICY_VIOLATION",
"message": "Passport expiry needs a country to be meaningful",
"policyError": {
"field": "passportExpiry",
"requireAlsoOneOf": ["country"],
"message": "Passport expiry needs a country to be meaningful"
}
}
In the Go library this arrives as its own error type, *qf.PolicyViolationError — different from an ordinary "this question can't be answered" error, and different again from a plain validation mistake like a misspelled field name. QueryForge does not try the question a second time when this happens: rephrasing alone would not add the missing information, so it reports the problem once and stops, the same way it already does for a question outside the config's vocabulary.
Tenancy / scope fields
A scope field is a value your application already knows — subscriptionId, userId, enterpriseId — and forces onto every query. Declare it queryable: false so the model never even learns the field exists; you pass the value yourself, at call time, not in the question.
{ "name": "subscriptionId", "type": "string", "queryable": false }
engine.Translate(ctx, "delivered orders over 500 dollars", "sql", qf.Scope{
"subscriptionId": session.SubscriptionID,
})
// WHERE (subscriptionId = $1 AND status = $2 AND amount > $3)
Scope predicates are AND-ed onto the filter root after the model answers, and they're still fully parameterized — so a scope value can only narrow a query, never widen or replace one, and an injection payload in it stays a bound argument, not query text.
Making the tenant filter compulsory
Everything on this page so far makes a scope filter impossible to escape once you pass one. Nothing so far makes you pass it. policy.requiredScope is the missing half: it tells QueryForge to refuse any query that arrives without the tenant filter, instead of quietly running it against everybody's data.
The problem it solves
A scope filter is passed at the call site, in code — which means it can be left out at the call site, in code. Add a new endpoint, copy an existing call, forget the last argument, and you have a query that is valid, runs perfectly, returns rows, and is missing the one condition that kept each customer's data separate. Nothing errors. Nothing warns. The results look completely normal, because they are normal — just not filtered to one tenant.
There is a quieter version of the same bug. The filter is passed, but the value is empty — an environment variable that was never set, a claim missing from a token, a session field that was still null. An empty value becomes tenant_id = '', which matches no rows at all. The page comes back empty and looks exactly like a customer who has no data yet. QueryForge refuses a blank scope value for this reason, on every config, whether or not you turn the rule below on.
The three settings
In the config builder, in the Guardrails step, the box is called Always require a tenant / user filter. Pick one of three:
Swipe the picture sideways to see all of it.
- Off
(key left out)What it means. Nothing changes. A query with no tenant filter runs. This is the default, and correct for a single-tenant app or an internal tool where every user may see every row.
Example. Leave
requiredScopeout of the file. - Require any filter
requiredScope.mode: "any"What it means. Some scope must be passed, no matter which keys. A safety net against a call that forgot the argument entirely, useful when the keys differ between call sites.
Example.
"requiredScope": { "mode": "any" }. It is satisfied by{"region": "eu"}even though that is not a tenant. - Require these names
requiredScope.mode: "fields"What it means. The keys you name must each be present, with a real value. This is almost always the right choice, and the only one that catches a filter which was passed but is missing the tenant.
Example.
"requiredScope": { "mode": "fields", "fields": ["tenantId"] } - Names your app must pass
requiredScope.fieldsWhat it means. The scope keys your code passes at call time, separated by commas. These are scope keys, not fields: they are not checked against your field list, and they usually should not appear in it at all. Keep a tenant column out of the config, or declare it not-askable, so the model never learns it exists. The builder warns about the reverse: naming a key that is a queryable field. Every missing key is listed at once when a query is refused.
Example.
tenantId. A query without it is refused withINVALID_SCOPEbefore any AI call is made.
The difference between the last two is worth being clear about, because it is the whole point of naming keys. "Require any filter" is satisfied by {"region": "eu"} — something was passed, so the check is happy, and the query still is not confined to a tenant. "Require these names" with tenantId is not satisfied by that, and says so.
What it looks like in the JSON file
"policy": {
"requiredScope": {
"mode": "fields",
"fields": ["tenantId"]
}
}
Use { "mode": "any" } for the middle setting. Leave the key out entirely for "Off" — a config without it behaves exactly as it did before this setting existed.
The names are scope keys, not fields
This is the one part that surprises people. The names you list here are the keys you pass at call time — they are not checked against the field list, and they usually should not appear in it at all. The recommended setup for a tenant column is to keep it out of the config entirely, or declare it queryable: false, precisely so the model never learns it exists. If these names had to be registered fields, the safest configuration would be the one that refused to load.
The builder does warn you about the reverse mistake: naming a key that is a queryable field. That is legal, but it usually means you meant to hide the column and forgot, so the model can currently see it and write its own filter on it.
What a refused query looks like
{
"success": false,
"code": "INVALID_SCOPE",
"message": "invalid scope: this config requires scope key(s) \"tenantId\",
which the scope did not provide; pass a value for each"
}
Every missing key is listed at once, so wiring up a new call site does not turn into one error per name. In the Go library this is an errors.Is(err, qf.ErrScope) match; the Python SDK raises InvalidScopeError and the Java SDK the equivalent — the same error type you already get for a malformed scope value, because this is the same kind of problem: a bug in the calling code, not in the user's question.
The check runs before the model is called. A missing tenant filter cannot be fixed by asking the AI again, so it costs no API call and no waiting — it fails immediately, at the call site where the mistake actually is. It applies to every entry point equally: Translate, GenerateFrom and ApplyScope, so it cannot be sidestepped by compiling a hand-built AST instead of asking a question.
"", or only spaces — is refused on every config, even with this setting Off. There is no reading of an empty tenant id that means anything useful, and the alternative is a query that silently matches nothing. Numbers and booleans are untouched: 0 and false are real values, and a tenant id of 0 keeps working.A complete, valid config
Every field type, mapping, synonym, capability flag and safety rule from this page, in one file that loads as-is — the same one the Config Builder starts from when you click "start with an example."
{
"entity": "Order",
"version": 1,
"model": {
"provider": "gemini",
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
"model": "gemini-3.5-flash",
"apiKeyEnv": "QF_API_KEY",
"temperature": 0,
"maxTokens": 4096
},
"backends": {
"sql": { "table": "orders" },
"mongo": { "collection": "orders" }
},
"fields": [
{
"name": "status", "type": "enum",
"values": ["PLACED", "DELIVERED", "CANCELLED", "REFUNDED"],
"operators": ["equals", "notEquals", "in", "notIn"],
"synonyms": ["state", "order status", "delivery status"],
"indexed": true, "priority": 10,
"mapping": { "sql": "status", "mongo": "status" }
},
{
"name": "refunded", "type": "boolean",
"synonyms": ["was refunded", "is refunded"],
"mapping": { "sql": "refunded", "mongo": "refunded" }
},
{
"name": "createdAt", "type": "date",
"operators": ["before", "after", "between"],
"synonyms": ["created", "order date", "placed on", "ordered"],
"indexed": true, "priority": 8,
"mapping": { "sql": "created_at", "mongo": "createdAt" }
},
{
"name": "amount", "type": "number",
"operators": ["gt", "lt", "gte", "lte", "between"],
"synonyms": ["total", "order value", "price", "cost"],
"validators": { "min": 0 },
"mapping": { "sql": "amount", "mongo": "amount" }
},
{
"name": "tags", "type": "array", "itemType": "string",
"operators": ["contains", "containsAny", "containsAll"],
"synonyms": ["labels"],
"mapping": { "sql": "tags", "mongo": "tags" }
},
{
"name": "customerName", "type": "string",
"operators": ["contains", "startsWith", "endsWith", "equals"],
"synonyms": ["customer", "buyer", "name"],
"searchable": true,
"mapping": { "sql": "customer_name", "mongo": "customerName" }
},
{
"name": "internalNote", "type": "string",
"queryable": false, "returnable": false
}
],
"defaults": { "limit": 50, "maxLimit": 500 },
"policy": { "maxNestingDepth": 5, "denyRegexOn": ["customerName"] }
}
What it does with a real question
Given this config, the question "cancelled orders over 200 dollars" resolves status and amount — both registered, both permitting the operators used — and compiles like this:
SELECT * FROM orders
WHERE (status = $1 AND amount > $2)
-- args: ["CANCELLED", 200]{ "status": "CANCELLED", "amount": { "$gt": 200 } }Same question, same validated plan, two different compiled outputs — nothing about the fields or the question changes between backends.
Common mistakes
Pulled directly from the loader's own validation rules — these are the ones that trip people up most, roughly ordered from "won't load at all" to "loads fine but quietly does the wrong thing."
| Mistake | What happens |
|---|---|
An enum field with no values | Rejected at load — an enum needs a domain to check against. |
Pasting a real API key into apiKeyEnv | Rejected at load. That field takes a variable name, e.g. QF_API_KEY — put the key in the environment, or in the apiKey field. |
Two fields with the same name | Rejected at load — names must be unique. |
contains/startsWith/regex listed without searchable: true | Rejected at load on an enum (defaults false); on a string it's still an error if you also explicitly set searchable: false. |
validators.min greater than validators.max | Rejected at load — no value could ever satisfy both. |
caseInsensitive together with valueCase on the same field | Rejected at load — they solve the same mismatch from opposite ends and can't both apply. |
Mongo elemMatch that isn't a prefix of the field's own path | Rejected at load — the array path must be a leading part of the field's path. |
A field and a synonym on another field that are the same word | Loads, but the later field silently wins the shared index — the earlier term stops resolving. No error is raised. |
A dotted Mongo path with no elemMatch, where the first segment actually is an array of sub-documents | Loads, but two conditions on that array can each match a different element instead of the same one. |
No backends declared at all | Loads — the entity name is used as the table/collection name. Fine for a prototype; often not what was intended once the physical name diverges. |
Settings you pass at run time
The config file describes your data. These settings describe how QueryForge runs on your machine. They are environment variables, so you can change them per deployment without touching code or the config file.
They apply to the Python and Java SDKs, which run the QueryForge engine as a small helper process for each call. The Go library runs inside your own program and reads none of them — in Go you set the same things in code: a context deadline for timeouts and qf.SlogObserver for logs.
| Environment variable | Java system property | Default | What it does |
|---|---|---|---|
Whatever your config's apiKeyEnv names, e.g. QF_API_KEY | — | none | The model API key. The config file holds only the variable's name, never the key itself. |
QUERYFORGE_MAX_CONCURRENT_PROCESSES | -Dqueryforge.maxConcurrentProcesses | unset — no limit | The most engine processes allowed to run at the same time. Extra calls wait in line. See Limiting how many queries run at once. |
QUERYFORGE_LOG_LEVEL | -Dqueryforge.logLevel | off | off | error | warn | info | debug. Turns on SDK and engine logs. See Timeouts, errors and logs. |
QUERYFORGE_BINARY | -Dqueryforge.binary | the engine bundled with the package | Run this engine executable instead. If it is missing or not executable you get a clear error — the SDK never quietly falls back to a different engine. |
QUERYFORGE_CACHE_DIR | -Dqueryforge.cacheDir | the system temp directory | Java only. Where the bundled engine is unpacked before it runs. Set it when your temp directory is mounted noexec. |
-D system property wins over the environment variable of the same meaning, so a JVM launch flag can override what a container image sets.Limiting how many queries run at once
Each Python or Java call starts one engine process, and that process stays alive for the whole call — including the seconds spent waiting for the AI model to answer. A burst of 200 web requests therefore means 200 engine processes running together, which is how a server runs out of memory, file handles or process slots. One setting puts a ceiling on it.
# Python or Java
export QUERYFORGE_MAX_CONCURRENT_PROCESSES=8
# Java, as a JVM flag instead
java -Dqueryforge.maxConcurrentProcesses=8 -jar app.jar
With a limit of 8, the first 8 calls start right away. Call number 9 waits in line and starts the moment one of the 8 finishes. Waiting uses no polling and no extra threads, and your code does not change — a call that had to wait simply returns a little later.
What happens in each situation
| Situation | What happens |
|---|---|
| Not set, or set to an empty value | No limit. Exactly the behaviour from before this setting existed. |
| A slot is free | The call starts immediately. The check costs about a microsecond. |
| All slots are busy | The call waits in line and starts as soon as a slot frees up. |
| The call's timeout runs out while it is still waiting | The call fails with the code SDK_BUSY — TimeoutError in Python, TimeoutException in Java. No engine process is started, so it is always safe to retry. |
| The call has no timeout | It waits as long as it takes. |
An invalid value: 0, -1, abc, 1.5 | Every call fails with InvalidConfigError (Python) / InvalidConfigException (Java), and the message names the setting. A mistyped limit is never treated as "no limit" — you would believe you were protected when you were not. |
Waiting counts against the timeout
Time spent in line is taken out of the call's timeout, not added on top of it. Give a query a 5-second timeout, let it wait 2 seconds for a slot, and the engine gets the remaining 3 seconds. A caller who asked for 5 seconds never waits much longer than 5 seconds, however busy the server is.
The limit is per process
The count is shared by every thread inside one program. In Java that means the whole JVM — every QueryForge instance shares one limit. In Python it means one interpreter process.
The setting is read once, on the first call. Changing the variable while the program is running has no effect; restart to apply a new value.
Choosing a number
Turn on logging at info and watch the wait_ms field (next section). If it is usually near zero, the limit is rarely reached. If it is often large, calls are queueing — raise the limit if the server has room, or add workers. If the server still runs short of memory during bursts, lower it. Your AI provider's rate limit is a sensible upper bound too: more simultaneous calls than it allows only turns waiting in line into rate-limit errors.
Timeouts, errors and logs
Every failure comes back as a specific error with a stable code. There is no path that returns an empty or partial query in place of an error.
Setting a timeout
# Python (seconds)
# for every query, or for one
qf = QueryForge.postgres("orders.config.json", timeout=10)
sql = qf.query("cancelled orders").timeout(5).to_sql()
// Java (milliseconds)
// for every query, or for one
QueryForge forge = QueryForge.postgres(Paths.get("orders.config.json")).withTimeout(10_000);
String sql = forge.query("cancelled orders").timeout(5_000).toSql();
// Go: a context deadline
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := engine.Translate(ctx, "cancelled orders", "sql", nil)
Error codes
The same codes appear everywhere: on the Python exception as e.code, on the Java exception as getCode(), and in Go from qf.Classify(err). Catch the base class (QueryForgeError / QueryForgeException) if you don't care why, or a specific one if you do.
| Code | Python / Java class | What it means | Retry? |
|---|---|---|---|
INVALID_REQUEST, UNKNOWN_OP | InvalidRequestError / InvalidRequestException | The call itself is malformed — e.g. an empty question. Fix the calling code. | No |
INVALID_CONFIG | InvalidConfigError / InvalidConfigException | The config file did not load, or a run-time setting such as QUERYFORGE_MAX_CONCURRENT_PROCESSES is invalid. | No |
UNKNOWN_BACKEND | UnknownBackendError / UnknownBackendException | No generator for that backend name. | No |
INVALID_SCOPE | InvalidScopeError / InvalidScopeException | A tenant or scope filter was missing, blank or not allowed — always an application bug. See the tenant filter. | No |
VALIDATION_FAILED | ValidationError / ValidationException | The query broke a rule the config declares, usually a field the config doesn't list. | No |
UNSUPPORTED_REQUEST | UnsupportedRequestError / UnsupportedRequestException | The question can't be answered with this config. The message is written to be shown to the person who asked. | No |
POLICY_VIOLATION | PolicyViolationError / PolicyViolationException | A field rule was broken. Also safe to show to the person who asked. | No |
MODEL_OUTPUT | ModelOutputError / ModelOutputException | The model answered, but never with usable output. | Yes |
MODEL_TRANSPORT | ModelTransportError / ModelTransportException | The model could not be reached: network, missing or rejected API key, or rate limit. | Yes |
GENERATE_FAILED | GenerateError / GenerateException | A valid query plan could not be compiled for this backend. | No |
TIMEOUT | TimeoutError / TimeoutException | The engine ran past the call's timeout. | Maybe |
SDK_BUSY | TimeoutError / TimeoutException | Python and Java only. The timeout ran out while waiting for a free slot under QUERYFORGE_MAX_CONCURRENT_PROCESSES. Nothing was started. | Yes |
BINARY_NOT_FOUND | BinaryNotFoundError / BinaryNotFoundException | Python and Java only. No engine for this platform — reinstall, or set QUERYFORGE_BINARY. | No |
PROTOCOL_ERROR | ProtocolError / ProtocolException | Python and Java only. The engine crashed or is the wrong version — a broken install. | No |
INTERNAL | QueryForgeError / QueryForgeException | An error QueryForge has no name for. Worth reporting as a bug. | No |
TIMEOUT and SDK_BUSY share a class on purpose, so one except TimeoutError / catch (TimeoutException e) handles both. Check the code when you need to tell "the engine was slow" apart from "the server was too busy to start it".
Logs
Logging is off until you turn it on. Set QUERYFORGE_LOG_LEVEL=info and every call writes one line when it finishes:
engine request completed
library=queryforge language=java operation=translate request_id=e63571548915
backend=sql entity=Order outcome=ok duration_ms=2310 wait_ms=410
duration_ms is how long the call took in total. wait_ms appears only when QUERYFORGE_MAX_CONCURRENT_PROCESSES is set, and is the part of that time spent waiting for a slot. A failed call writes one error line instead, carrying error_code.
debug. Only the shape is logged: the entity, the backend, and the names of the scope keys. The full field list is in docs/OBSERVABILITY.md.Quick reference
Every key on one page, for when you already know what you're looking for.
Top level
entity | string, required |
version | number, optional |
model | object, optional |
models | array of model objects, optional |
backends | object keyed by backend id, optional |
fields | array of field objects, required, min length 1 |
defaults | { limit, maxLimit }, optional |
policy | { maxNestingDepth, denyRegexOn, requires, requiredScope }, optional |
timezone | IANA zone name, optional, defaults to UTC |
Every field key
name | string, required |
type | string | number | boolean | enum | date | array, required |
values | string[], required if type is enum (or itemType is enum) |
itemType | a field type, used when type is array |
operators | string[] from the operator catalogue |
synonyms | string[] |
customField | boolean — makes description (and valueHint, on a searchable string) required |
displayName | string, always optional |
description | string, required once customField is true |
valueHint | string — searchable string fields only |
mapping | { backendId: physicalPath } |
elemMatch | string — Mongo array path |
keywordMapping | { elasticsearch/opensearch: path } |
nestedPath | string — ES nested object path |
routingField | boolean |
valueCase | "lower" | "upper" |
caseInsensitive | boolean, string fields only |
queryable / filterable / searchable / sortable / returnable | boolean, each independently defaulted |
indexed | boolean |
priority | number |
validators | { min, max }, number fields |
How QueryForge works
QueryForge has one engine, written in Go. The engine does all the real work. The Python and Java packages are small helpers that pass your question to that same engine. The pictures below show how the pieces fit together.
The big picture
Your app sends a question and your config. The engine asks an AI model what the question means, and the AI answers with a plan. The engine checks that plan against your config — only the fields and values you allowed can pass. Then the engine writes the query. Your app runs it with the database driver you already use.
Three ways to use the same engine
In Go, the engine is a normal library inside your program. In Python and Java, the engine is a separate small program that comes inside the package. The Python or Java code starts it when you make a call. You never see this happen, and you don't need Go installed.
Because every language uses the same engine, the same question and config give the exact same query in Go, Python and Java.
What happens in one call (Python and Java)
- You call
to_sql()in Python ortoSql()in Java. - The SDK finds the engine program made for your computer.
- It starts the program and sends one JSON message with your question and config. Your API key is not in the message — the engine reads it from the environment.
- The engine asks the AI model, checks the plan against your config, and builds the query.
- The engine sends one JSON message back, then closes.
- The SDK gives you the query and its values. If something went wrong, you get a clear error instead.
Nothing keeps running between calls. There is no server, no open port, and nothing to install besides the package. Starting the engine takes a few milliseconds; the AI model takes much longer. If your app makes many calls at the same time, read Limiting how many queries run at once.
Java: why two dependencies? (the classifier)
The engine is a real program, like any app on your computer. A program is built for one kind of computer: one operating system (Linux, Mac or Windows) and one type of chip (Intel/AMD or ARM). A program built for a Mac does not run on Linux.
Java code is different — the same jar runs everywhere. So the Java SDK comes in two parts:
queryforge— the Java code. One small file for everyone.queryforge+ a classifier — the engine program for one kind of computer. There are five, one for each computer type.
A classifier is just a label in Maven that picks one version of a file. You add the one that matches your computer. We don't put all five engines in one jar, because then everyone would download four engines they never use.
| Your app runs on | Classifier |
|---|---|
| Linux, Intel or AMD chip (most servers) | linux-amd64 |
| Linux, ARM chip (e.g. AWS Graviton) | linux-arm64 |
| Mac with an Intel chip | darwin-amd64 |
| Mac with an Apple chip (M1 and newer) | darwin-arm64 |
| Windows, Intel or AMD chip | windows-amd64 |
linux-amd64. If the classifier is wrong or missing, the first call fails with BinaryNotFoundException, and the message names the computer type it looked for.What happens on the first call. Java cannot run a program while it is still packed inside a jar. So the first time you make a call, the SDK copies the engine out to a temp folder and runs it from there. Every later call uses that same copy.
Each engine version gets its own copy, so two apps using different QueryForge versions never mix them up. If your temp folder does not allow running programs (it is mounted noexec), set QUERYFORGE_CACHE_DIR to a different folder.
Python: why only one install?
pip does the choosing for you. QueryForge publishes a separate package file (a "wheel") for each kind of computer, and pip install queryforge-ai downloads the one that matches your machine. The engine is already inside it, ready to run. There is nothing to copy and no label to pick.
pip install inside the image, so pip picks the engine for the image, not for your laptop.Why we built it this way
| Choice | What you get |
|---|---|
| One engine for every language | The same results everywhere. There are no separate Python and Java copies that slowly drift apart. |
| A small program, not a server | Nothing to host, no port to open, nothing to keep alive or monitor. |
| The engine ships inside the package | No Go to install. The Java library has zero other dependencies. |
| The engine runs as part of your app | It has your app's permissions and nothing more. It only talks to the AI model — never to your database. |
The trade-off: each Python or Java call starts a small program (a few milliseconds), and in Java you have to pick the right classifier for your computer.