Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/ddl2cpp-relation-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ Each should yield a `HasManyThrough` on both referenced tables. Because both for
join record point at *different* tables here, the selectors are only needed when a join table
points twice at the same target.

> **Not yet generated for the composite-key shape.** Of the 159 join-table candidates, the 84 whose
> primary key *is* their two foreign keys are skipped by rule 6 below: those columns are emitted as
> plain `Field`s rather than `BelongsTo`, and a through-relation has no other way to resolve its two
> ends, so generating one yields a record that does not compile. The remaining 75 — join tables with
> a key of their own — are generated as described here. Lifting this needs `BelongsTo` to support
> being a primary key.

### 3. `HasOneThrough` / one-to-one

24 single-column foreign keys sit under a single-column unique index, which makes the relation
Expand Down Expand Up @@ -122,6 +129,22 @@ considered throughout; composite ones are counted and skipped as before.
of a join table already covered by rule 2 or 3.
5. **Scalar rather than collection** when the child's foreign key is itself covered by a
single-column unique index: the relation is one-to-one.
6. **No relation at all** whenever the child's foreign key column does *not* become a `BelongsTo`
(see rule 1). Every inverse relation — `HasMany`, `HasOne` and both through-relations — resolves
its other end through exactly that `BelongsTo`, so generating one anyway produces a record that
does not compile. The column stays a plain `Field`, and hence no relation is emitted, when:

- it is also part of the child's own primary key. This rules out the classic composite-key join
table, whose primary key *is* its two foreign keys; a join table carrying a key of its own is
unaffected and still yields rule 2 or 3. Supporting that shape needs `BelongsTo` to be usable
as a primary key, which it is not today (`BelongsTo::IsPrimaryKey` is hard-coded `false`).
- the foreign key is composite (already true before, and unchanged).
- it is a *self*-reference that cannot name its target: a pointer-to-member may only name a
member the compiler has already seen, so a self-referencing foreign key declared before the
primary key it points at — or one pointing at a merely `UNIQUE` column rather than the primary
key — falls back to a plain `Field` too.

In all of these the generator emits nothing rather than something that cannot be built.

A selector (`SqlRealName { "<column>" }`) is emitted whenever the referenced table is reachable
from the same child table through more than one foreign key, which is what makes the ambiguous
Expand Down
7 changes: 7 additions & 0 deletions src/Lightweight/Description.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ struct RecordMemberList
/// - `static constexpr std::array<std::string_view, FieldCount> FieldNames;` — the resolved SQL
/// column name for each field (i.e. the value `FieldNameAt` would otherwise compute).
///
/// `Members` must list *every* non-static data member in declaration order, relation members
/// (`HasMany`, `HasManyThrough`, `HasOneThrough`) included — it stands in for reflection, so omitting
/// one hides it from `EnumerateRecordMembers` and relation auto-loading silently never runs.
/// Consumers that want only the columns filter by the `RecordColumnMember` concept (see
/// `RecordColumnCount`), never by index. A relation has no SQL column, so its `FieldNames` slot
/// carries its C++ member name, which is what reflection reports there.
///
/// @ingroup DataMapper
template <typename Record>
struct Description;
Expand Down
67 changes: 64 additions & 3 deletions src/Lightweight/Tools/CxxModelPrinter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,46 @@ namespace
});
}

/// Whether the child side of @p constraint is emitted as a `BelongsTo` member rather than a plain
/// `Field`. Mirrors - and must stay in sync with - the guard at the column emission site in
/// `PrintTable`.
///
/// Every relation this planner can emit (`HasMany`, `HasOne`, `HasOneThrough`, `HasManyThrough`)
/// resolves its other end through exactly that `BelongsTo`, so planning one for a foreign key that
/// does not become one produces a record that does not compile: `InverseBelongsToResolver`
/// static_asserts on the missing member as soon as `ConfigureRelationAutoLoading` reaches the
/// relation - which, since the generated `Description<>` lists relation members, it now does (#556).
///
/// @param table The table declaring @p constraint.
/// @param constraint The candidate foreign key.
/// @return `true` when the foreign key column becomes a `BelongsTo` member.
bool IsEmittedAsBelongsTo(SqlSchema::Table const& table, SqlSchema::ForeignKeyConstraint const& constraint)
{
// Composite: emitted as plain fields (with a warning), so there is no BelongsTo either.
if (!IsSingleColumn(constraint))
return false;

// A column that is both a primary key and a foreign key stays a plain Field - `BelongsTo` cannot
// be a primary key today (`BelongsTo::IsPrimaryKey` is hard-coded `false`).
auto const& childColumn = constraint.foreignKey.columns.front();
if (std::ranges::contains(table.primaryKeys, childColumn))
return false;

if (constraint.primaryKey.table != constraint.foreignKey.table)
return true;

// A self-reference names a member of the very struct being defined, so it is declarable only
// when it points at a primary key column emitted *before* it. Otherwise the column falls back
// to a plain field as well.
auto const referenced =
std::ranges::find(table.columns, constraint.primaryKey.columns.front(), &SqlSchema::Column::name);
if (referenced == table.columns.end() || !referenced->isPrimaryKey)
return false;

auto const child = std::ranges::find(table.columns, childColumn, &SqlSchema::Column::name);
return child != table.columns.end() && referenced < child;
}

/// Whether @p table is a pure join table: exactly two single-column foreign keys pointing at two
/// distinct tables, and no columns of its own beyond those keys.
///
Expand Down Expand Up @@ -453,6 +493,14 @@ namespace
if (!std::ranges::all_of(table.columns, [&](auto const& c) { return isKeyColumn(c.name); }))
return std::nullopt;

// A through-relation resolves its join record's owner and far sides through exactly the two
// BelongsTo members, so a join table whose foreign keys do not become BelongsTo cannot satisfy
// the relation it would otherwise imply - the generated code would not compile. The classic
// composite-key join table (its primary key *is* its two foreign keys) is the common case here.
// Skip it for the same reason EmitInverseRelation does: no BelongsTo, no relation (#556).
if (!IsEmittedAsBelongsTo(table, singleColumnKeys[0]) || !IsEmittedAsBelongsTo(table, singleColumnKeys[1]))
return std::nullopt;

return std::pair { singleColumnKeys[0], singleColumnKeys[1] };
}

Expand Down Expand Up @@ -497,7 +545,7 @@ namespace
}

/// Emits the inverse `HasOne`/`HasMany` relation implied by one single-column foreign key, unless it
/// is composite or points outside the generated set.
/// does not become a `BelongsTo` (see @ref IsEmittedAsBelongsTo) or points outside the generated set.
///
/// @param table The table declaring @p constraint.
/// @param constraint The candidate foreign key.
Expand All @@ -511,8 +559,12 @@ namespace
IsAmbiguousFn const& isAmbiguous,
CxxModelPrinter::RelationPlan& plan)
{
if (!IsSingleColumn(constraint))
return; // composite: no BelongsTo either, so no inverse
// HasMany/HasOne resolves its inverse through the child's BelongsTo member. Where the column
// emission site declines to emit one - a composite key, a column that is also a primary key, a
// self-reference that cannot name its target - the relation cannot be satisfied and the
// generated record fails to compile (#556).
if (!IsEmittedAsBelongsTo(table, constraint))
return;

auto const& ownerTable = constraint.primaryKey.table.table;
if (byName(ownerTable) == nullptr)
Expand Down Expand Up @@ -1083,6 +1135,15 @@ void CxxModelPrinter::PrintTable(SqlSchema::Table const& table, std::vector<Plan
auto const memberName = uniqueMemberNameBuilder.DeclareName(
SanitizeName(FormatName(StripSuffix(relation.memberName), _config.formatType)));

// A relation is not a column, but the descriptor is the record's *member* list, not its column
// list: RecordMemberCount reads Description<>::FieldCount whenever a specialization exists, so a
// relation left out here is invisible to EnumerateRecordMembers - ConfigureRelationAutoLoading
// then installs no loader and the first access throws SqlRequireLoadedError (#556). Column-only
// consumers select members by the RecordColumnMember concept rather than by index, so the extra
// entry costs them nothing. Reflection reports a relation's C++ member name where a column would
// report its SQL name, so mirror that to keep both enumeration paths interchangeable.
definition.members.emplace_back(memberName, memberName);

auto const ownerSelector = relation.ownerSelectorRequired
? std::format(", Light::SqlRealName {{ \"{}\" }}", relation.ownerForeignKeyColumn)
: std::string {};
Expand Down
6 changes: 3 additions & 3 deletions src/examples/test_chinook/entities/Album.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct Album final
template <>
struct Lightweight::Description<Album>
{
static constexpr std::size_t FieldCount = 3;
using Members = Lightweight::RecordMemberList<&Album::AlbumId, &Album::Title, &Album::ArtistId>;
static constexpr std::array<std::string_view, 3> FieldNames = { "AlbumId", "Title", "ArtistId" };
static constexpr std::size_t FieldCount = 4;
using Members = Lightweight::RecordMemberList<&Album::AlbumId, &Album::Title, &Album::ArtistId, &Album::Track_1>;
static constexpr std::array<std::string_view, 4> FieldNames = { "AlbumId", "Title", "ArtistId", "Track_1" };
};
6 changes: 3 additions & 3 deletions src/examples/test_chinook/entities/Artist.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct Artist final
template <>
struct Lightweight::Description<Artist>
{
static constexpr std::size_t FieldCount = 2;
using Members = Lightweight::RecordMemberList<&Artist::ArtistId, &Artist::Name>;
static constexpr std::array<std::string_view, 2> FieldNames = { "ArtistId", "Name" };
static constexpr std::size_t FieldCount = 3;
using Members = Lightweight::RecordMemberList<&Artist::ArtistId, &Artist::Name, &Artist::Album_1>;
static constexpr std::array<std::string_view, 3> FieldNames = { "ArtistId", "Name", "Album_1" };
};
13 changes: 7 additions & 6 deletions src/examples/test_chinook/entities/Customer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct Customer final
template <>
struct Lightweight::Description<Customer>
{
static constexpr std::size_t FieldCount = 13;
static constexpr std::size_t FieldCount = 14;
using Members = Lightweight::RecordMemberList<&Customer::CustomerId,
&Customer::FirstName,
&Customer::LastName,
Expand All @@ -49,9 +49,10 @@ struct Lightweight::Description<Customer>
&Customer::Phone,
&Customer::Fax,
&Customer::Email,
&Customer::SupportRepId>;
static constexpr std::array<std::string_view, 13> FieldNames = { "CustomerId", "FirstName", "LastName", "Company",
"Address", "City", "State", "Country",
"PostalCode", "Phone", "Fax", "Email",
"SupportRepId" };
&Customer::SupportRepId,
&Customer::Invoice_1>;
static constexpr std::array<std::string_view, 14> FieldNames = { "CustomerId", "FirstName", "LastName", "Company",
"Address", "City", "State", "Country",
"PostalCode", "Phone", "Fax", "Email",
"SupportRepId", "Invoice_1" };
};
14 changes: 8 additions & 6 deletions src/examples/test_chinook/entities/Employee.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct Employee final
template <>
struct Lightweight::Description<Employee>
{
static constexpr std::size_t FieldCount = 15;
static constexpr std::size_t FieldCount = 17;
using Members = Lightweight::RecordMemberList<&Employee::EmployeeId,
&Employee::LastName,
&Employee::FirstName,
Expand All @@ -52,9 +52,11 @@ struct Lightweight::Description<Employee>
&Employee::PostalCode,
&Employee::Phone,
&Employee::Fax,
&Employee::Email>;
static constexpr std::array<std::string_view, 15> FieldNames = { "EmployeeId", "LastName", "FirstName", "Title",
"ReportsTo", "BirthDate", "HireDate", "Address",
"City", "State", "Country", "PostalCode",
"Phone", "Fax", "Email" };
&Employee::Email,
&Employee::Customer_1,
&Employee::Employee>;
static constexpr std::array<std::string_view, 17> FieldNames = {
"EmployeeId", "LastName", "FirstName", "Title", "ReportsTo", "BirthDate", "HireDate", "Address", "City",
"State", "Country", "PostalCode", "Phone", "Fax", "Email", "Customer_1", "Employee"
};
};
6 changes: 3 additions & 3 deletions src/examples/test_chinook/entities/Genre.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct Genre final
template <>
struct Lightweight::Description<Genre>
{
static constexpr std::size_t FieldCount = 2;
using Members = Lightweight::RecordMemberList<&Genre::GenreId, &Genre::Name>;
static constexpr std::array<std::string_view, 2> FieldNames = { "GenreId", "Name" };
static constexpr std::size_t FieldCount = 3;
using Members = Lightweight::RecordMemberList<&Genre::GenreId, &Genre::Name, &Genre::Track_1>;
static constexpr std::array<std::string_view, 3> FieldNames = { "GenreId", "Name", "Track_1" };
};
12 changes: 7 additions & 5 deletions src/examples/test_chinook/entities/Invoice.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ struct Invoice final
template <>
struct Lightweight::Description<Invoice>
{
static constexpr std::size_t FieldCount = 9;
static constexpr std::size_t FieldCount = 10;
using Members = Lightweight::RecordMemberList<&Invoice::InvoiceId,
&Invoice::CustomerId,
&Invoice::InvoiceDate,
Expand All @@ -42,8 +42,10 @@ struct Lightweight::Description<Invoice>
&Invoice::BillingState,
&Invoice::BillingCountry,
&Invoice::BillingPostalCode,
&Invoice::Total>;
static constexpr std::array<std::string_view, 9> FieldNames = { "InvoiceId", "CustomerId", "InvoiceDate",
"BillingAddress", "BillingCity", "BillingState",
"BillingCountry", "BillingPostalCode", "Total" };
&Invoice::Total,
&Invoice::InvoiceLine>;
static constexpr std::array<std::string_view, 10> FieldNames = { "InvoiceId", "CustomerId", "InvoiceDate",
"BillingAddress", "BillingCity", "BillingState",
"BillingCountry", "BillingPostalCode", "Total",
"InvoiceLine" };
};
6 changes: 3 additions & 3 deletions src/examples/test_chinook/entities/Mediatype.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct Mediatype final
template <>
struct Lightweight::Description<Mediatype>
{
static constexpr std::size_t FieldCount = 2;
using Members = Lightweight::RecordMemberList<&Mediatype::MediaTypeId, &Mediatype::Name>;
static constexpr std::array<std::string_view, 2> FieldNames = { "MediaTypeId", "Name" };
static constexpr std::size_t FieldCount = 3;
using Members = Lightweight::RecordMemberList<&Mediatype::MediaTypeId, &Mediatype::Name, &Mediatype::Track_1>;
static constexpr std::array<std::string_view, 3> FieldNames = { "MediaTypeId", "Name", "Track_1" };
};
5 changes: 0 additions & 5 deletions src/examples/test_chinook/entities/Playlist.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,12 @@
#include <array>
#include <string_view>

struct Playlisttrack;
struct Track;

struct Playlist final
{
static constexpr std::string_view TableName = "Playlist";

Light::Field<int32_t, Light::PrimaryKey::ServerSideAutoIncrement, Light::SqlRealName { "PlaylistId" }> PlaylistId;
Light::Field<std::optional<Light::SqlDynamicUtf16String<120>>, Light::SqlRealName { "Name" }> Name;

Light::HasManyThrough<Track, Light::Through<Playlisttrack>> Track_1;
};

template <>
Expand Down
15 changes: 7 additions & 8 deletions src/examples/test_chinook/entities/Track.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@
#include <string_view>

struct Invoiceline;
struct Playlist;
struct Playlisttrack;

struct Track final
{
Expand All @@ -30,14 +28,13 @@ struct Track final
Light::Field<std::optional<int32_t>, Light::SqlRealName { "Bytes" }> Bytes;
Light::Field<Light::SqlNumeric<10, 2>, Light::SqlRealName { "UnitPrice" }> UnitPrice;

Light::HasManyThrough<Playlist, Light::Through<Playlisttrack>> Playlist_1;
Light::HasMany<Invoiceline> InvoiceLine;
};

template <>
struct Lightweight::Description<Track>
{
static constexpr std::size_t FieldCount = 9;
static constexpr std::size_t FieldCount = 10;
using Members = Lightweight::RecordMemberList<&Track::TrackId,
&Track::Name,
&Track::AlbumId,
Expand All @@ -46,8 +43,10 @@ struct Lightweight::Description<Track>
&Track::Composer,
&Track::Milliseconds,
&Track::Bytes,
&Track::UnitPrice>;
static constexpr std::array<std::string_view, 9> FieldNames = { "TrackId", "Name", "AlbumId",
"MediaTypeId", "GenreId", "Composer",
"Milliseconds", "Bytes", "UnitPrice" };
&Track::UnitPrice,
&Track::InvoiceLine>;
static constexpr std::array<std::string_view, 10> FieldNames = { "TrackId", "Name", "AlbumId",
"MediaTypeId", "GenreId", "Composer",
"Milliseconds", "Bytes", "UnitPrice",
"InvoiceLine" };
};
Loading
Loading