Skip to content

feat(orm): Add JOIN support to Mapper/BaseBuilder and FK auto-detection in code generator#2491

Open
SBALAVIGNESH123 wants to merge 6 commits intodrogonframework:masterfrom
SBALAVIGNESH123:feature/orm-relational-access
Open

feat(orm): Add JOIN support to Mapper/BaseBuilder and FK auto-detection in code generator#2491
SBALAVIGNESH123 wants to merge 6 commits intodrogonframework:masterfrom
SBALAVIGNESH123:feature/orm-relational-access

Conversation

@SBALAVIGNESH123
Copy link
Copy Markdown

This addresses #241.

After studying the codebase, I noticed that Drogon already has a solid relationship system — the Relationship class, PivotTable, CSP template code generation, and the lazy loading getRelated() methods all work well through the JSON config. What's missing are three things: JOIN clause support in the query builder, join methods on the Mapper, and automatic foreign key detection in the code generator so users don't have to manually write relationship JSON for every FK constraint.This PR adds all three. The BaseBuilder now has a JoinClause struct and emits JOIN clauses in gen_sql() between FROM and WHERE. FilterBuilder and Mapper both get fluent innerJoin(), leftJoin(), rightJoin() methods that chain naturally with the existing API. On the code generator side, I added FK detection queries for all three backends — PostgreSQL via information_schema, MySQL via KEY_COLUMN_USAGE, and SQLite3 via PRAGMA foreign_key_list. The detected FKs get converted into Relationship objects and merged with any user-specified config, where user config always takes priority. The detection is best-effort so it won't break anything if a database doesn't support FK introspection.
Everything is additive and fully backward compatible — no existing APIs were changed or removed.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds SQL JOIN support to Drogon ORM query building and enhances model generation by auto-detecting foreign-key relationships across PostgreSQL/MySQL/SQLite.

Changes:

  • Added JOIN clause generation in BaseBuilder and fluent join methods in FilterBuilder.
  • Added JOIN support to Mapper by accumulating join fragments into generated SELECT/COUNT SQL.
  • Extended drogon_ctl create model to auto-detect foreign keys and merge them with user-configured relationships.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
orm_lib/inc/drogon/orm/Mapper.h Adds fluent join methods and appends JOIN fragments to Mapper-generated SQL.
orm_lib/inc/drogon/orm/FilterBuilder.h Adds fluent join methods backed by a structured join representation.
orm_lib/inc/drogon/orm/BaseBuilder.h Introduces JoinClause/JoinType and emits JOIN clauses in generated SQL.
drogon_ctl/create_model.cc Adds FK introspection per DB backend and merges detected relationships with user config.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +189 to +200
Mapper<T> &innerJoin(const std::string &table,
const std::string &onLeft,
const std::string &onRight)
{
joinString_ += " INNER JOIN ";
joinString_ += table;
joinString_ += " ON ";
joinString_ += onLeft;
joinString_ += " = ";
joinString_ += onRight;
return *this;
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These JOIN helpers concatenate raw table / onLeft / onRight directly into SQL, which allows SQL injection if any part is derived from user input (and also makes identifier quoting inconsistent across DBs). Consider switching Mapper JOINs to a structured representation (similar to BaseBuilder::JoinClause) and either (1) validate identifiers against a strict allowed-character set (e.g., [A-Za-z0-9_\\.]+), or (2) route identifiers through the library's identifier-quoting/escaping facility if one exists. At minimum, documenting that these arguments must be trusted SQL identifiers would reduce foot-guns.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +96
inline std::string to_join_string(JoinType type)
{
switch (type)
{
case JoinType::InnerJoin:
return "INNER JOIN";
case JoinType::LeftJoin:
return "LEFT JOIN";
case JoinType::RightJoin:
return "RIGHT JOIN";
case JoinType::FullJoin:
return "FULL JOIN";
default:
return "INNER JOIN";
}
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default branch silently maps unknown values to INNER JOIN, which can hide bugs (e.g., if JoinType is extended or memory is corrupted). Prefer eliminating the default and letting compilers warn on non-exhaustive switches, or use an explicit failure path (e.g., unimplemented() / assertion) consistent with to_string(CompareOperator).

Copilot uses AI. Check for mistakes.
Comment on lines 161 to +166
std::string sql = "select " + columns_ + " from " + from_;
for (const auto &join : joins_)
{
sql += " " + to_join_string(join.type) + " " + join.table +
" ON " + join.onLeft + " = " + join.onRight;
}
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JOIN SQL is assembled via raw string concatenation of join.table, join.onLeft, and join.onRight. If any of these can originate from user-controlled inputs (directly or indirectly), this becomes an injection vector. Consider enforcing identifier validation/quoting at the point joins are added (e.g., in FilterBuilder::*Join()), so BaseBuilder can safely assume the join parts are sanitized.

Copilot uses AI. Check for mistakes.
"ON kcu.constraint_name = rc.constraint_name "
"AND kcu.constraint_schema = rc.constraint_schema "
"JOIN information_schema.constraint_column_usage ccu "
"ON rc.unique_constraint_name = ccu.constraint_name "
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostgreSQL FK introspection join is incomplete: rc.unique_constraint_name = ccu.constraint_name should also be joined on the constraint schema (e.g., rc.unique_constraint_schema = ccu.constraint_schema). Without it, the query can mis-resolve referenced tables/columns when constraint names collide across schemas or return incorrect/multiple matches.

Suggested change
"ON rc.unique_constraint_name = ccu.constraint_name "
"ON rc.unique_constraint_name = ccu.constraint_name "
"AND rc.unique_constraint_schema = ccu.constraint_schema "

Copilot uses AI. Check for mistakes.
Comment on lines +435 to +439
relJson["type"] = "has one";
relJson["original_table_name"] = tableName;
relJson["original_key"] = fkColumn;
relJson["target_table_name"] = referencedTable;
relJson["target_key"] = referencedColumn;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relationship JSON normalization is inconsistent across backends: MySQL/SQLite lower-case original_table_name and target_table_name, but PostgreSQL uses raw tableName/referencedTable. This can produce backend-dependent relationship configs and mismatches when the rest of the generator assumes normalized naming. Consider applying the same normalization strategy across all three branches (either always toLower() or never), ideally matching existing generator conventions.

Copilot uses AI. Check for mistakes.
Comment on lines +905 to +906
std::string fkSql = "PRAGMA foreign_key_list(" + tableName + ");";
*client << fkSql << Mode::Blocking >> [&](const Result &fkResult) {
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQLite FK detection builds PRAGMA foreign_key_list(<table>) by concatenating tableName directly. Since tableName comes from CLI input, this is susceptible to SQL injection via crafted table names. Prefer quoting/escaping the identifier (e.g., wrapping in double-quotes and escaping embedded quotes) or validating tableName against a strict identifier pattern before using it in SQL.

Copilot uses AI. Check for mistakes.
Comment on lines +678 to +696
bool exists = false;
for (const auto &r : allRelationships)
{
if (r.originalKey() == fkColumn &&
r.targetTableName() == referencedTable)
{
exists = true;
break;
}
}
if (!exists)
{
Json::Value relJson;
relJson["type"] = "has one";
relJson["original_table_name"] = toLower(tableName);
relJson["original_key"] = fkColumn;
relJson["target_table_name"] = toLower(referencedTable);
relJson["target_key"] = referencedColumn;
relJson["enable_reverse"] = true;
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FK-to-Relationship conversion logic (dedupe scan, relJson construction, try/catch + push + logging) is duplicated across PG/MySQL/SQLite with small variations. Consider extracting a small helper (e.g., tryAddAutoRelationship(allRelationships, originalTable, fkColumn, referencedTable, referencedColumn, normalizeNames)) to reduce repetition and keep future behavior changes consistent across backends.

Copilot uses AI. Check for mistakes.
…schema join fix, extract helper, quote PRAGMA
@SBALAVIGNESH123
Copy link
Copy Markdown
Author

Addressed all Copilot review feedback in the latest commit (2d88a91):

  • Added isValidSqlIdentifier() to validate JOIN parameters against SQL injection
  • Removed silent default branch in o_join_string() switch
  • Fixed PostgreSQL FK query: added missing AND rc.unique_constraint_schema = ccu.constraint_schema join condition
  • Normalized table names consistently across all three backends
  • Quoted tableName in SQLite3 PRAGMA foreign_key_list() to prevent injection
  • Extracted ryAddAutoRelationship() helper to eliminate code duplication across PG/MySQL/SQLite3

@SBALAVIGNESH123
Copy link
Copy Markdown
Author

Ran the format check locally using Docker with the exact CI setup (clang-format-17 + dos2unix on Ubuntu 24.04) and fixed all formatting differences. The latest commit (b2c6157) should pass the format check cleanly now.

Changes across commits:

  • b7ef924: Initial implementation (JOIN support + FK auto-detection)
  • 2d88a91: Addressed all Copilot review feedback (SQL injection prevention, PG schema fix, extracted helper, quoted PRAGMA)
  • d005037 + b2c6157: Applied exact clang-format-17 formatting via Docker

Could you approve the workflow run on the latest commit? (First-time contributor, so CI needs maintainer approval to trigger.) Thanks!

@an-tao
Copy link
Copy Markdown
Member

an-tao commented Apr 12, 2026

@SBALAVIGNESH123 Thank you very much for the patch. Please take a look at the CoroMapper template to ensure consistency with this PR.

@SBALAVIGNESH123
Copy link
Copy Markdown
Author

SBALAVIGNESH123 commented Apr 12, 2026

Thanks for the review @an-tao! Good catch on CoroMapper — I should have included that from the start. The latest commit adds innerJoin(), leftJoin(), and rightJoin() overrides to CoroMapper that delegate to the Mapper base class and return CoroMapper& for proper chaining. I also injected joinString_ into the three CoroMapper query methods (findOne, findBy, and count) so JOINs work the same way whether you're using the sync, async, or coroutine API. The insert, update, and delete paths are untouched since JOINs don't apply there. Everything was formatted with clang-format-17 locally via Docker to match your CI setup. Let me know if anything else needs adjusting.

Mapper.h uses isValidSqlIdentifier() from BaseBuilder.h but didn't include it. This builds fine on older compilers through transitive includes, but GCC 15 with -Wtemplate-body correctly rejects the undeclared name during two-phase template lookup.
@SBALAVIGNESH123
Copy link
Copy Markdown
Author

Added one more fix in 4fcd7ec — Mapper.h was using isValidSqlIdentifier() from BaseBuilder.h but never included it. This compiles fine on most compilers through transitive includes, but GCC 15 with its new -Wtemplate-body diagnostic correctly rejects the undeclared name during two-phase template lookup. Added the missing #include <drogon/orm/BaseBuilder.h> in Mapper.h to fix it. Build-tested locally with GCC 15.2.0 (MinGW-w64) — everything compiles clean now including libdrogon.a and drogon_ctl.exe.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants