Skip to content

Commit ea5ba21

Browse files
Yaraslautclaude
andcommitted
fix(DataMapper,ddl2cpp): address code-review findings on composite FKs and relation generation
Ten findings from a review of the composite-foreign-key and ddl2cpp relation work. Correctness: - QuerySingle(primaryKeys...) and one FirstImpl() overload each had an early `return std::nullopt;` ahead of their final `return record;`. Verified with standalone repros that this defeats NRVO in both GCC and Clang, moving the record to a new address on return while the composite-FK lazy loader installed on it still held a pointer to the old one - a use-after-free. Restructured both to a single return statement. - ddl2cpp's join-table cardinality check used the *far* key's uniqueness instead of the *owner* key's - the far key always references exactly one row, so that check was trivially always true. Fixed, and corrected the test that had locked in the backwards expectation, plus the doc wording. - The `PrimaryKey::AutoAssign` collision counter only recognized `value + 1`-style keys, so two auto-assigned SqlGuid keys bypassed the static_assert meant to reject multi-column auto-assignment. - CompositeForeignKey had no operator==/<=>, so it was neither equality_comparable nor (owing to its private members) an aggregate - Reflection::CollectDifferences hard-errors on any record holding one. Added comparison operators, matching HasMany/HasOneThrough/HasManyThrough. - GetPrimaryKeyField() kept overwriting its result for every matching-type primary key member, silently returning the last one instead of the first. Cleanup: - Deduplicated the eager (LoadCompositeForeignKey) and lazy (ConfigureRelationAutoLoading) composite-FK load paths into one LoadCompositeForeignKeyRecord() helper. - Merged the near-identical HasOneThrough/HasManyThrough codegen branches in CxxModelPrinter, driven by the template name string. - Replaced two C-style index loops in CompositeForeignKey with views::iota. - Rebuilt GetPrimaryKeyFields() on the same compile-time tuple_cat machinery RecordPrimaryKeyTupleHelper already uses, dropping an O(K^2) runtime scan. - Replaced byName()'s linear table scan in PlanRelations with a hash map. Also fixed, unrelated to the review but required for a local build under this toolchain: two dependent-name lookups needing an explicit `template` keyword, and a spurious unused-lambda-capture warning-as-error. Verified: clang-debug (ASan/UBSan) against SQLite3 - 1341/1342 test cases, 13431/13431 assertions passed (1 pre-existing SQLite-unsupported skip). GCC/MSSQL/PostgreSQL legs not available in this environment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 3f0daa8 commit ea5ba21

8 files changed

Lines changed: 253 additions & 83 deletions

File tree

docs/ddl2cpp-relation-generation.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ considered throughout; composite ones are counted and skipped as before.
113113
2. **`HasManyThrough`** on each of the two referenced tables, when a table qualifies as a join
114114
table: exactly two single-column foreign keys, pointing at two *distinct* tables, and no
115115
non-key columns beyond those two foreign keys and its own key columns.
116-
3. **`HasOneThrough`** in place of `HasManyThrough` when the join table's foreign key to the far
117-
side is covered by a single-column unique index.
116+
3. **`HasOneThrough`** in place of `HasManyThrough`, on a given owner's side, when the join table's
117+
foreign key back to *that owner* is covered by a single-column unique index — meaning that owner
118+
can appear in at most one join row, hence reach at most one record on the other side. The other
119+
side keeps whatever `HasManyThrough`/`HasOneThrough` its own foreign key's uniqueness implies,
120+
independently.
118121
4. **`HasMany`** on the referenced side of every remaining foreign key, i.e. one that is not part
119122
of a join table already covered by rule 2 or 3.
120123
5. **Scalar rather than collection** when the child's foreign key is itself covered by a

src/Lightweight/DataMapper/CompositeForeignKey.hpp

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <cstddef>
1313
#include <functional>
1414
#include <memory>
15+
#include <ranges>
1516
#include <tuple>
1617
#include <type_traits>
1718
#include <utility>
@@ -228,8 +229,8 @@ class CompositeForeignKey
228229
static_assert(
229230
[]() consteval {
230231
auto const indices = std::array { Connections::IntoMemberIndex... };
231-
for (auto outer = std::size_t { 0 }; outer != indices.size(); ++outer)
232-
for (auto inner = outer + 1; inner != indices.size(); ++inner)
232+
for (auto const outer: std::views::iota(std::size_t { 0 }, indices.size()))
233+
for (auto const inner: std::views::iota(outer + 1, indices.size()))
233234
if (indices[outer] == indices[inner])
234235
return false;
235236
return true;
@@ -257,7 +258,7 @@ class CompositeForeignKey
257258
/// because the referenced indices are asserted pairwise distinct above.
258259
template <std::size_t Slot>
259260
static constexpr std::size_t ConnectionForSlot = []() consteval {
260-
for (auto candidate = std::size_t { 0 }; candidate != Count; ++candidate)
261+
for (auto const candidate: std::views::iota(std::size_t { 0 }, Count))
261262
{
262263
auto rank = std::size_t { 0 };
263264
for (auto const other: IntoIndices)
@@ -385,6 +386,20 @@ class CompositeForeignKey
385386
struct Loader
386387
{
387388
std::function<std::shared_ptr<ReferencedRecord>()> loadReference {};
389+
390+
std::weak_ordering operator<=>(Loader const& /*other*/) const noexcept
391+
{
392+
return std::weak_ordering::equivalent; // Loader is not comparable, so we return equivalent
393+
}
394+
395+
// A defaulted `==` on the enclosing class does not derive equality from a member's `<=>` - each
396+
// member needs its own viable `==`, or the default is silently deleted. See HasMany::Loader for
397+
// the same shape (there, without this operator; equal by convention since it holds none of the
398+
// relation's state).
399+
bool operator==(Loader const& /*other*/) const noexcept
400+
{
401+
return true;
402+
}
388403
};
389404

390405
/// Used internally to configure on-demand loading of the referenced record.
@@ -395,6 +410,16 @@ class CompositeForeignKey
395410
_loader = std::move(loader);
396411
}
397412

413+
/// Three-way comparison operator.
414+
///
415+
/// Without this, the relation is neither `std::equality_comparable` nor (owing to its private
416+
/// members) an aggregate, and `Reflection::CollectDifferences` - which falls back to recursing into
417+
/// non-comparable members as aggregates - hard-errors on any record holding one. HasMany,
418+
/// HasOneThrough and HasManyThrough all define one for the same reason.
419+
std::weak_ordering operator<=>(CompositeForeignKey const& other) const noexcept = default;
420+
/// Equality comparison operator.
421+
bool operator==(CompositeForeignKey const& other) const noexcept = default;
422+
398423
private:
399424
void RequireLoaded() const
400425
{

src/Lightweight/DataMapper/DataMapper.hpp

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,17 @@ class DataMapper
622622
template <typename FieldType>
623623
std::optional<typename FieldType::ReferencedRecord> LoadBelongsTo(FieldType::ValueType value);
624624

625+
/// Queries the record referenced by a composite foreign key, without touching the relation itself.
626+
///
627+
/// Shared by the eager path (@ref LoadCompositeForeignKey) and the lazy loader installed by
628+
/// @ref ConfigureRelationAutoLoading, so both resolve a missing target row and wrap a found one the
629+
/// same way instead of maintaining two copies of that logic.
630+
///
631+
/// @param owner The record holding the foreign key.
632+
/// @return The referenced record, or `nullptr` if no matching row exists.
633+
template <typename FieldType, typename Record>
634+
std::shared_ptr<typename FieldType::ReferencedRecord> LoadCompositeForeignKeyRecord(Record const& owner);
635+
625636
/// Eagerly loads the record referenced by a composite foreign key.
626637
///
627638
/// @param record The record holding the foreign key.
@@ -1112,7 +1123,7 @@ size_t SqlCoreDataMapperQueryBuilder<Record, Derived, QueryOptions>::CountImpl()
11121123
this->_query.searchCondition.condition));
11131124
auto reader = stmt.ExecuteWithVariants(_boundInputs);
11141125
if (reader.FetchRow())
1115-
return reader.GetColumn<size_t>(1);
1126+
return reader.template GetColumn<size_t>(1);
11161127
return 0;
11171128
}
11181129

@@ -1217,7 +1228,7 @@ auto SqlCoreDataMapperQueryBuilder<Record, Derived, QueryOptions>::AllImpl() ->
12171228
}
12181229

12191230
if (!outputColumnsBound)
1220-
value = reader.GetColumn<value_type>(1);
1231+
value = reader.template GetColumn<value_type>(1);
12211232
}
12221233

12231234
return result;
@@ -1338,16 +1349,27 @@ auto SqlCoreDataMapperQueryBuilder<Record, Derived, QueryOptions>::FirstImpl() -
13381349
#else
13391350
reader.BindOutputColumns(&(record.*ReferencedFields)...);
13401351
#endif
1341-
if (!reader.FetchRow())
1342-
return std::nullopt;
1343-
if (!outputColumnsBound)
1352+
1353+
// A single return statement at the end is deliberate, not stylistic: a composite foreign key
1354+
// configured below (ConfigureRelationAutoLoading) captures a pointer to *optionalRecord. An earlier
1355+
// `return std::nullopt;` here defeats NRVO in both GCC and Clang (verified: it forces a
1356+
// move-construct into the caller's storage at a new address), which would leave that captured
1357+
// pointer dangling.
1358+
if (reader.FetchRow())
13441359
{
1345-
using ElementMask = std::integer_sequence<size_t, MemberIndexOf<ReferencedFields>...>;
1346-
detail::GetAllColumns<ElementMask>(reader, record);
1347-
}
1360+
if (!outputColumnsBound)
1361+
{
1362+
using ElementMask = std::integer_sequence<size_t, MemberIndexOf<ReferencedFields>...>;
1363+
detail::GetAllColumns<ElementMask>(reader, record);
1364+
}
13481365

1349-
if constexpr (QueryOptions.loadRelations)
1350-
_dm.ConfigureRelationAutoLoading(record);
1366+
if constexpr (QueryOptions.loadRelations)
1367+
_dm.ConfigureRelationAutoLoading(record);
1368+
}
1369+
else
1370+
{
1371+
optionalRecord.reset();
1372+
}
13511373

13521374
return optionalRecord;
13531375
}
@@ -2177,18 +2199,22 @@ std::optional<Record> DataMapper::QuerySingle(PrimaryKeyTypes&&... primaryKeys)
21772199
_stmt.Prepare(queryBuilder->First());
21782200
auto reader = _stmt.Execute(std::forward<PrimaryKeyTypes>(primaryKeys)...);
21792201

2202+
// A single return statement at the end is deliberate, not stylistic: a composite foreign key
2203+
// configured below (ConfigureRelationAutoLoading) captures a pointer to *resultRecord. An earlier
2204+
// `return std::nullopt;` here defeats NRVO in both GCC and Clang (verified: it forces a move-construct
2205+
// into the caller's storage at a new address), which would leave that captured pointer dangling.
21802206
auto resultRecord = std::optional<Record> { Record {} };
2181-
if (!detail::ReadSingleResult(_stmt.Connection().ServerType(), reader, *resultRecord))
2182-
return std::nullopt;
2183-
2184-
if (resultRecord)
2207+
if (detail::ReadSingleResult(_stmt.Connection().ServerType(), reader, *resultRecord))
2208+
{
21852209
SetModifiedState<ModifiedState::NotModified>(resultRecord.value());
21862210

2187-
if constexpr (QueryOptions.loadRelations)
2188-
{
2189-
if (resultRecord)
2211+
if constexpr (QueryOptions.loadRelations)
21902212
ConfigureRelationAutoLoading(*resultRecord);
21912213
}
2214+
else
2215+
{
2216+
resultRecord.reset();
2217+
}
21922218

21932219
return resultRecord;
21942220
}
@@ -2484,6 +2510,22 @@ inline LIGHTWEIGHT_FORCE_INLINE void CallOnBelongsTo(Callable const& callable)
24842510
});
24852511
}
24862512

2513+
template <typename FieldType, typename Record>
2514+
std::shared_ptr<typename FieldType::ReferencedRecord> DataMapper::LoadCompositeForeignKeyRecord(Record const& owner)
2515+
{
2516+
using ReferencedRecord = typename FieldType::ReferencedRecord;
2517+
2518+
// OrderedValuesOf() rather than ValuesOf(): QuerySingle emits one WHERE predicate per primary key
2519+
// member of the referenced record, in that record's member declaration order, and binds its
2520+
// arguments positionally - so the values have to be permuted into that order first. See
2521+
// CompositeKeyOrderingTests.cpp.
2522+
auto loaded = std::apply([this](auto const&... key) { return this->template QuerySingle<ReferencedRecord>(key...); },
2523+
FieldType::OrderedValuesOf(owner));
2524+
if (!loaded)
2525+
return {};
2526+
return std::make_shared<ReferencedRecord>(std::move(*loaded));
2527+
}
2528+
24872529
template <typename Record, typename FieldType>
24882530
void DataMapper::LoadCompositeForeignKey(Record const& record, FieldType& field)
24892531
{
@@ -2492,12 +2534,7 @@ void DataMapper::LoadCompositeForeignKey(Record const& record, FieldType& field)
24922534
ZoneScopedN("DataMapper::LoadCompositeForeignKey");
24932535
ZoneTextObject(RecordTableName<ReferencedRecord>);
24942536

2495-
// OrderedValuesOf() rather than ValuesOf(): QuerySingle emits one WHERE predicate per primary key
2496-
// member of the referenced record, in that record's member declaration order, and binds its
2497-
// arguments positionally - so the values have to be permuted into that order first. See
2498-
// CompositeKeyOrderingTests.cpp.
2499-
auto loaded = std::apply([this](auto const&... key) { return QuerySingle<ReferencedRecord>(key...); },
2500-
FieldType::OrderedValuesOf(record));
2537+
auto loaded = LoadCompositeForeignKeyRecord<FieldType>(record);
25012538

25022539
// A missing target row leaves the relation unloaded rather than throwing here: eagerly loading a
25032540
// dangling foreign key is a data-integrity problem to surface at the accessor, which is where the
@@ -2509,7 +2546,7 @@ void DataMapper::LoadCompositeForeignKey(Record const& record, FieldType& field)
25092546
return;
25102547
}
25112548

2512-
field.EmplaceRecord(std::make_shared<ReferencedRecord>(std::move(*loaded)));
2549+
field.EmplaceRecord(std::move(loaded));
25132550
}
25142551

25152552
template <typename FieldType>
@@ -2949,11 +2986,7 @@ void DataMapper::ConfigureRelationAutoLoading(Record& record)
29492986
field.SetAutoLoader(typename FieldType::Loader {
29502987
.loadReference = [owner = &record]() -> std::shared_ptr<ReferencedRecord> {
29512988
DataMapper& dm = DataMapper::AcquireThreadLocal();
2952-
auto loaded = std::apply([&dm](auto const&... key) { return dm.QuerySingle<ReferencedRecord>(key...); },
2953-
FieldType::OrderedValuesOf(*owner));
2954-
if (!loaded)
2955-
return {};
2956-
return std::make_shared<ReferencedRecord>(std::move(*loaded));
2989+
return dm.LoadCompositeForeignKeyRecord<FieldType>(*owner);
29572990
},
29582991
});
29592992
}

src/Lightweight/DataMapper/Record.hpp

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#pragma once
44

5+
#include "../DataBinder/SqlGuid.hpp"
56
#include "../Utils.hpp"
67
#include "BelongsTo.hpp"
78
#include "Field.hpp"
@@ -402,9 +403,14 @@ namespace detail
402403
template <typename ValueType>
403404
concept IncrementableKeyValue = requires(ValueType value) { value + 1; };
404405

406+
/// Whether @p ValueType is one of the two kinds `GenerateAutoAssignPrimaryKey` actually generates a
407+
/// value for: a GUID (via `SqlGuid::Create()`) or an incrementable value (via `MAX(...) + 1`).
408+
template <typename ValueType>
409+
concept AutoAssignableKeyValue = std::same_as<ValueType, SqlGuid> || IncrementableKeyValue<ValueType>;
410+
405411
template <typename FieldType>
406412
concept GeneratesAutoAssignedKey = IsField<FieldType> && IsAutoAssignPrimaryKeyField<FieldType>::value
407-
&& IncrementableKeyValue<typename FieldType::ValueType>;
413+
&& AutoAssignableKeyValue<typename FieldType::ValueType>;
408414

409415
template <typename Record>
410416
constexpr std::size_t AutoAssignPrimaryKeyFieldCount =
@@ -455,29 +461,24 @@ constexpr bool HasCompositePrimaryKey = RecordPrimaryKeyCount<Record> > 1;
455461
template <typename Record>
456462
[[nodiscard]] RecordPrimaryKeyTuple<Record> GetPrimaryKeyFields(Record const& record)
457463
{
458-
auto result = RecordPrimaryKeyTuple<Record> {};
459-
auto slot = std::size_t { 0 };
460-
EnumerateRecordMembers(record, [&]<size_t I, typename FieldType>(FieldType const& field) {
461-
if constexpr (IsField<FieldType>)
462-
if constexpr (FieldType::IsPrimaryKey)
464+
// Mirrors RecordPrimaryKeyTupleHelper's compile-time tuple_cat construction (one std::tuple<> or
465+
// std::tuple<ValueType> per member, concatenated), but reads each primary-key member's value instead
466+
// of just its type. The two therefore cannot disagree on which members are collected or in what
467+
// order, and no runtime index-matching against the heterogeneous tuple is needed.
468+
return []<std::size_t... I>(Record const& record, std::index_sequence<I...>) {
469+
return std::tuple_cat([&record]<std::size_t J>() {
470+
using FieldType = RecordMemberTypeOf<J, Record>;
471+
if constexpr (IsField<FieldType>)
463472
{
464-
// The tuple was built by walking the members in this same order, so the Nth key member
465-
// corresponds to tuple element N. The tuple is heterogeneous, so the write is matched
466-
// against the compile-time index and only the assignable slot is emitted.
467-
[&]<std::size_t... J>(std::index_sequence<J...>) {
468-
(
469-
[&] {
470-
if constexpr (std::assignable_from<std::tuple_element_t<J, RecordPrimaryKeyTuple<Record>>&,
471-
typename FieldType::ValueType const&>)
472-
if (J == slot)
473-
std::get<J>(result) = field.Value();
474-
}(),
475-
...);
476-
}(std::make_index_sequence<RecordPrimaryKeyCount<Record>> {});
477-
++slot;
473+
if constexpr (FieldType::IsPrimaryKey)
474+
return std::tuple<typename FieldType::ValueType> { GetRecordMemberAt<J>(record).Value() };
475+
else
476+
return std::tuple<> {};
478477
}
479-
});
480-
return result;
478+
else
479+
return std::tuple<> {};
480+
}.template operator()<I>()...);
481+
}(record, std::make_index_sequence<RecordMemberCount<Record>> {});
481482
}
482483

483484
/// Returns the first primary key field of the record.
@@ -490,13 +491,18 @@ inline LIGHTWEIGHT_FORCE_INLINE RecordPrimaryKeyType<Record> GetPrimaryKeyField(
490491
static_assert(HasPrimaryKey<Record>, "Record must have a primary key");
491492

492493
auto result = RecordPrimaryKeyType<Record> {};
494+
bool found = false;
493495
EnumerateRecordMembers(record, [&]<size_t I, typename FieldType>(FieldType const& field) {
494496
// std::same_as<typename FieldType::ValueType, RecordPrimaryKeyType<Record>>condition is for the case where there are
495497
// multiple primary keys, we want to return the first one
496498
if constexpr (IsField<FieldType>)
497499
if constexpr (IsPrimaryKey<FieldType>)
498500
if constexpr (std::same_as<typename FieldType::ValueType, RecordPrimaryKeyType<Record>>)
499-
result = field.Value();
501+
if (!found)
502+
{
503+
result = field.Value();
504+
found = true;
505+
}
500506
});
501507
return result;
502508
}

0 commit comments

Comments
 (0)