doug-martin / nestjs-query

Easy CRUD for GraphQL.
https://doug-martin.github.io/nestjs-query
MIT License
823 stars 142 forks source link

Assember results are cashed/not refreshed #1561

Closed coupster74 closed 1 year ago

coupster74 commented 1 year ago

I suspect this is not nestjs-query specific, but thought I would raise here with those who know more about the foundation of when/how assemblers are called.

I have entities that have a start date and an end date, and an isActive flag. I created an assembler which sets the isActive to true if the current date is between start and end date. This all works fine enough, however, when promoted into production, the isActive does not refresh - current date changes to be between start and end, and isActive remains false. It appears to be cached as a result set. Once I restart the service, then the results are appropriately refreshed.

few additional points..

This issue has been at play for a while and I'd appreciate any help I can get at this point.

here is the base entity:

  Column,
  UpdateDateColumn,
  CreateDateColumn,
  Index,
  DeleteDateColumn,
} from 'typeorm';
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
import {
  BeforeCreateMany,
  BeforeCreateOne,
  BeforeUpdateMany,
  BeforeUpdateOne,
  CreateManyInputType,
  CreateOneInputType,
  FilterableField,
  UpdateManyInputType,
  UpdateOneInputType,
} from '@nestjs-query/query-graphql';
import { UserContextOnRequest } from 'src/auth/user-context.interface';
import { Logger } from '@nestjs/common';

@ObjectType()
@BeforeCreateOne(
  (input: CreateOneInputType<CommonFields>, context: UserContextOnRequest) => {
    // eslint-disable-next-line no-param-reassign
    const createdBy = context.req.user?.userContext
      ? context.req.user?.userContext.email
      : 'Unknown';
    return {
      input: { ...input.input, createdBy: createdBy },
    };
  },
)
@BeforeCreateMany(
  (input: CreateManyInputType<CommonFields>, context: UserContextOnRequest) => {
    const createdBy = context.req.user?.userContext
      ? context.req.user?.userContext.email
      : 'Unknown';
    // eslint-disable-next-line no-param-reassign
    input.input = input.input.map((c) => ({ ...c, createdBy }));
    return input;
  },
)
@BeforeUpdateOne(
  (input: UpdateOneInputType<CommonFields>, context: UserContextOnRequest) => {
    // eslint-disable-next-line no-param-reassign
    const updatedBy = context.req.user?.userContext
      ? context.req.user?.userContext.email
      : 'Unknown';
    input.update.updatedBy = updatedBy;
    return input;
  },
)
@BeforeUpdateMany(
  (
    input: UpdateManyInputType<CommonFields, CommonFields>,
    context: UserContextOnRequest,
  ) => {
    const updatedBy = context.req.user?.userContext
      ? context.req.user?.userContext.email
      : 'Unknown';
    // eslint-disable-next-line no-param-reassign
    input.update.updatedBy = updatedBy;
    return input;
  },
)
export abstract class CommonFields {
  @FilterableField({ defaultValue: true })
  @Column({ type: 'boolean', default: true })
  isActive: boolean;

  @FilterableField({ defaultValue: false })
  @Column({ type: 'boolean', default: false })
  isArchived: boolean;

  @FilterableField({ nullable: true })
  @Column({ type: 'varchar', nullable: true })
  @Index({ fulltext: true })
  internalComment?: string;

  @FilterableField(() => GraphQLISODateTime)
  @CreateDateColumn({ type: 'timestamp' })
  created: Date;

  @FilterableField({ nullable: true })
  @Column({ type: 'varchar', nullable: true })
  createdBy: string;

  @FilterableField(() => GraphQLISODateTime)
  @UpdateDateColumn({ type: 'timestamp' })
  updated: Date;

  @FilterableField({ nullable: true })
  @Column({ type: 'varchar', nullable: true })
  updatedBy: string;

  @Field(() => GraphQLISODateTime, { nullable: true })
  @DeleteDateColumn({ type: 'timestamp', nullable: true })
  deletedOn?: Date;

  @FilterableField({ nullable: true })
  @Column({ type: 'varchar', nullable: true })
  deletedBy: string;
}

and

import { PrimaryGeneratedColumn, Column } from 'typeorm';
import { GraphQLISODateTime, ID, ObjectType } from '@nestjs/graphql';
import { FilterableField, IDField } from '@nestjs-query/query-graphql';
import { CommonFields } from './common-fields.entity';

@ObjectType()
export abstract class BaseEntity extends CommonFields {
  @IDField(() => ID)
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @FilterableField(() => GraphQLISODateTime)
  @Column({ type: 'timestamp', nullable: true })
  remoteUpdated?: Date;
}

here is an entity demonstrating this behaviour:

import { FilterableField, QueryOptions } from '@nestjs-query/query-graphql';
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
import { DEFAULT_QUERY_RESULTS } from '../config/constants';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base/base.entity';

@Entity()
@ObjectType()
@QueryOptions({ defaultResultSize: DEFAULT_QUERY_RESULTS, maxResultsSize: -1 })
export class ClientNotification extends BaseEntity {
  @FilterableField(() => GraphQLISODateTime, { nullable: false })
  @Column({ nullable: false })
  startDate!: Date;

  @FilterableField(() => GraphQLISODateTime, { nullable: false })
  @Column({ nullable: false })
  endDate!: Date;

  @Field({ nullable: false })
  @Column({ nullable: false })
  notice!: string;
}

and here is the assembler

import { ClassTransformerAssembler, Query } from '@nestjs-query/core';

type EntityWithDateRange = {
  startDate: Date;
  endDate: Date;
  isActive: boolean;
};

export class EntityWithDateRangeAssembler<
  E extends EntityWithDateRange,
> extends ClassTransformerAssembler<E, E> {
  nowTime: Date;
  constructor() {
    super();
    this.nowTime = new Date();
  }

  convertToDTO(entity: E): E {
    const dto = super.convertToDTO(entity);

    if (
      entity.startDate.getTime() < this.nowTime.getTime() &&
      this.nowTime.getTime() < entity.endDate.getTime()
    )
      dto.isActive = true;
    else dto.isActive = false;

    return dto;
  }

  checkForIsActive(filterClause, level = 0) {
    //Logger.log(`level ${level}: checking ${JSON.stringify(filterClause)}`);

    // check if it has isActive attribute
    if (
      'isActive' in filterClause &&
      !(filterClause['isActive']['is'] == null)
    ) {
      const isActiveValue: boolean = filterClause['isActive']['is'];
      delete filterClause.isActive;
      if (isActiveValue) {
        filterClause.and = [
          { startDate: { lte: this.nowTime.toISOString() } },
          {
            endDate: { gte: this.nowTime.toISOString() },
          },
        ];
      } else {
        filterClause.or = [
          {
            startDate: { gt: this.nowTime.toISOString() },
          },
          {
            endDate: { lte: this.nowTime.toISOString() },
          },
        ];
      }
      //Logger.log('FOUND IT, FIXED IT')
      return filterClause;
    }

    // recurse
    for (const key in filterClause) {
      // value as an array (and/or) - check each as objects
      if (Array.isArray(filterClause[key])) {
        filterClause[key] = filterClause[key].map((x) =>
          this.checkForIsActive(x, ++level),
        );
      }

      // value as class
      if (
        typeof filterClause[key] === 'object' &&
        !Array.isArray(filterClause[key]) &&
        filterClause[key] !== null
      ) {
        filterClause[key] = this.checkForIsActive(filterClause[key], ++level);
      }
    }

    return filterClause;
  }

  convertQuery(query: Query<E>): Query<E> {
    const filter = query.filter;
    query.filter = this.checkForIsActive(filter);
    return { ...query };
  }
}
coupster74 commented 1 year ago

I think I've figured it out.. but here for others. I was under the impression this was constructed when required, but as part of injection, it is constructed on start up.. and as shown above, the date is only set then... dumb.