Search code examples
typescriptlazy-loadingone-to-onetypeorm

TypeORM lazyload update parent fails on child save


I am not sure if this is a bug, or I am doing something wrong, but I tried a lot of things to get this working and I couldn't. I hope you guys can help.

Basically I have a one to one relationship that I need to lazyLoad. The relation tree is kind of big in my project and I can't load it without promises.

The issue I face is that when I save a child, the parent update generated sql is missing the update fields: UPDATE `a` SET WHERE `id` = 1

This is working perfectly when I am not using lazyLoading (Promises).

I got a simple example set up using the generated code tool.

Entity A

@Entity()
export class A {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @OneToOne(
        (type: any) => B,
        async (o: B) => await o.a
    )
    @JoinColumn()
    public b: Promise<B>;
}

Entity B

@Entity()
export class B {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @OneToOne(
        (type: any) => A,
        async (o: A) => await o.b)
    a: Promise<A>;

}

main.ts

createConnection().then(async connection => {

    const aRepo = getRepository(A);
    const bRepo = getRepository(B);

    console.log("Inserting a new user into the database...");
    const a = new A();
    a.name = "something";
    const aCreated = aRepo.create(a);
    await aRepo.save(aCreated);

    const as = await aRepo.find();
    console.log("Loaded A: ", as);

    const b = new B();
    b.name = "something";
    const bCreated = bRepo.create(b);
    bCreated.a =  Promise.resolve(as[0]);
    await bRepo.save(bCreated);

    const as2 = await aRepo.find();
    console.log("Loaded A: ", as2);

}).catch(error => console.log(error));

Output

Inserting a new user into the database...
query: SELECT `b`.`id` AS `b_id`, `b`.`name` AS `b_name` FROM `b` `b` INNER JOIN `a` `A` ON `A`.`bId` = `b`.`id` WHERE `A`.`id` IN (?) -- PARAMETERS: [[null]]
query: START TRANSACTION
query: INSERT INTO `a`(`id`, `name`, `bId`) VALUES (DEFAULT, ?, DEFAULT) -- PARAMETERS: ["something"]
query: UPDATE `a` SET  WHERE `id` = ? -- PARAMETERS: [1]
query failed: UPDATE `a` SET  WHERE `id` = ? -- PARAMETERS: [1]

If I remove the promises from the entities, everything is working fine:

Entity A

...
    @OneToOne(
        (type: any) => B,
        (o: B) => o.a
    )
    @JoinColumn()
    public b: B;
}

Entity B

...
    @OneToOne(
        (type: any) => A,
        (o: A) => o.b)
    a: A;

}

main.ts

createConnection().then(async connection => {
...
    const bCreated = bRepo.create(b);
    bCreated.a =  as[0];
    await bRepo.save(bCreated);
...

Output

query: INSERT INTO `b`(`id`, `name`) VALUES (DEFAULT, ?) -- PARAMETERS: ["something"]
query: UPDATE `a` SET `bId` = ? WHERE `id` = ? -- PARAMETERS: [1,1]
query: COMMIT
query: SELECT `A`.`id` AS `A_id`, `A`.`name` AS `A_name`, `A`.`bId` AS `A_bId` FROM `a` `A`

I have also created a git project to illustrate this and for easy of testing.

1) using promises (not working) https://github.com/cuzzea/bug-typeorm/tree/promise-issue

2) no lazy loading (working) https://github.com/cuzzea/bug-typeorm/tree/no-promise-no-issue


Solution

  • I've had a poke around in your promise-issue repository branch, and discovered a few interesting things:

    1. The invalid UPDATE query is being triggered by the initial await aRepo.save(aCreated);, NOT by the insert of B and subsequent foreign key assignment to a.b. Assigning a.b = null before the aRepo.create(a) avoids the issue.

    2. Adding the initialization a.b = null; before aRepo.create(a) avoids the unexpected invalid UPDATE; ie:

      const a = new A();
      a.name = "something";
      a.b = null;
      const aCreated = aRepo.create(a);
      await aRepo.save(aCreated);
    3. I'm fairly confident that the use of async functions for the inverseSide argument to @OneToOne() (ie. async (o: B) => await o.a)) is incorrect.
      The documentation indicates that this should be just (o: B) => o.a, and the generics on OneToOne also confirm this.
      TypeORM will be resolving the promise BEFORE it passes the value to this function, and an async function returns another Promise rather than the correct property value.

    4. I've also just noticed that you're passing an instance of class A to aRepo.create(). This isn't necessary; you can pass your instance directly to aRepo.save(a). The Repository.create() simply copies the values from the provided object into a new instance of the entity class. It also appears that .create() creates promises when they don't already exist. This might actually be the cause of this issue; logging aCreated just before aRepo.save(aCreated) is called shows that the promise isn't resolved.
      In fact, removing the aRepo.create(a) step (and changing the save to await aRepo.save(a); also seems to avoid this issue. Perhaps Repository<T>.create() is handling lazy load properties differently when its argument is already instanceof T? I'll look into this.

    I also tried upgrading the typeorm package to typeorm@next (0.3.0-alpha.12) but the issue still appears to exist there.

    I've just noticed you've already logged a GitHub issue for this; I'll look at creating a test case to demonstrate over the next few days.

    I hope this is enough to answer your question!

    Update

    After some further code tracing, it appears that item 4) from the list above is the reason for this issue.

    In RelationLoader.enableLazyLoad(), TypeORM overloads lazy property accessors on @Entity instances with its own getter and setter - e.g. Object.defineProperty(A, 'b', ...). The overloaded property accessors load and cache the related B record, returning Promise<B>.

    Repository.create() iterates all relations for the created entity and - when an object is provided - builds a new related object from the provided values. However, this logic doesn't account for a Promise object, and tries to build a related entity directly from the Promise's properties.

    So in your case above, aRepo.create(a) builds a new A, iterates the A relations (ie. b), and builds an empty B from the Promise on a.b. The new B doesn't have any properties defined because Promise instances don't share any properties B. Then because there is no id specified, the foreign key name and value are not defined for aRepo.save(), leading to the error you've encountered.

    So simply passing a directly to aRepo.save() and removing the aRepo.create(a) step seems the correct course of action in this case.

    This is an issue that should be fixed however - but I don't think it's an easy fix since this really needs for Repository.create() to be able to await the Promise; which not achievable at present since Repository.create() is not async.