Skip to content

[ORM, Type] Overriding a base class property with Reference or BackReference does not register correctly as a reference #704

Description

@CrappyAlgorithm

Overriding a base class property with Reference/BackReference does not register correctly as a reference

Note

This issue was created with the help of Opus 5. I reviewed the Reproduction and Workaround
sections before posting this issue. I did not look into the detail for the other sections
ORM consequence, Cause and Suggested fix, so take them with a grain of salt, but they may still
be useful to you.

Environment

  • @deepkit/type 1.0.19, @deepkit/type-compiler 1.0.19, @deepkit/orm 1.0.19, @deepkit/mysql 1.0.19
  • Node.js 22.17.1, Typescript 5.5.3

Summary

When an entity class overrides a property that its base class already declares, and the
override adds Reference or BackReference, the property keeps the correct type
(isReference() / isBackReference() return true, joins type-check and produce correct
SQL) but it is never added to the reference index of the ReflectionClass. As a result
ReflectionClass.getReferences() does not list it.

For the ORM this means the relation is silently not populated: joinWith() builds the
correct LEFT JOIN, the database returns the child rows, and the formatter throws them
away. No error is raised, the relation is simply undefined.

Reproduction

import { AutoIncrement, BackReference, PrimaryKey, Reference, ReflectionClass, entity } from '@deepkit/type';

class BookInput {
    // the message/DTO shape, without database concerns
    pages?: PageInput[];
}

class PageInput {
    constructor(public content: string) {}
}

@entity.name('books')
class Book extends BookInput {
    id: number & PrimaryKey & AutoIncrement = 0;

    declare pages?: Page[] & BackReference;
}

@entity.name('pages')
class Page extends PageInput {
    id: number & PrimaryKey & AutoIncrement = 0;
    book!: Book & Reference;
}

const bookSchema = ReflectionClass.from(Book);

console.log(bookSchema.getProperty('pages').isBackReference()); // true  (override applied)
console.log(bookSchema.getProperty('pages').type);              // Array<Page>  (override applied)
console.log(bookSchema.getReferences().map(p => p.name));       // []    <-- expected ['pages']

The same happens for Reference (many-to-one) overrides. The trigger is that the base class
already declares a property of that name.

ORM consequence

With Book/Page rows present in the database:

const book = await database.query(Book).joinWith('pages').filter({ id: 1 }).findOne();
console.log(book.pages); // undefined  <-- expected the joined Page entities

The generated SQL is correct and returns the child row:

SELECT `books`.`id` AS `0`, `__pages`.`content` AS `1`, `__pages`.`id` AS `2`, `__pages`.`book` AS `3`
FROM (SELECT * FROM `books` WHERE `books`.`id` = ? LIMIT 1) as `books`
LEFT JOIN `pages` AS `__pages` ON (`books`.`id` = `__pages`.`book`)

It is dropped in Formatter.createObject, which gates all relation assignment on the
reference index:

if (classSchema.getReferences().length > 0) {
    const handledRelation = model.joins.length ? this.assignJoins(model, classSchema, dbRecord, converted) : undefined;
    // ...
}

Note that an entity is only affected as long as every one of its relations is an
override. A single non-overriding relation keeps the index non-empty, and assignJoins
then iterates model.joins directly, so the overriding relations are populated after all.
That makes the bug easy to miss.

A second symptom of the same cause is ReflectionClass.findReverseReference(), which
iterates the reference index and therefore fails with
Class X has no reference to class Y defined when the reverse property is an override.

Cause

ReflectionClass fills its reference index only in registerProperty()
(src/reflection/reflection.ts):

registerProperty(property: ReflectionProperty) {
    // ...
    this.properties.push(property);
    this.propertyNames.push(property.name);
    if (property.isReference() || property.isBackReference()) {
        this.references.push(property);
    }
    // ...
}

The constructor first registers the base class properties, at which point pages is still
BookInput.pages (a plain array, not a back reference), so it is not indexed. The own
members are applied afterwards through add(), which takes the setType path for an
existing name and never revisits the index:

add(member: Type) {
    if (member.kind === ReflectionKind.property || member.kind === ReflectionKind.propertySignature) {
        const existing = this.getPropertyOrUndefined(member.name);
        if (existing) {
            existing.setType(member.type);   // type is overridden, index is not updated
        } else {
            this.registerProperty(new ReflectionProperty(member, this));
        }
    }
    // ...
}

Suggested fix

Update the index when an existing property is re-typed, e.g. in add() after
existing.setType(member.type), or inside ReflectionProperty.setType(): add the property
to references when it became a reference/back reference, and remove it when it no longer
is one. primaries and autoIncrements are maintained in the same place and look like they
have the same problem for overrides.

Workaround

Appending the affected property to the index after the class definitions restores the
expected behaviour:

const references = (bookSchema as unknown as { references: ReflectionProperty[] }).references;
references.push(bookSchema.getProperty('pages'));

Re-registering the properties (removeProperty() + registerProperty()) also fixes the
index, but it appends the member at the end of the class members and thereby breaks
deserialization of properties that the base class declares as constructor parameters — they
are then instantiated with the base class type (PageInput instead of Page).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions