Documentation

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.

Build one interactively → Jump to a full example
Getting started

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"
QueryForge only builds the query — it never connects to your database. You run the SQL or Mongo query it hands back with whatever driver you already use.

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.

All three give back the same thing for the same question: a query string, its parameter values (never inlined — that's what stops injection), and a plain-English readback of what QueryForge understood. Bind the values with your own driver; QueryForge never runs the query for you.

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.

Overview

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.

KeyRequired?What it's for
entityrequiredThe name of the thing you're querying — "Order", "Customer". Every incoming query must name this exactly.
fieldsrequiredThe whole vocabulary. At least one field. Anything not listed here can't be asked about.
versionoptionalYour own revision number — for your logs, not read by the loader.
modeloptionalWhich AI service turns a sentence into a query plan.
modelsoptionalA fallback chain of extra models, tried in order if the first is unreachable.
backendsoptionalThe real table, collection, or index each database uses.
defaultsoptionalPage size when a question doesn't say, and the hard ceiling on any explicit page size.
policyoptionalSafety 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.
timezoneoptionalThe 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]
A config that fails any rule below is rejected at LoadConfig time, before your service ever starts handling questions — not discovered later on a live request.
Step 1 · the basics

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.

123

Swipe the picture sideways to see all of it.

  1. Name entity

    What 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.

  2. Version number version

    What 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.

  3. Timezone timezone

    What 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"
Step 2 · the AI planner

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.

12345

Swipe the picture sideways to see all of it.

  1. Provider card provider · baseURL

    What 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 Gemini baseURL. Use Custom endpoint for anything else.

  2. Model model

    What 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

  3. Variable name apiKeyEnv

    What 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

  4. API key: variable or paste apiKeyEnv · apiKey

    What it means. Choose one way to give QueryForge the key. "Use an environment variable" writes apiKeyEnv (preferred). "Paste my key" writes apiKey into the file instead. The two are never allowed together.

    Example. See the next picture for what "Paste my key" shows.

  5. The line to run (not saved)

    What it means. The builder shows the exact export line 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.

12

Swipe the picture sideways to see all of it.

  1. Your API key apiKey

    What 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.

  2. 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.

12345

Swipe the picture sideways to see all of it.

  1. Provider label provider

    What it means. A label that picks which client talks to the service. anthropic and bedrock have 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

  2. API address baseURL

    What it means. The API root, without /chat/completions, which is added for you. Required for every provider except anthropic and bedrock, which work out their own address.

    Example. https://generativelanguage.googleapis.com/v1beta/openai

  3. Creativity temperature

    What it means. How random the model is allowed to be. Set it to 0 so the same question compiles the same way every time. Left blank, the provider default is used.

    Example. 0

  4. Reply length limit maxTokens

    What 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

  5. Force JSON replies jsonMode

    What it means. Sends response_format=json_object with 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.

1234

Swipe the picture sideways to see all of it.

  1. Wire format protocol

    What 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, anthropic or bedrock.

    Example. auto (leave the key out)

  2. Timeout, seconds timeoutSeconds

    What 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

  3. Retry attempts maxRetries

    What 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 0 to fall through to the next backup model straight away.

    Example. default 2

  4. Retry wait, ms retryBackoffMs

    What it means. The delay before the first retry. It doubles after that, with a little random jitter. If the provider sends its own Retry-After header, 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
}
Prefer 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).

1234567

Swipe the picture sideways to see all of it.

  1. AWS region aws.region

    What 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, set baseURL instead; it wins.

    Example. us-east-1

  2. Authentication bearerToken · accessKeyId …

    What it means. Which credentials to use. Standard AWS environment writes nothing and reads AWS_BEARER_TOKEN_BEDROCK or the usual AWS_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.

  3. Access key ID aws.accessKeyId · accessKeyIdEnv

    What 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 writes accessKeyId). The secret below works the same way, as secretAccessKey / secretAccessKeyEnv. Half a key pair is rejected when the config loads.

    Example. AWS_ACCESS_KEY_ID

  4. Where does it come from? Env or plain name

    What it means. Chooses between the two ways to give a credential: the Env form 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.

  5. Secret access key aws.secretAccessKey · secretAccessKeyEnv

    What it means. The other half of the pair. Together with the access key ID it signs every request.

    Example. AWS_SECRET_ACCESS_KEY

  6. Session token aws.sessionToken · sessionTokenEnv

    What 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)

  7. The lines to run (not saved)

    What it means. The export lines 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.

Credentials written into the file travel with it, into version control and bug reports. QueryForge allows it, redacts them from every log line, error and re-serialised JSON, and adds a warning to every translate result that names the field (never the value). Prefer the Env form and the export lines the Config Builder shows you.
QueryForge signs Bedrock requests itself, but it does not look up profiles, SSO, AssumeRole, web identity or instance roles. On those, resolve temporary credentials in your own process and hand them over (Python 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.

123456

Swipe the picture sideways to see all of it.

  1. 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".

  2. Move earlier / later / remove order

    What 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.

  3. Provider provider

    What it means. The same label as on the main model: the client to use. Anything other than anthropic or bedrock uses the OpenAI-compatible client.

    Example. groq

  4. Model id model

    What it means. The model id exactly as that provider spells it.

    Example. llama-3.3-70b-versatile

  5. API address baseURL

    What it means. The API root of the backup service, without /chat/completions.

    Example. https://api.groq.com/openai/v1

  6. Key variable apiKeyEnv

    What 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.

Step 3 · where the data lives

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.

123

Swipe the picture sideways to see all of it.

  1. Database sql · mysql · mongo

    What 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). mysql is its own choice (? placeholders, backticks; MySQL 8.0+ or MariaDB 10.5+), and mongo is MongoDB.

    Example. PostgreSQL — sql

  2. Physical table name table · collection

    What 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 stays Order; only the compiled query uses this name.

    Example. orders_v2 → db.orders_v2.find(…) for Mongo, SELECT * FROM orders_v2 … for SQL.

  3. Elasticsearch / OpenSearch elasticsearch · opensearch

    What 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".

Any other key (not 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.
Step 4 · the vocabulary

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.

1234567

Swipe the picture sideways to see all of it.

  1. Name name

    What 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

  2. Kind of value type

    What 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

  3. Physical name, per database mapping

    What 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" }

  4. MongoDB physical name mapping.mongo

    What it means. Dot paths are fine here, because a Mongo document can nest values inside other values.

    Example. customer.address.city

  5. Allowed values values

    What it means. The complete list for an enum field, 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

  6. Other ways to say it synonyms

    What 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 to status.

  7. Allowed comparisons operators

    What 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, in and notIn are allowed on status.

For a field of kind array, one more box appears: List holds (itemType).

12

Swipe the picture sideways to see all of it.

  1. Kind of value type

    What it means. Choose array when one record holds a list of values, such as a list of tags.

    Example. array — a list of values

  2. List holds itemType

    What 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.

Step 4 · the vocabulary

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.

Nothing here connects to a live database. The builder is a static page with no server of its own, so it can only read text you paste — never a hostname, a port, or a password. You run one read-only command yourself and copy its output in; the parsing happens entirely in your browser.

Pick the database you are importing from. Each tab shows what the builder asks you to paste, and how to get it.

1234

Swipe the picture sideways to see all of it.

  1. 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 _mapping response.

  2. 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

  3. Paste it here

    What it means. Paste what the command printed. A _mapping response. The gray example inside the box shows the expected shape, and parsing happens entirely in your browser.

    Example. { "properties": { "status": { "type": "keyword" }, … } }

  4. 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 no nestedPath).
  • elemMatch — from a Mongo array whose sample elements are sub-documents, not scalars.
  • indexed — from a SQL PRIMARY KEY or UNIQUE column.
  • values — from a MySQL ENUM(...) 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.

12345

Swipe the picture sideways to see all of it.

  1. 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. location is new, so it is ticked.

  2. 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. status and amount were already added.

  3. Type type

    What 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 becomes number).

    Example. double becomes number.

  4. 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 string with 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 Elasticsearch nested type), elemMatch (a Mongo array of sub-documents), indexed (a SQL primary key or unique column) and values (a MySQL ENUM list).

    Example. "unrecognized type geo_point — mapped to string, review before use".

  5. 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.

Fields

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).

12

Swipe the picture sideways to see all of it.

  1. Kind of value: string type

    What it means. Holds: Free text. Names, notes, ids written as text.

    Example. "type": "string"

  2. Default comparisons operators

    What 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.

An 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.
Fields

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.

12345

Swipe the picture sideways to see all of it.

  1. Can be asked about queryable

    What it means. Whether the model sees the field at all. false hides it from the prompt completely and rejects any query that names it. This is how a tenancy column is declared. Default: true.

    Example. internalCost with false: "orders where internalCost is over 50" is refused.

  2. Can be filtered on filterable

    What it means. Whether the field may appear in a filter (the WHERE clause, or the find document). Default: true.

    Example. false: the field can be shown or sorted but never filtered on.

  3. Can be text-searched searchable

    What it means. Whether text-search comparisons (contains, startsWith, endsWith, regex) are allowed. Only means anything on string and enum. Default: true for a string, otherwise false.

    Example. description with false: "description mentions x" is refused, but it can still be shown and sorted.

  4. Can sort results sortable

    What 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.

  5. Shown in results returnable

    What it means. Whether the field may appear in results. false also removes it from the default projection, so SELECT * becomes an explicit list of allowed columns and the value cannot leak even by omission. Default: true.

    Example. salary with false: SELECT * becomes SELECT 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.

Fields

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.

12345

Swipe the picture sideways to see all of it.

  1. Name name

    What it means. A name that explains nothing by itself, such as txt01 or customField3: leftovers from a generic or per-tenant schema. The model cannot guess what they hold the way it can guess that status is a status.

    Example. txt01

  2. Label displayName

    What it means. A friendlier name shown to the model and used in the plain-English readback (Explain). The model still writes the real name in 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".

  3. This is a custom field customField

    What 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 status or amount never need it.

  4. Description description

    What it means. One short line on what the field means. It is shown to the model. Required once customField is ticked. Up to 500 characters.

    Example. ISO country of issuance for the passport

  5. Value hint valueHint

    What 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 customField is ticked. Up to 1000 characters.

    Example. Ask "passports issued in Germany" and the model now knows txt01 is 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".

Ordinary fields don't need any of this. A field already named clearly — 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.
Fields

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 keyword path for exact match, sort and aggregation, because a single field can't do both. contains reads the plain mapping; every other operator prefers keywordMapping when 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 SHIPPED while people say "shipped". Only the compiled argument is recased; the model still sees and validates against the case you wrote in values.
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 / max bounds, checked before any query is built. {"min": 0, "max": 5} on rating rejects "rating above 9" outright, instead of quietly returning nothing.
Fields

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.

OperatorMeansTypically used on
equals / notEqualsExact matchany type
gt / lt / gte / lteGreater / less than (or equal)number, date
betweenInclusive range — or, given a period, the whole calendar range it namesnumber, date
in / notInMembership in a short liststring, number, enum, array
contains / startsWith / endsWithSubstring match — needs searchable: truestring, enum
containsAny / containsAllArray membership: any / every listed value presentarray
regexPattern match — needs searchable: true, blockable via policy.denyRegexOnstring
before / afterStrictly earlier / laterdate
isNull / isNotNullPresence checkany type
Listing an operator that can never be satisfied on a field's type (say, 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.
Fields

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.

PhrasePeriodAsked on 2026-09-15 (UTC), selects
todayday 0..02026-09-15 00:00 → 2026-09-16 00:00
yesterdayday -1..-12026-09-14 00:00 → 2026-09-15 00:00
last 7 daysday -6..02026-09-09 00:00 → 2026-09-16 00:00 (7 days, today included)
last 30 daysday -29..02026-08-17 00:00 → 2026-09-16 00:00
this weekweek 0..0Mon 2026-09-14 00:00 → Mon 2026-09-21 00:00
last monthmonth -1..-12026-08-01 00:00 → 2026-09-01 00:00
this yearyear 0..02026-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.

ValueResult
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_date values 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)".
One config carries one zone, because one config serves one entity. If your users genuinely span several zones and each needs their own midnight, this key is not the tool for it — say so on the issue tracker rather than picking a zone at random.
A period with a bound missing is rejected outright: {"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.
Search backends

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.

123

Swipe the picture sideways to see all of it.

  1. Type: Direct Index index

    What it means. Search exactly one real index. The config holds { "index": "orders" }.

    Example. The request becomes GET /orders/_search.

  2. Index name index

    What it means. The physical index to search. Index names must be valid: lowercase, with none of the characters Elasticsearch forbids.

    Example. orders

  3. 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

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-orders

date

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 indexes

rules

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"] }
Only fields marked "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}.
Search backends

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.

OperatorPath usedCompiles to
equals / inexactterm / terms
notEquals / notInexactterm/terms in must_not, plus an exists guard (below)
gt / gte / lt / lte / betweenexactrange
before / afterexactrange — inclusive on both, matching every other generator
startsWithexactprefix
endsWithexactwildcard — see the cost note below
regexexactregexp — the raw pattern, gated upstream by policy.denyRegexOn
contains on a stringtext (plain mapping)match — real full-text search
contains on an arrayexactterm — membership, same reading as equality on a multi-valued field
containsAnyexactterms
containsAllexactbool/filter of one term per value (an AND, not an OR)
isNull / isNotNullexactexists, 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

In 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.

Config: 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:

RuleRejected when
One source mode per backendindex, indexes, alias and routing are mutually exclusive — more than one set on the same backend.
Valid index namesAny 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 tokenindexPattern 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 fieldAny routing strategy references a field not marked routingField: true.
Strategy/type mismatchpattern routing on an array or boolean field; date routing on anything but a date field.
Routing condition shapeAn 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 shapeA branch sets both if and else, sets neither, or else isn't the last branch.
keywordMapping / nestedPath need stringsSet on a field whose values aren't strings — not string/enum, or an array of them.
nestedPath needs a mapping inside itNo 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".
Last step · guardrails

defaults & policy

Two small blocks that bound what an otherwise-valid question can do — page sizes and how deep a filter tree may nest.

1234

Swipe the picture sideways to see all of it.

  1. Default page size defaults.limit

    What it means. How many rows come back when the question does not say. Without it a query can come back unbounded.

    Example. 50

  2. Hard ceiling on results defaults.maxLimit

    What 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.

  3. Max filter nesting policy.maxNestingDepth

    What it means. How deeply "and" / "or" groups may nest inside each other. 0 means unlimited. It bounds the cost of one pathological question.

    Example. 5

  4. Block pattern-search on these fields policy.denyRegexOn

    What it means. Fields on which the regex operator 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"]
}
Last step · guardrails

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

1234

Swipe the picture sideways to see all of it.

  1. The trigger field when.field

    What 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

  2. The companion field requireAlsoOneOf

    What 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

  3. Custom message (optional) message

    What 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

  4. + Add rule policy.requires

    What it means. Each rule is one sentence made from two dropdowns. Leave the box empty and nothing changes about your config: the requires list 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 passportExpiry is present. The app gets back: "Passport expiry needs a country to be meaningful."
  • "Orders from India" → runs normally. passportExpiry was 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.

Completely optional. A config with no field rules behaves exactly as it always has. Add one only for a field that genuinely doesn't mean anything on its own — most fields never need this.
Multi-tenancy

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.

Last step · guardrails

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:

1234

Swipe the picture sideways to see all of it.

  1. 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 requiredScope out of the file.

  2. 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.

  3. 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"] }

  4. Names your app must pass requiredScope.fields

    What 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 with INVALID_SCOPE before 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.

A blank value — "", 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.
Putting it together

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:

PostgreSQL
SELECT * FROM orders
WHERE (status = $1 AND amount > $2)
-- args: ["CANCELLED", 200]
MongoDB
{ "status": "CANCELLED", "amount": { "$gt": 200 } }

Same question, same validated plan, two different compiled outputs — nothing about the fields or the question changes between backends.

Putting it together

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."

MistakeWhat happens
An enum field with no valuesRejected at load — an enum needs a domain to check against.
Pasting a real API key into apiKeyEnvRejected 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 nameRejected at load — names must be unique.
contains/startsWith/regex listed without searchable: trueRejected 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.maxRejected at load — no value could ever satisfy both.
caseInsensitive together with valueCase on the same fieldRejected 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 pathRejected 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 wordLoads, 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-documentsLoads, but two conditions on that array can each match a different element instead of the same one.
No backends declared at allLoads — the entity name is used as the table/collection name. Fine for a prototype; often not what was intended once the physical name diverges.
The Config Builder runs every one of these checks live as you type, and tells you exactly which key is affected — it's the fastest way to catch these before they reach a real load. Open it →
Running in production

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 variableJava system propertyDefaultWhat it does
Whatever your config's apiKeyEnv names, e.g. QF_API_KEY—noneThe model API key. The config file holds only the variable's name, never the key itself.
QUERYFORGE_MAX_CONCURRENT_PROCESSES-Dqueryforge.maxConcurrentProcessesunset — no limitThe 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.logLeveloffoff | error | warn | info | debug. Turns on SDK and engine logs. See Timeouts, errors and logs.
QUERYFORGE_BINARY-Dqueryforge.binarythe engine bundled with the packageRun 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.cacheDirthe system temp directoryJava only. Where the bundled engine is unpacked before it runs. Set it when your temp directory is mounted noexec.
In Java, a -D system property wins over the environment variable of the same meaning, so a JVM launch flag can override what a container image sets.
Running in production

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

SituationWhat happens
Not set, or set to an empty valueNo limit. Exactly the behaviour from before this setting existed.
A slot is freeThe call starts immediately. The check costs about a microsecond.
All slots are busyThe call waits in line and starts as soon as a slot frees up.
The call's timeout runs out while it is still waitingThe 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 timeoutIt waits as long as it takes.
An invalid value: 0, -1, abc, 1.5Every 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.

Separate processes each get their own limit. A Python app running 4 gunicorn or uvicorn workers with a limit of 8 can run up to 32 engine processes in total, and 3 Java replicas with a limit of 8 can run 24. Choose the number per worker.

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.

Running in production

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.

CodePython / Java classWhat it meansRetry?
INVALID_REQUEST, UNKNOWN_OPInvalidRequestError / InvalidRequestExceptionThe call itself is malformed — e.g. an empty question. Fix the calling code.No
INVALID_CONFIGInvalidConfigError / InvalidConfigExceptionThe config file did not load, or a run-time setting such as QUERYFORGE_MAX_CONCURRENT_PROCESSES is invalid.No
UNKNOWN_BACKENDUnknownBackendError / UnknownBackendExceptionNo generator for that backend name.No
INVALID_SCOPEInvalidScopeError / InvalidScopeExceptionA tenant or scope filter was missing, blank or not allowed — always an application bug. See the tenant filter.No
VALIDATION_FAILEDValidationError / ValidationExceptionThe query broke a rule the config declares, usually a field the config doesn't list.No
UNSUPPORTED_REQUESTUnsupportedRequestError / UnsupportedRequestExceptionThe question can't be answered with this config. The message is written to be shown to the person who asked.No
POLICY_VIOLATIONPolicyViolationError / PolicyViolationExceptionA field rule was broken. Also safe to show to the person who asked.No
MODEL_OUTPUTModelOutputError / ModelOutputExceptionThe model answered, but never with usable output.Yes
MODEL_TRANSPORTModelTransportError / ModelTransportExceptionThe model could not be reached: network, missing or rejected API key, or rate limit.Yes
GENERATE_FAILEDGenerateError / GenerateExceptionA valid query plan could not be compiled for this backend.No
TIMEOUTTimeoutError / TimeoutExceptionThe engine ran past the call's timeout.Maybe
SDK_BUSYTimeoutError / TimeoutExceptionPython and Java only. The timeout ran out while waiting for a free slot under QUERYFORGE_MAX_CONCURRENT_PROCESSES. Nothing was started.Yes
BINARY_NOT_FOUNDBinaryNotFoundError / BinaryNotFoundExceptionPython and Java only. No engine for this platform — reinstall, or set QUERYFORGE_BINARY.No
PROTOCOL_ERRORProtocolError / ProtocolExceptionPython and Java only. The engine crashed or is the wrong version — a broken install.No
INTERNALQueryForgeError / QueryForgeExceptionAn 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.

Logs never contain the question text, scope or tenant values, the config's contents, or API keys — at any level, including 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.
Cheat sheet

Quick reference

Every key on one page, for when you already know what you're looking for.

Top level

entitystring, required
versionnumber, optional
modelobject, optional
modelsarray of model objects, optional
backendsobject keyed by backend id, optional
fieldsarray of field objects, required, min length 1
defaults{ limit, maxLimit }, optional
policy{ maxNestingDepth, denyRegexOn, requires, requiredScope }, optional
timezoneIANA zone name, optional, defaults to UTC

Every field key

namestring, required
typestring | number | boolean | enum | date | array, required
valuesstring[], required if type is enum (or itemType is enum)
itemTypea field type, used when type is array
operatorsstring[] from the operator catalogue
synonymsstring[]
customFieldboolean — makes description (and valueHint, on a searchable string) required
displayNamestring, always optional
descriptionstring, required once customField is true
valueHintstring — searchable string fields only
mapping{ backendId: physicalPath }
elemMatchstring — Mongo array path
keywordMapping{ elasticsearch/opensearch: path }
nestedPathstring — ES nested object path
routingFieldboolean
valueCase"lower" | "upper"
caseInsensitiveboolean, string fields only
queryable / filterable / searchable / sortable / returnableboolean, each independently defaulted
indexedboolean
prioritynumber
validators{ min, max }, number fields
Build a config now → View the Go source
How it works

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

The big picture Your app sends a question to the QueryForge engine. The engine asks an AI model what it means, checks the answer, and sends back a query. Your app runs the query on your own database. QueryForge never connects to the database. Your app Go, Python or Java QueryForge engine checks the plan and builds the query AI model Gemini, OpenAI, Groq… question query + values what does it mean? a plan you run the query Your database SQL, Mongo, Elasticsearch QueryForge never connects to your database.

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.

The AI never writes the SQL. It only suggests a plan. QueryForge checks the plan and builds the query itself, with the values kept separate from the query text. That is what stops SQL injection.

Three ways to use the same engine

Three ways to use the same engine In a Go app the engine is a library inside the app. In a Python app and a Java app, a small wrapper starts a separate engine program for each call. All three use the same engine code, so they give the same query. Go app QueryForge engine a normal Go library No separate program. Nothing extra to start. Python app queryforge package small helper, no query logic starts it per call Engine program (Go) comes inside the pip package Java app queryforge jar small helper, no query logic starts it per call Engine program (Go) comes in the classifier jar Same engine code in all three → the same question gives the same query

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)

What happens in one call Your code asks the SDK for a query. The SDK finds the engine, starts it and sends one JSON message. The engine asks the AI model, gets a plan, checks it and builds the query. It sends one JSON message back and closes. The SDK gives your code the query and its values, or an error. Your code QueryForge SDK Python or Java Engine program Go · lives for one call AI model 1 to_sql() / toSql() 2 finds the right engine 3 starts it, sends 1 JSON message 4 what does it mean? a plan checks the plan with your config and builds the query 5 sends 1 JSON message back then the program closes 6 query + values, or an error
  1. You call to_sql() in Python or toSql() in Java.
  2. The SDK finds the engine program made for your computer.
  3. 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.
  4. The engine asks the AI model, checks the plan against your config, and builds the query.
  5. The engine sends one JSON message back, then closes.
  6. 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:

Java needs two dependencies Maven Central has one queryforge jar with the Java code, and five engine jars, one for each kind of computer. A Java app on a Linux Intel or AMD server uses the Java code jar plus the linux-amd64 engine jar, chosen with the classifier. Maven Central queryforge Java code Engine programs — pick one: linux-amd64 Linux · Intel/AMD linux-arm64 Linux · ARM darwin-amd64 Mac · Intel darwin-arm64 Mac · Apple chip windows-amd64 Windows · Intel/AMD Your Java app running on a Linux Intel/AMD server queryforge-<version>.jar the Java code — same for every computer queryforge-<version>-linux-amd64.jar the engine program, built for exactly this kind of computer the classifier picks this one
  • 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 onClassifier
Linux, Intel or AMD chip (most servers)linux-amd64
Linux, ARM chip (e.g. AWS Graviton)linux-arm64
Mac with an Intel chipdarwin-amd64
Mac with an Apple chip (M1 and newer)darwin-arm64
Windows, Intel or AMD chipwindows-amd64
Pick the classifier for the computer where your app runs, not where you build it. For example, if you build on a Mac but deploy to a Linux server, use 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.

The first call in Java The engine sits inside the jar. On the first call the SDK copies it to a temp folder, once. Then it runs it. Later calls reuse the copy. Engine inside the jar can't be run from there First call: copy it out to a temp folder, only once Run it later calls reuse the copy <temp folder>/queryforge-bin/ move it with QUERYFORGE_CACHE_DIR

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.

The same rule applies: install on the computer where the app runs. In Docker, run pip install inside the image, so pip picks the engine for the image, not for your laptop.

Why we built it this way

ChoiceWhat you get
One engine for every languageThe same results everywhere. There are no separate Python and Java copies that slowly drift apart.
A small program, not a serverNothing to host, no port to open, nothing to keep alive or monitor.
The engine ships inside the packageNo Go to install. The Java library has zero other dependencies.
The engine runs as part of your appIt 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.