Skip to content

Non-reflection DataMapper::Update and fluent Query<T>() don't skip HasMany members (compile error / "no such column") #517

Description

@Yaraslaut

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:

  1. DataMapper::Update(record) — fails to compile, and
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions