typeorm icon indicating copy to clipboard operation
typeorm copied to clipboard

Calling `create` with a loaded entity reloads relations in both directions

Open smcgivern opened this issue 1 year ago • 0 comments

Issue description

Calling create with a loaded entity reloads relations in both directions

Expected Behavior

Calling create only uses the non-virtual columns from the given entity (or entities) to construct the new entity.

Actual Behavior

We sometimes call create with an existing entity, or a saved set of attributes. If we do this including relations (i.e. the default behaviour), TypeORM will reload all relations in both directions. That is, it will reload relations of the entity being loaded - and the inverse relations too.

Steps to reproduce

entity/Category.ts:

import { BaseEntity, Column, Entity, OneToMany, PrimaryColumn } from "../../../../src"
import { Post } from "./Post"

@Entity()
export class Category extends BaseEntity {
    @PrimaryColumn()
    id: number

    @Column()
    name: string

    @OneToMany((type) => Post, post => post.category)
    posts: Promise<Post[]>
}

entity/Post.ts:

import {
    BaseEntity,
    Column,
    Entity,
    ManyToOne,
    PrimaryColumn,
} from "../../../../src"
import { Category } from "./Category"

@Entity()
export class Post extends BaseEntity {
    @PrimaryColumn()
    id: number

    @Column({
        nullable: true,
        unique: true,
    })
    externalId?: string

    @Column()
    title: string

    @Column({
        default: "This is default text.",
    })
    text: string

    @ManyToOne((type) => Category, category => category.posts)
    category: Promise<Category>
    @Column()
    categoryId: number;
}

issue-new.ts:

import "reflect-metadata";
import { createTestingConnections, closeTestingConnections, reloadTestingDatabases } from "../../utils/test-utils";
import { DataSource } from "../../../src/data-source/DataSource"
import { Post } from "./entity/Post"
import { Category } from "./entity/Category"

describe("github issues > #new", () => {

    let dataSources: DataSource[];
    before(async () => dataSources = await createTestingConnections({
        entities: [__dirname + "/entity/*{.js,.ts}"],
        schemaCreate: true,
        dropSchema: true,
    }));
    beforeEach(() => reloadTestingDatabases(dataSources));
    after(() => closeTestingConnections(dataSources));

    it.only("should not reload relations in create", () => Promise.all(dataSources.map(async dataSource => {
        const category = Category.create({ id: 1, name: "Create" })
        await category.save()
        const existingPost = Post.create({
            id: 1,
            title: "First post",
            categoryId: category.id,
        })
        await existingPost.save()

        const newPost = Post.create({
            id: 2,
            title: "About ActiveRecord",
            text: "Huge discussion how good or bad ActiveRecord is.",
            categoryId: category.id,
        })
        console.log("Recreating", newPost)

        const newPost2 = Post.create(newPost)
        newPost2.title.should.be.eql(newPost.title)
    })));
});

Run this with logging enabled. You will see:

# skip schema setup
query: SELECT "Category"."id" AS "Category_id", "Category"."name" AS "Category_name" FROM "category" "Category" WHERE "Category"."id" IN ($1) -- PARAMETERS: [1]
query: START TRANSACTION
query: INSERT INTO "category"("id", "name") VALUES ($1, $2) -- PARAMETERS: [1,"Create"]
query: COMMIT
query: SELECT "Post"."id" AS "Post_id", "Post"."externalId" AS "Post_externalId", "Post"."title" AS "Post_title", "Post"."text" AS "Post_text", "Post"."categoryId" AS "Post_categoryId" FROM "post" "Post" WHERE "Post"."id" IN ($1) -- PARAMETERS: [1]
query: START TRANSACTION
query: INSERT INTO "post"("id", "externalId", "title", "text", "categoryId") VALUES ($1, DEFAULT, $2, DEFAULT, $3) RETURNING "text" -- PARAMETERS: [1,"First post",1]
query: COMMIT
Recreating Post {
  id: 2,
  title: 'About ActiveRecord',
  text: 'Huge discussion how good or bad ActiveRecord is.',
  categoryId: 1
}
    ✔ should not reload relations in create (71ms)
query: SELECT "category"."id" AS "category_id", "category"."name" AS "category_name" FROM "category" "category" INNER JOIN "post" "Post" ON "Post"."categoryId" = "category"."id" WHERE "Post"."id" IN ($1) -- PARAMETERS: [2]

query failed: SELECT "category"."id" AS "category_id", "category"."name" AS "category_name" FROM "category" "category" INNER JOIN "post" "Post" ON "Post"."categoryId" = "category"."id" WHERE "Post"."id" IN ($1) -- PARAMETERS: [2]
error: Error: Connection terminated

  1 passing (495ms)

Note how this runs the queries for Post.category and Category.posts. This can result in loading far more relations than expected.

My Environment

Dependency Version
TypeORM version master branc

Additional Context

We can work around this by filtering the column list to only non-virtual columns ourselves, but this is really surprising and hard to debug behaviour. We had this case with an inverse relation that loaded tens of thousands of rows, which was very bad for performance.

Relevant Database Driver(s)

  • [ ] aurora-mysql
  • [ ] aurora-postgres
  • [ ] better-sqlite3
  • [ ] cockroachdb
  • [ ] cordova
  • [ ] expo
  • [ ] mongodb
  • [ ] mysql
  • [ ] nativescript
  • [ ] oracle
  • [X] postgres
  • [ ] react-native
  • [ ] sap
  • [ ] spanner
  • [ ] sqlite
  • [ ] sqlite-abstract
  • [ ] sqljs
  • [ ] sqlserver

Are you willing to resolve this issue by submitting a Pull Request?

Yes, I have the time, but I don't know how to start. I would need guidance.

smcgivern avatar Feb 20 '24 15:02 smcgivern