In systems with many interrelated SQL tables, writing queries with the correct joins is complex and error-prone. Developers must manually trace foreign key relationships, determine join paths, and construct multi-table queries — work that the schema itself already encodes.
Kiss provides an automatic SQL query generator that solves this problem. The developer specifies what columns to select, what conditions to apply, and how to order the results. The system determines the join path automatically and produces the correct SQL.
All classes are in the org.kissweb.database package:
SchemaGraphModels the database schema as a graph where tables are nodes and foreign key relationships are edges. Finds the shortest join path between tables using BFS (breadth-first search).
QueryBuilderFluent API to specify SELECT, WHERE, ORDER BY, GROUP BY, HAVING, and
explicit joins. Uses a SchemaGraph to automatically determine the
required joins and produce the final SQL.
SchemaGraph.EdgeRepresents a single or composite foreign key relationship between two tables.
The schema graph must be built once at application startup. There are several ways to create it.
SchemaGraph graph = SchemaGraph.fromDatabase(conn);
This reads JDBC DatabaseMetaData for all tables and their foreign
keys. Composite (multi-column) foreign keys are automatically grouped
by constraint name. Works on all five supported databases (PostgreSQL,
MySQL, SQLite, SQL Server, Oracle).
SchemaGraph graph = new SchemaGraph();
graph.addForeignKey("employee", "department_id",
"department", "department_id");
graph.addForeignKey("order_line",
new String[]{"order_id", "product_id"},
"order_product",
new String[]{"order_id", "product_id"});
SchemaGraph graph = SchemaGraph.fromDatabase(conn);
graph.addForeignKey("audit_log", "user_id",
"employee", "employee_id");
// First run: read from database, save cache
SchemaGraph graph = SchemaGraph.fromDatabase(conn);
graph.saveToFile("schema-cache.txt");
// Subsequent runs: load from cache (sub-millisecond)
SchemaGraph graph = SchemaGraph.loadFromFile("schema-cache.txt");
List<Record> records = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.select("department.name AS dept_name")
.select("building.address")
.where("project.project_id = ?", projectId)
.where("employee.active = 'Y'")
.orderBy("employee.last_name")
.orderBy("employee.first_name")
.fetchAll(conn);
for (Record r : records) {
String name = r.getString("first_name");
String dept = r.getString("dept_name");
}
The system automatically generates SQL such as:
SELECT employee.first_name, employee.last_name,
department.name AS dept_name, building.address
FROM project
JOIN project_assignment
ON project_assignment.project_id = project.project_id
JOIN employee
ON project_assignment.employee_id = employee.employee_id
JOIN department
ON employee.department_id = department.department_id
JOIN building
ON department.building_id = building.building_id
WHERE project.project_id = ?
AND employee.active = 'Y'
ORDER BY employee.last_name, employee.first_name
Create an empty schema graph for programmatic population.
Build a schema graph by reading all tables and foreign keys from JDBC
DatabaseMetaData. Called once at application startup.
Parameters:
conn — a Kiss Connection object
Returns: a populated SchemaGraph
Throws: SQLException if database metadata cannot be read
Load a schema graph from a cache file previously saved with
saveToFile().
Parameters:
path — path to the cache file
Returns: a populated SchemaGraph
Throws: IOException if the file cannot be read or has an invalid format
Declare a table in the graph. This is optional — tables are
automatically created when referenced by addForeignKey.
Declare a single-column foreign key relationship between two tables. Both tables are automatically added to the graph if not already present. Duplicate edges are ignored.
Parameters:
fromTable — the child table containing the FK column
fromColumn — the FK column in the child table
toTable — the parent/referenced table
toColumn — the referenced column (usually the primary key)
Declare a composite (multi-column) foreign key relationship. Both tables are automatically added to the graph if not already present. Duplicate edges are ignored.
Parameters:
fromTable — the child table containing the FK columns
fromColumns — array of FK column names in the child table
toTable — the parent/referenced table
toColumns — array of referenced column names
Check whether a table exists in the graph.
Returns: true if the table has been added
Return all table names in the graph.
Returns: an unmodifiable Set<String> of table names
Return all foreign key edges incident on a given table.
Returns: unmodifiable list of Edge objects, or an empty list if the table is not in the graph
Find the shortest join path connecting all the given tables using BFS. The algorithm picks the root table as the starting point, then greedily adds the shortest BFS path from the current tree to each remaining table (a standard Steiner tree approximation).
Parameters:
tables — Set<String> of tables that must be connected
rootTable — the preferred root table (used as FROM), or null
Returns: an ordered list of Edge objects representing the joins
Throws: SQLException if no path exists between some tables
Write the schema graph to a text file for later loading.
The cache file format is line-based text:
# SchemaGraph cache v1 TABLE employee TABLE department FK employee department_id department department_id FK order_line order_id,product_id order_product order_id,product_id
Read a schema graph from a cache file previously saved with saveToFile().
Comments (lines starting with #) and blank lines are allowed.
SchemaGraph.Edge represents a foreign key relationship (single or
composite).
Return the child table name.
Return the parent/referenced table name.
Return the first (or only) FK column name.
Return the first (or only) referenced column name.
Return all FK column names as List<String>.
Return all referenced column names as List<String>.
Return true if this is a composite (multi-column) foreign key.
Generate the ON condition clause for this edge. For composite keys,
column pairs are separated with AND.
Parameters:
fromAlias — alias for the from table, or null to use the table name
toAlias — alias for the to table, or null to use the table name
Returns: the ON condition string (e.g. employee.dept_id = department.dept_id)
All table and column names are case-insensitive (stored lowercase internally).
Add a column to the SELECT list. Accepts formats such as
"table.column", "table.column AS alias",
"COUNT(table.column)", and "SUM(table.column) AS total".
Returns: this for chaining
Add multiple columns to the SELECT list.
Returns: this for chaining
Enable SELECT DISTINCT.
Returns: this for chaining
The following methods add aggregate function calls to the SELECT list. Each has a variant without an alias and a variant with an alias.
Add COUNT(table.column) or COUNT(table.column) AS alias.
Add SUM(table.column) or SUM(table.column) AS alias.
Add AVG(table.column) or AVG(table.column) AS alias.
Add a WHERE condition. Multiple calls at the same level are combined
with the current group’s operator (AND by default).
Use ? for parameter placeholders.
Example:
.where("employee.active = ?", "Y")
.where("employee.salary > ?", 50000)
By default, multiple where() calls are combined with AND.
Use startOr() and endOr() to group conditions with OR:
String sql = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.startOr()
.where("employee.department_id = ?", 10)
.where("employee.department_id = ?", 20)
.endOr()
.where("employee.active = 'Y'")
.build();
// WHERE (department_id = ? OR department_id = ?)
// AND employee.active = 'Y'
OR groups can be nested with explicit AND groups:
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.startOr()
.where("employee.salary > ?", 100000)
.startAnd()
.where("employee.department_id = ?", 5)
.where("employee.level = ?", "senior")
.endAnd()
.endOr()
.build();
// WHERE (salary > ? OR (department_id = ? AND level = ?))
Conditions can be added conditionally (the most common pattern in real codebases):
QueryBuilder qb = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name");
qb.startOr();
if (dept1 != null) qb.where("employee.department_id = ?", dept1);
if (dept2 != null) qb.where("employee.department_id = ?", dept2);
qb.endOr();
Empty OR/AND groups (no conditions inside) are silently omitted from the SQL.
Begin an OR group — conditions added between startOr() and
endOr() are combined with OR.
End the current OR group and return to the parent group.
Begin an AND group. Useful inside an OR group to explicitly group conditions with AND.
End the current AND group and return to the parent group.
Use whereIn() and whereNotIn() with a nested
QueryBuilder or raw SQL:
QueryBuilder sub = new QueryBuilder(graph)
.select("project_assignment.employee_id")
.where("project_assignment.project_id = ?", projectId);
String sql = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.whereIn("employee.employee_id", sub)
.build();
// WHERE employee.employee_id IN
// (SELECT project_assignment.employee_id ...)
With raw SQL:
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.whereIn("employee.employee_id",
"SELECT person_id FROM timesheet WHERE work_date = ?",
20260101)
.build();
QueryBuilder sub = new QueryBuilder(graph)
.select("1")
.where("project_assignment.employee_id = employee.employee_id")
.where("project_assignment.project_id = ?", 42);
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.whereExists(sub)
.build();
// WHERE EXISTS (SELECT 1 FROM project_assignment WHERE ...)
With raw SQL:
String sql = new QueryBuilder(graph)
.select("department.name")
.whereExists("SELECT 1 FROM employee "
+ "WHERE employee.department_id = department.department_id "
+ "AND employee.active = 'Y'")
.build();
Add column IN (SELECT ...) using a QueryBuilder subquery.
Add column IN (raw SQL) with parameter values.
Add column NOT IN (SELECT ...) using a QueryBuilder subquery.
Add column NOT IN (raw SQL) with parameter values.
Add EXISTS (SELECT ...) using a QueryBuilder subquery.
Add EXISTS (raw SQL) with parameter values.
Add NOT EXISTS (SELECT ...) using a QueryBuilder subquery.
Add NOT EXISTS (raw SQL) with parameter values.
Subquery parameters are merged into the outer query’s parameter list in the correct positional order.
Add an ascending ORDER BY column.
Add a descending ORDER BY column.
Add a GROUP BY column.
Add a HAVING condition. Multiple calls are combined with AND.
Explicit joins give the developer control over exactly which columns are used for a join. They are necessary for self-joins, and useful when multiple foreign key paths exist between two tables and the developer needs to select a specific one.
Single-column INNER JOIN without alias.
Single-column INNER JOIN with alias. The alias is required for self-joins.
Composite (multi-column) INNER JOIN, optionally with alias.
Single-column LEFT JOIN without alias.
Single-column LEFT JOIN with alias.
Composite LEFT JOIN, optionally with alias.
Single-column RIGHT JOIN without alias.
Single-column RIGHT JOIN with alias.
Composite RIGHT JOIN, optionally with alias.
Limit the number of rows returned. Uses Connection.limit() to
produce database-specific SQL.
Use with() to prepend CTE blocks to the query:
String sql = new QueryBuilder(graph)
.with("last_timesheet",
"SELECT person_id, MAX(work_date) work_date "
+ "FROM timesheet WHERE billable = 'Y' "
+ "GROUP BY person_id")
.select("employee.first_name", "employee.last_name")
.select("last_timesheet.work_date")
.leftJoin("employee", "employee_id",
"last_timesheet", "person_id")
.build();
// WITH last_timesheet AS (SELECT ...) SELECT ...
Multiple CTEs are supported:
String sql = new QueryBuilder(graph)
.with("cte1", "SELECT employee_id FROM employee "
+ "WHERE active = 'Y'")
.with("cte2", "SELECT employee_id, MAX(work_date) "
+ "last_date FROM timesheet GROUP BY employee_id")
.select("cte1.employee_id")
.select("cte2.last_date")
.leftJoin("cte1", "employee_id",
"cte2", "employee_id")
.build();
// WITH cte1 AS (...), cte2 AS (...) SELECT ...
A CTE can also use a QueryBuilder as its body:
QueryBuilder cteSub = new QueryBuilder(graph)
.select("employee.employee_id")
.where("employee.active = ?", "Y");
String sql = new QueryBuilder(graph)
.with("active_emps", cteSub)
.select("active_emps.employee_id")
.build();
Add a CTE with a raw SQL body and optional parameters.
Add a CTE whose body is built from a QueryBuilder.
CTE names are treated like aliases — they are excluded from the schema graph path finding. CTE parameters appear before WHERE parameters in the final parameter list.
Combine multiple queries with union() or unionAll():
QueryBuilder q1 = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.select("'Employee' AS person_type")
.where("employee.active = 'Y'");
QueryBuilder q2 = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.select("'Contractor' AS person_type")
.where("employee.active = 'N'");
String sql = q1.unionAll(q2)
.orderBy("last_name")
.build();
// SELECT ... UNION ALL SELECT ... ORDER BY last_name
Append a UNION (removes duplicates) with another QueryBuilder.
Append a UNION ALL (keeps duplicates) with another QueryBuilder.
ORDER BY and LIMIT on a UNION apply to the combined result. Multiple unions can be chained. Parameters from all parts are merged in the correct positional order.
Generate the SQL query string. After calling this method,
getParameters() returns the ordered parameter values.
Returns: the generated SQL string
Throws: SQLException if no join path exists between the referenced tables, or if no SELECT columns were specified
Return the ordered list of ? placeholder values. Must be called
after build().
Returns: unmodifiable List<Object>
Throws: IllegalStateException if called before build()
Build and execute the query, returning all results as Record
objects.
Returns: List<Record>
Throws: Exception on database or query building errors
Build and execute the query, returning the first result or null.
Returns: Record or null
Throws: Exception on database or query building errors
Build and execute the query, returning results as a JSONArray.
Returns: JSONArray
Throws: Exception on database or query building errors
String sql = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name")
.select("department.name")
.select("building.address")
.where("employee.active = ?", "Y")
.orderBy("employee.last_name")
.build();
The builder automatically determines the join path from
employee through department to building
based on the foreign key relationships in the schema graph.
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.select("mgr.first_name AS manager_name")
.join("employee", "manager_id",
"employee", "employee_id", "mgr")
.build();
// ... JOIN employee mgr
// ON employee.manager_id = mgr.employee_id
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.select("mgr.first_name AS manager")
.select("mentor.first_name AS mentor_name")
.join("employee", "manager_id",
"employee", "employee_id", "mgr")
.join("employee", "mentor_id",
"employee", "employee_id", "mentor")
.build();
String sql = new QueryBuilder(graph)
.select("department.name")
.selectCount("employee.employee_id", "emp_count")
.leftJoin("department", "department_id",
"employee", "department_id")
.groupBy("department.name")
.build();
// SELECT department.name,
// COUNT(employee.employee_id) AS emp_count
// FROM department
// LEFT JOIN employee
// ON department.department_id = employee.department_id
// GROUP BY department.name
String sql = new QueryBuilder(graph)
.select("department.name")
.selectCount("employee.employee_id", "emp_count")
.selectAvg("employee.salary", "avg_salary")
.selectSum("employee.salary", "total_salary")
.selectMin("employee.salary", "min_salary")
.selectMax("employee.salary", "max_salary")
.groupBy("department.name")
.having("COUNT(employee.employee_id) > ?", 5)
.build();
String sql = new QueryBuilder(graph)
.select("order_line.qty")
.select("pricing.price")
.join("order_line",
new String[]{"product_id", "region_id"},
"pricing",
new String[]{"product_id", "region_id"}, null)
.build();
// ... JOIN pricing
// ON order_line.product_id = pricing.product_id
// AND order_line.region_id = pricing.region_id
When a table has multiple foreign keys pointing to the same table, use an explicit join to select which one:
// employee has both department_id and home_department_id
String sql = new QueryBuilder(graph)
.select("employee.name")
.select("department.name AS home_dept")
.join("employee", "home_department_id",
"department", "department_id")
.build();
// First run: read from database, save cache
SchemaGraph graph = SchemaGraph.fromDatabase(conn);
graph.saveToFile("schema-cache.txt");
// Subsequent runs: load from cache (sub-millisecond)
SchemaGraph graph = SchemaGraph.loadFromFile("schema-cache.txt");
// Hybrid: load cache, then add extra relationships
SchemaGraph graph = SchemaGraph.loadFromFile("schema-cache.txt");
graph.addForeignKey("audit_log", "user_id",
"employee", "employee_id");
QueryBuilder qb = new QueryBuilder(graph)
.select("employee.first_name", "employee.last_name");
qb.startOr();
if (dept1 != null)
qb.where("employee.department_id = ?", dept1);
if (dept2 != null)
qb.where("employee.department_id = ?", dept2);
qb.endOr();
qb.where("employee.active = 'Y'");
String sql = qb.build();
// WHERE (department_id = ? OR department_id = ?)
// AND employee.active = 'Y'
QueryBuilder sub = new QueryBuilder(graph)
.select("project_assignment.employee_id")
.where("project_assignment.project_id = ?", projId);
String sql = new QueryBuilder(graph)
.select("employee.first_name")
.where("employee.active = 'Y'")
.whereIn("employee.employee_id", sub)
.build();
// WHERE employee.active = 'Y'
// AND employee.employee_id IN
// (SELECT project_assignment.employee_id ...)
String sql = new QueryBuilder(graph)
.with("last_ts",
"SELECT person_id, MAX(work_date) work_date "
+ "FROM timesheet WHERE billable = 'Y' "
+ "GROUP BY person_id")
.select("employee.first_name")
.select("last_ts.work_date")
.leftJoin("employee", "employee_id",
"last_ts", "person_id")
.build();
QueryBuilder q1 = new QueryBuilder(graph)
.select("employee.first_name")
.select("'Active' AS status")
.where("employee.active = 'Y'");
QueryBuilder q2 = new QueryBuilder(graph)
.select("employee.first_name")
.select("'Inactive' AS status")
.where("employee.active = 'N'");
String sql = q1.unionAll(q2)
.orderBy("first_name")
.build();
When build() is called, the query builder:
SQLException naming the
disconnected tables.
BFS is optimal because all foreign key edges have equal weight. It runs in O(V+E) time, which is sub-millisecond even for 300+ tables.
Tables that are the target (toTable) of an explicit join(),
leftJoin(), or rightJoin() are excluded from the automatic
join graph traversal. This means:
INNER JOIN.
join() produces JOIN,
leftJoin() produces LEFT JOIN, and rightJoin() produces RIGHT JOIN.
build() throws SQLException naming the disconnected tables.
Fully supported. Edge holds column lists; the ON condition emits
AND-separated column pairs.
Supported via explicit join() with an alias.
Auto-join picks the first path found. Use an explicit join() to
select a specific path.
Works correctly — explicit join target tables bypass graph path finding.
Supported — table names include the schema prefix throughout.
The developer’s responsibility to ensure correct SQL semantics.
Supported via startOr() / endOr() grouping with nesting.
Supported via whereIn(), whereExists(), etc.
Supported via with(). CTE names are excluded from auto-join path finding.
Supported via union() / unionAll().
Not supported; use raw SQL.
Not supported.
SchemaGraph is safe for concurrent read
operations after construction.
src/main/core/org/kissweb/database/SchemaGraph.javaSchema graph model: table/edge management, BFS path finding, schema caching.
src/main/core/org/kissweb/database/QueryBuilder.javaFluent query builder: SELECT, WHERE, ORDER BY, GROUP BY, HAVING, explicit joins, aggregate helpers, build, and execute methods.
src/test/core/org/kissweb/database/QueryBuilderTest.java108 unit tests covering all functionality.