feat(orm): Add JOIN support to Mapper/BaseBuilder and FK auto-detection in code generator#2491
Conversation
…on in code generator
There was a problem hiding this comment.
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
BaseBuilderand fluent join methods inFilterBuilder. - Added JOIN support to
Mapperby accumulating join fragments into generated SELECT/COUNT SQL. - Extended
drogon_ctl create modelto 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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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"; | ||
| } | ||
| } |
There was a problem hiding this comment.
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).
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| "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 " |
There was a problem hiding this comment.
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.
| "ON rc.unique_constraint_name = ccu.constraint_name " | |
| "ON rc.unique_constraint_name = ccu.constraint_name " | |
| "AND rc.unique_constraint_schema = ccu.constraint_schema " |
drogon_ctl/create_model.cc
Outdated
| relJson["type"] = "has one"; | ||
| relJson["original_table_name"] = tableName; | ||
| relJson["original_key"] = fkColumn; | ||
| relJson["target_table_name"] = referencedTable; | ||
| relJson["target_key"] = referencedColumn; |
There was a problem hiding this comment.
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.
drogon_ctl/create_model.cc
Outdated
| std::string fkSql = "PRAGMA foreign_key_list(" + tableName + ");"; | ||
| *client << fkSql << Mode::Blocking >> [&](const Result &fkResult) { |
There was a problem hiding this comment.
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.
drogon_ctl/create_model.cc
Outdated
| 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; |
There was a problem hiding this comment.
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.
…schema join fix, extract helper, quote PRAGMA
|
Addressed all Copilot review feedback in the latest commit (2d88a91):
|
|
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:
Could you approve the workflow run on the latest commit? (First-time contributor, so CI needs maintainer approval to trigger.) Thanks! |
|
@SBALAVIGNESH123 Thank you very much for the patch. Please take a look at the CoroMapper template to ensure consistency with this PR. |
|
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.
|
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. |
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.