使用 NestJS、TypeORM、GraphQL 更新具有实体之间关系的 PSQL 表

Posted

技术标签:

【中文标题】使用 NestJS、TypeORM、GraphQL 更新具有实体之间关系的 PSQL 表【英文标题】:Updating PSQL Tables with Relationships between Entities Using NestJS, TypeORM, GraphQL 【发布时间】:2021-06-02 15:46:05 【问题描述】:

我已经为创建新表和更新后端的 TypeORM 实体苦苦挣扎了一周。我们将 NestJS、GraphQL 和 TypeORM 与 PSQL 数据库一起使用。我们有一个生产服务器/数据库设置,其中已经保存了客户的信息。我正在尝试使用代码优先的方法向数据库添加一个新表来生成模式。在 repo 的 master 分支上,我在本地环境中启动它,并连接到一个干净的数据库。创建帐户并将信息保存到表后,我会切换到一个新分支,其中包含用于实现新表的代码,包括模块、服务、实体和解析器。如果我尝试运行此分支并连接到我在 master 上使用的同一个数据库,它将无法编译,无法生成 schema.gql 文件,并在“GraphQLModule 依赖项已初始化”处停止。我创建的这个新表与 Teams 表具有多对一关系,其中已经包含值。出于某种原因,我认为 TypeORM 无法正确更新数据库,我不知道为什么。如果我创建一个新数据库,并使用新表代码连接到分支上的新数据库,它就可以正常工作,并且不会引发任何错误。问题是我连接原来的数据库,没有报错,但是代码编译失败,不知道怎么调试。

是否有人在使用 TypeORM、Nest 和 GraphQL 将新表添加到他们的 PSQL 数据库时遇到任何问题?

这里有一些代码 sn-ps 说明了我的意思:

豁免表实体(已存在于旧数据库中)

@Entity( name: 'waivers' )
@ObjectType()
export class WaiverEntity extends BaseEntity 
  @Field(() => ID)
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Field(() => AccountEntity)
  @ManyToOne(
    () => AccountEntity,
    creator => creator.waivers,
     onDelete: 'SET NULL' ,
  )
  @JoinColumn()
  creator: Promise<AccountEntity>;

  @Field(() => TeamEntity)
  @ManyToOne(
    () => TeamEntity,
    team => team.waivers,
     onDelete: 'CASCADE' ,
  )
  @JoinColumn()
  team: Promise<TeamEntity>;

  @Field(() => ID)
  @Column( nullable: true )
  creatorId: string;

  @Field(() => ID)
  @Index()
  @Column( nullable: true )
  teamId: string;

  @Field()
  @Column('json')
  organizer: Organizer;

  @Field()
  @Column('json')
  event: Event;

  @Field()
  @Column('json',  nullable: true )
  eventDate: EventDate;

  @Field( nullable: true )
  @Column()
  includeEmergencyContact: boolean;

  @Field( nullable: true )
  @Column( nullable: true )
  customerLabel: string;

  @Field(() => CustomEntity,  nullable: true, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  intensity: CustomEntity;

  @Field(() => [CustomEntity],  nullable: true, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  activities: CustomEntity[];

  @Field( defaultValue: waiverStatus.DRAFT, nullable: false )
  @Column( default: waiverStatus.DRAFT, nullable: false )
  status: string;

  @Field( nullable: true )
  @Column( type: 'varchar', nullable: true )
  title: string;

  @Field( nullable: true )
  @Column( nullable: true )
  body: string;

  @Field( nullable: true )
  @Column( nullable: true, default: signatureDefaultContent )
  signatureContent: string;

  @Field(() => [String],  nullable: true )
  @Column('simple-array',  nullable: true )
  ageGroup: string[];

  @Field(() => [AdditionalFields],  nullable: false, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  additionalFields: AdditionalFields[];

  @Field( nullable: false )
  @Column( nullable: false )
  step: number;

  @Exclude()
  @Field( nullable: true )
  @Column( nullable: true, unique: true )
  pdfURL: string;

  @BeforeInsert()
  cleanUpBeforeUpdate(): void 
    // add Prefix on retrieval
    if (this.organizer && this.organizer.photoURL) 
      try 
        const photoUrls = this.organizer.photoURL.split(
          `$AWS_BUCKETS.ORGANIZATION_BUCKET_IMAGE/`,
        );

        this.organizer.photoURL =
          photoUrls.length > 1 ? photoUrls[1] : this.organizer.photoURL;
       catch (e) 
    
  

  @AfterLoad()
  updateURLs(): void 
    // add Prefix on retrieval
    this.pdfURL = this.pdfURL
      ? `$getBucketPrefix(
          AWS_BUCKETS_TYPES.WAIVER_BUCKET_FILES,
          'https://',
        )/$this.pdfURL`
      : null;

    if (this.organizer) 
      this.organizer.photoURL = this.organizer.photoURL
        ? `$getBucketPrefix(
            AWS_BUCKETS_TYPES.ORGANIZATION_BUCKET_IMAGE,
            'https://',
          )/$this.organizer.photoURL`
        : null;
    
  

  @Field( nullable: true )
  @Column( type: 'timestamp', nullable: true )
  @IsDate()
  publishDate: Date;

  @Field( nullable: true )
  @Column( nullable: true, unique: true )
  slug: string;

  @Field(() => [DownloadEntity],  nullable: true )
  @OneToMany(
    () => DownloadEntity,
    downloadEntity => downloadEntity.waiver,
  )
  @JoinColumn()
  waiverDownloads: Promise<DownloadEntity[]>;

  @Field( defaultValue: 0 )
  downloadCount: number;

  @Field(() => [WaiverMembersEntity])
  @OneToMany(
    () => WaiverMembersEntity,
    waiverMember => waiverMember.account,
  )
  accountConnection: Promise<WaiverMembersEntity[]>;

  @Field(() => [WaiverConsentsEntity])
  @OneToMany(
    () => WaiverConsentsEntity,
    waiverMember => waiverMember.waiver,
  )
  consent: Promise<WaiverConsentsEntity[]>;

  @Field(() => [AccountEntity])
  waiverMember: AccountEntity[];

  @Field(() => [ParticipantsEntity])
  @OneToMany(
    () => ParticipantsEntity,
    participant => participant.waiver,
  )
  participants: ParticipantsEntity[];

  @Field( defaultValue: 0 )
  totalResponses: number;

  @Field()
  eventName: string;

  @Field( nullable: true )
  @Column( type: 'varchar', nullable: true )
  smsContent: string;

  @Field( nullable: true )
  @Column( nullable: true )
  smsCode: string;

  @Field()
  @Column( type: 'timestamp', default: () => timeStamp )
  @IsDate()
  createdAt: Date;

  @Field()
  @Column(
    type: 'timestamp',
    default: () => timeStamp,
    onUpdate: timeStamp,
  )
  @IsDate()
  lastUpdatedAt: Date;


这里是新的实体豁免模板,它与团队表具有多对一关系,并且存在于新分支上

@Entity( name: 'waiverTemplates' )
@ObjectType()
export class WaiverTemplateEntity extends BaseEntity 
  @Field(() => ID)
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Field(() => TeamEntity)
  @ManyToOne(
    () => TeamEntity,
    team => team.waiverTemplates,
     onDelete: 'CASCADE', eager: true ,
  )
  @JoinColumn()
  team: Promise<TeamEntity>;

  @Field(() => ID)
  @Index()
  @Column( nullable: true )
  teamId: string;

  @Field()
  @Column('json')
  event: Event;

  @Field()
  @Column('json')
  eventDate: EventDate;

  @Field( nullable: true )
  @Column( nullable: true )
  includeEmergencyContact: boolean;

  @Field( nullable: true )
  @Column( nullable: true )
  customerLabel: string;

  @Field(() => CustomEntity,  nullable: true, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  intensity: CustomEntity;

  @Field(() => [CustomEntity],  nullable: true, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  activities: CustomEntity[];

  @Field( defaultValue: waiverStatus.DRAFT, nullable: false )
  @Column( default: waiverStatus.DRAFT, nullable: false )
  status: string;

  @Field( nullable: true )
  @Column( type: 'varchar', nullable: true )
  title: string;

  @Field( nullable: true )
  @Column( nullable: true )
  body: string;

  @Field( nullable: true )
  @Column( nullable: true, default: signatureDefaultContent )
  signatureContent: string;

  @Field(() => [String],  nullable: true )
  @Column('simple-array',  nullable: true )
  ageGroup: string[];

  @Field(() => [AdditionalFields],  nullable: false, defaultValue: [] )
  @Column('jsonb',  nullable: true )
  additionalFields: AdditionalFields[];

  @Field()
  eventName: string;

最后,这里是teams 表,它也存在于旧分支上。这是来自新分支的代码,其中包含与 WaiverTemplateEntity 的新 OneToMany 关系。

@Entity( name: 'teams' )
@ObjectType()
export class TeamEntity extends BaseEntity 
  @Field(() => ID)
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Field()
  @Column('varchar')
  title: string;

  @Field( nullable: true )
  @Column('varchar',  nullable: true )
  taxID?: string;

  @Field( nullable: true )
  @Column(simpleJSON,  nullable: true )
  type: CustomEntity;

  @Field( nullable: true )
  @Column('varchar',  nullable: true )
  description?: string;

  @Field(() => AccountEntity,  nullable: false )
  @OneToOne(
    () => AccountEntity,
    accountEntity => accountEntity.organization,
     nullable: true, onDelete: 'SET NULL' ,
  )
  creator: AccountEntity;

  @Field( nullable: true )
  @Column( nullable: true )
  creatorId: string;

  @Field(() => BillingEntity,  nullable: true )
  @OneToOne(
    () => BillingEntity,
    billingEntity => billingEntity.team,
     cascade: true ,
  )
  billingInformation: Promise<BillingEntity>;

  @Field( nullable: true )
  @Column('varchar',  nullable: true )
  photoURL?: string;

  @Field( defaultValue: false )
  @Column( default: false )
  nonProfitFreemium: boolean;

  @AfterLoad()
  updateURLs(): void 
    // add Prefix on retrieval
    this.photoURL = this.photoURL
      ? `$getBucketPrefix(
          AWS_BUCKETS_TYPES.ORGANIZATION_BUCKET_IMAGE,
          'https://',
        )/$this.photoURL`
      : null;
  

  @Field(() => [CardEntity],  nullable: true )
  @OneToMany(
    () => CardEntity,
    cardEntity => cardEntity.holder,
     cascade: true ,
  )
  cards: Promise<CardEntity[]>;

  @Field( nullable: true, defaultValue:  )
  @Column(simpleJSON,  nullable: true )
  location?: LocationEntity;

  @Field( nullable: true, defaultValue:  )
  @Column(simpleJSON,  nullable: true )
  contact?: ContactEntity;

  @Field( nullable: true )
  @Column( nullable: true )
  numberOfEmployees?: string;

  @Field( nullable: true )
  @Column( nullable: true )
  stripeId?: string;

  @Field()
  @Column( type: 'timestamp', default: () => 'CURRENT_TIMESTAMP(6)' )
  @IsDate()
  createdAt: Date;

  @Field()
  @Column(
    type: 'timestamp',
    default: () => 'CURRENT_TIMESTAMP(6)',
    onUpdate: 'CURRENT_TIMESTAMP(6)',
  )
  @IsDate()
  lastUpdatedAt: Date;

  @Field(() => [InvitationEntity])
  @OneToMany(
    () => InvitationEntity,
    invitationEntity => invitationEntity.team,
  )
  invitations: Promise<InvitationEntity[]>;

  @Field(() => [WaiverEntity])
  @OneToMany(
    () => WaiverEntity,
    waiver => waiver.team,
  )
  waivers: Promise<WaiverEntity[]>;

  @Field( nullable: true )
  @Column( default: () => 0 )
  credits: number;

  @Field( nullable: true )
  @Column( default: () => false )
  autoReload: boolean;

  @Field( nullable: true )
  @Column( default: () => 0 )
  autoReloadAmount: number;

  @Field( nullable: true )
  @Column( default: () => 0 )
  autoReloadMinAmount: number;

  @Field( nullable: true )
  @Column( type: 'float', default: 0.0 )
  fixedWaiverPrice: number;

  @Field(() => [TransactionEntity])
  @OneToMany(
    () => TransactionEntity,
    transaction => transaction.team,
  )
  transactions: Promise<TransactionEntity[]>;

  @Field(() => [WaiverTemplateEntity])
  @OneToMany(
    () => WaiverTemplateEntity,
    waiverTemplate => waiverTemplate.team,
  )
  waiverTemplates: Promise<WaiverTemplateEntity[]>;

我知道表中有很多列,但需要注意的是 Teams 表和 WaiverTemplates 表之间的关系。这是我在实体中唯一更改的内容,我认为可能是我无法连接到这个新分支上的先前数据库的原因。如果您想查看我的服务、解析器或模块,请询问。我不相信它们会导致任何问题,因为如果我连接到新数据库,一切都会按预期编译和工作,不会引发任何错误。我真的只是在寻找有关如何调试此问题的任何见解。

【问题讨论】:

【参考方案1】:

如果有人对此问题感兴趣,我今天终于解决了错误,至少在上面的表格方面。

使用 TypeORM 更改 PSQL 数据库时,最好使用 typeorm migration:generate -n [name of migration file] 创建或生成自己的迁移文件,然后 typeorm migration:run。 generate 命令将自动生成一个向上和向下 SQL 迁移以运行。您可以在此命令之前使用npx 或从 node_modules 访问 cli,因为仅运行 typeorm 命令会给我一个 command not found 错误。

然后我查看了生成的迁移文件,你瞧,我添加到表中的列没有设置为NULL,因此我在上一个表中的这些列的值有错误为空。我必须手动将NULL 添加到每个列中才能编译代码。不过这很奇怪,因为我将实体更新为在这些字段的 @Column 装饰器中包含 nullable: true

如果有人知道如何使用 TypeORM 和 Nest 更好地改变现有表中的关系,请与我联系。我仍在为迁移文件手动编写 SQL,以便可以更改其他三个表中的关系。我使用的遗留代码做得很差,所以关系从一开始就是错误的。

【讨论】:

以上是关于使用 NestJS、TypeORM、GraphQL 更新具有实体之间关系的 PSQL 表的主要内容,如果未能解决你的问题,请参考以下文章

将 TypeORM 实体模型类与 NestJS-GraphQL 模式类型结合使用好吗?

NestJS + Typeorm + Graphql:嵌套关系中 DTO 的正确设计模式

如何将“typeorm”模型转换为 graphql 有效负载?

找不到模块'@nestjs/typeorm'

NestJS + TypeORM:使用两个或更多数据库?

TypeOrm - NestJS 使用 queryBuilder