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).
Overriding a base class property with
Reference/BackReferencedoes not register correctly as a referenceNote
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/type1.0.19,@deepkit/type-compiler1.0.19,@deepkit/orm1.0.19,@deepkit/mysql1.0.19Summary
When an entity class overrides a property that its base class already declares, and the
override adds
ReferenceorBackReference, the property keeps the correct type(
isReference()/isBackReference()returntrue, joins type-check and produce correctSQL) but it is never added to the reference index of the
ReflectionClass. As a resultReflectionClass.getReferences()does not list it.For the ORM this means the relation is silently not populated:
joinWith()builds thecorrect
LEFT JOIN, the database returns the child rows, and the formatter throws themaway. No error is raised, the relation is simply
undefined.Reproduction
The same happens for
Reference(many-to-one) overrides. The trigger is that the base classalready declares a property of that name.
ORM consequence
With
Book/Pagerows present in the database:The generated SQL is correct and returns the child row:
It is dropped in
Formatter.createObject, which gates all relation assignment on thereference index:
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
assignJoinsthen iterates
model.joinsdirectly, 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(), whichiterates the reference index and therefore fails with
Class X has no reference to class Y definedwhen the reverse property is an override.Cause
ReflectionClassfills its reference index only inregisterProperty()(
src/reflection/reflection.ts):The constructor first registers the base class properties, at which point
pagesis stillBookInput.pages(a plain array, not a back reference), so it is not indexed. The ownmembers are applied afterwards through
add(), which takes thesetTypepath for anexisting name and never revisits the index:
Suggested fix
Update the index when an existing property is re-typed, e.g. in
add()afterexisting.setType(member.type), or insideReflectionProperty.setType(): add the propertyto
referenceswhen it became a reference/back reference, and remove it when it no longeris one.
primariesandautoIncrementsare maintained in the same place and look like theyhave the same problem for overrides.
Workaround
Appending the affected property to the index after the class definitions restores the
expected behaviour:
Re-registering the properties (
removeProperty()+registerProperty()) also fixes theindex, 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 (
PageInputinstead ofPage).