Summary
In the non-reflection build (i.e. without LIGHTWEIGHT_CXX26_REFLECTION), a record that has a HasMany (or any non-storage) member cannot be used with:
DataMapper::Update(record) — fails to compile, and
- the fluent
DataMapper::Query<T>() — fails at runtime with no such column.
Both non-reflection code paths enumerate every member and treat it as a storage column, whereas their C++26-reflection counterparts guard each member with if constexpr (FieldWithStorage<FieldType>). Create, Delete, and QuerySingle<T>(pk) (+ relation navigation) are guarded and work fine, so the two paths above look like oversights rather than intentional limitations.
Net effect: a HasMany-bearing record can only be created/deleted/QuerySingled, never updated or listed via the fluent builder. That makes HasMany impractical on any table you also update or list, and forces a relation-free "shadow" projection struct over the same table as a workaround.
Environment
- Lightweight
v0.20260625.0 (also reproduced on master @ 27f5614f)
- GCC 16.1.1,
-std=c++23, non-reflection build (no LIGHTWEIGHT_CXX26_REFLECTION)
- SQLite3 via unixODBC
Reproducer
#include <Lightweight/Lightweight.hpp>
#include <cstdint>
#include <iostream>
#include <string_view>
using namespace Lightweight;
struct Child;
struct Parent {
static constexpr std::string_view TableName = "parents";
Field<uint64_t, PrimaryKey::ServerSideAutoIncrement, SqlRealName{"id"}> id; // 0
Field<SqlAnsiString<32>, SqlRealName{"name"}> name; // 1
HasMany<Child> children; // 2
};
struct Child {
static constexpr std::string_view TableName = "children";
Field<uint64_t, PrimaryKey::ServerSideAutoIncrement, SqlRealName{"id"}> id; // 0
Field<int, SqlRealName{"n"}> n; // 1
BelongsTo<&Parent::id, SqlRealName{"parent_id"}> parent; // 2
};
int main() {
SqlConnection::SetDefaultConnectionString(SqlConnectionString{"DRIVER=SQLite3;Database=issue.db"});
DataMapper dm;
dm.CreateTables<Parent, Child>();
Parent p{ .name = SqlAnsiString<32>{"acme"} };
dm.Create(p); // OK — Create is guarded
auto one = dm.QuerySingle<Parent>(p.id.Value()); // OK — QuerySingle is guarded
// (1) fluent Query<T>() — throws at runtime:
auto all = dm.Query<Parent>().All();
// -> HY000 (1) - [SQLite]no such column: parents.children
// (2) DataMapper::Update — fails to compile:
one->name = SqlAnsiString<32>{"beta"};
dm.Update(*one);
// -> error: 'const class Lightweight::HasMany<Child>' has no member named 'IsModified'
}
Actual output / errors
Query<Parent>().All() (runtime):
Create + QuerySingle: OK, children.Count()=0
Query<Parent>().All() THREW: HY000 (1) - [SQLite]no such column: parents.children (1)
Update(*one) (compile):
DataMapper.hpp:1945:29: error: 'const class Lightweight::HasMany<Child>' has no member named 'IsModified'
DataMapper.hpp:1977:29: error: 'const class Lightweight::HasMany<Child>' has no member named 'IsModified'
DataMapper.hpp:1978:54: error: 'const class Lightweight::HasMany<Child>' has no member named 'Value'
Root cause
The reflection and non-reflection branches diverge — the reflection branch guards members with FieldWithStorage, the non-reflection branch does not.
DataMapper::Update (src/Lightweight/DataMapper/DataMapper.hpp) — reflection branch is guarded:
// ~line 1935 (LIGHTWEIGHT_CXX26_REFLECTION)
if constexpr (FieldWithStorage<FieldType>)
{
if (record.[:el:].IsModified())
query.Set(FieldNameOf<el>, SqlWildcard);
...
}
…but the non-reflection branch calls IsModified() / .Value() on every member unconditionally:
// ~line 1944 (SET-clause build)
EnumerateRecordMembers(record, [&query]<size_t I, typename FieldType>(FieldType const& field) {
if (field.IsModified()) // <-- no FieldWithStorage guard
query.Set(FieldNameAt<I, Record>, SqlWildcard);
if constexpr (IsPrimaryKey<RecordMemberTypeOf<I, Record>>)
std::ignore = query.Where(FieldNameAt<I, Record>, SqlWildcard);
});
// ~line 1976 (SET-clause input binding) has the same unguarded field.IsModified()/field.Value()
DataMapper::BuildFullyQualifiedFieldList (~line 584) — used by the fluent Query<T>() (~line 401) — adds every member to the SELECT list with no guard:
static std::string BuildFullyQualifiedFieldList()
{
std::string fields;
EnumerateRecordMembers<Record>([&fields]<size_t I, typename Field>() { // <-- no FieldWithStorage guard
if (!fields.empty()) fields += ", ";
fields += '"'; fields += RecordTableName<Record>;
fields += "\".\""; fields += FieldNameAt<I, Record>; fields += '"';
});
return fields;
}
For comparison, other query paths (e.g. ~line 2095, ~line 2135) and the batch update (IsBatchUpdateSetColumn, ~line 1766) already gate on FieldWithStorage.
Suggested fix
Add the if constexpr (FieldWithStorage<FieldType>) guard to the non-reflection branches so they match the reflection branch (and the other builders):
Update: guard the SET-clause build loop (~1944) and the SET input-binding loop (~1976).
BuildFullyQualifiedFieldList: skip non-storage members (~587).
Happy to open a PR if that would help.
Summary
In the non-reflection build (i.e. without
LIGHTWEIGHT_CXX26_REFLECTION), a record that has aHasMany(or any non-storage) member cannot be used with:DataMapper::Update(record)— fails to compile, andDataMapper::Query<T>()— fails at runtime withno such column.Both non-reflection code paths enumerate every member and treat it as a storage column, whereas their C++26-reflection counterparts guard each member with
if constexpr (FieldWithStorage<FieldType>).Create,Delete, andQuerySingle<T>(pk)(+ relation navigation) are guarded and work fine, so the two paths above look like oversights rather than intentional limitations.Net effect: a
HasMany-bearing record can only be created/deleted/QuerySingled, never updated or listed via the fluent builder. That makesHasManyimpractical on any table you also update or list, and forces a relation-free "shadow" projection struct over the same table as a workaround.Environment
v0.20260625.0(also reproduced onmaster@27f5614f)-std=c++23, non-reflection build (noLIGHTWEIGHT_CXX26_REFLECTION)Reproducer
Actual output / errors
Query<Parent>().All()(runtime):Update(*one)(compile):Root cause
The reflection and non-reflection branches diverge — the reflection branch guards members with
FieldWithStorage, the non-reflection branch does not.DataMapper::Update(src/Lightweight/DataMapper/DataMapper.hpp) — reflection branch is guarded:…but the non-reflection branch calls
IsModified()/.Value()on every member unconditionally:DataMapper::BuildFullyQualifiedFieldList(~line 584) — used by the fluentQuery<T>()(~line 401) — adds every member to the SELECT list with no guard:For comparison, other query paths (e.g.
~line 2095,~line 2135) and the batch update (IsBatchUpdateSetColumn,~line 1766) already gate onFieldWithStorage.Suggested fix
Add the
if constexpr (FieldWithStorage<FieldType>)guard to the non-reflection branches so they match the reflection branch (and the other builders):Update: guard the SET-clause build loop (~1944) and the SET input-binding loop (~1976).BuildFullyQualifiedFieldList: skip non-storage members (~587).Happy to open a PR if that would help.