Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(dal): fixed the mongo schema types and repositories to return the correct mapped types #2890

Merged
merged 12 commits into from
Mar 1, 2023

Conversation

LetItRock
Copy link
Contributor

What change does this PR introduce?

This is the first part of fixing the dal types. At the end of the second part we would have functions to return the correct types when using repository functions, for ex. findById will return Entity | null. Right now it's just Entity which results in errors in the API app when we forget to add the null check.

The Mongoose docs are suggesting using the ObjectId for the field that is "id", but we do type them with string type. Then our repositories are querying DB and doing a mapping to the entity type just to have strings as ids. But having the wrong types on the schema breaks all the Mongoose types system and model functions.

In this PR I've introduced the DBModel interfaces that the Mongoose Schema uses and the Entity type is returned back from the repositories like it was before, so nothing will have to be changed on the API app side.

Why was this change needed?

Other information (Screenshots)

@LetItRock LetItRock self-assigned this Feb 23, 2023
@linear
Copy link

linear bot commented Feb 23, 2023

NV-1753 The repository function `findById` has wrong return type

Why? (Context)

The repository function findById has wrong return type and is leading to the Cannot read properties of null (reading 'FIELD') error across the API app if there is no check for null.

What?

Revisit all the repositories.

Definition of Done

The return type should be like Entity | null and we should add the missed checks across the API app.

Comment on lines +15 to +16
import { CreateChange } from '../src/app/change/usecases/create-change/create-change.usecase';
import { CreateChangeCommand } from '../src/app/change/usecases/create-change/create-change.command';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the API migration file, I've fixed the imports and ts issues here

Comment on lines +53 to +59
super(
messageRepository,
createLogUsecase,
createExecutionDetails,
subscriberRepository,
getDecryptedIntegrationsUsecase
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added GetDecryptedIntegrations as it's required by the super class, check below

@@ -21,7 +21,7 @@ export abstract class SendMessageBase extends SendMessageType {
protected createLogUsecase: CreateLog,
protected createExecutionDetails: CreateExecutionDetails,
protected subscriberRepository: SubscriberRepository,
protected getDecryptedIntegrationsUsecase?: GetDecryptedIntegrations
protected getDecryptedIntegrationsUsecase: GetDecryptedIntegrations
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not be optional as we have the code that uses this use-case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, I made him optional only because at the moment in-app does not require integration.


export class BaseRepository<T_Query, T_Response> {
public _model: Model<any & Document>;
export class BaseRepository<T_DBModel, T_MappedEntity> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a lot of type fixes here (no logic changes), but basically, the BaseRepository requires now to pass the T_DBModel which is a DB model, and T_MappedEntity which is the type that we map the DB object to.
So like the Mongoose model return DBModel objects and we map them to Entity, just to have ids as strings, not the ObjectIds.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like to differentiate the type of what the repository is expecting and what is returning. Was a pattern I used a lot in the past. I am happy you went that direction.

Comment on lines 30 to 43
export type ChangeDBModel = Omit<
ChangeEntity,
'_creatorId' | '_environmentId' | '_organizationId' | '_entityId' | '_parentId'
> & {
_creatorId: Types.ObjectId;

_environmentId: Types.ObjectId;

_organizationId: Types.ObjectId;

_entityId: Types.ObjectId;

_parentId?: Types.ObjectId;
};
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a DBModel that we pass to the Mongoose schema, please note that id fields have types as Types.ObjectId. So we will have here just to override types for the ids.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the replacement of the query enforcement that @djabarovgeorge implemented?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the query enforcement was to force using _organizationId or _environmentId when querying the model... but as I commented somewhere else this strict rule breaks the types, like the Mongoose model raises TS errors because these fields should be optional

import { ChangeEntityTypeEnum } from '@novu/shared';

import type { EnvironmentId } from '../environment';
import type { OrganizationId } from '../organization';

export class ChangeEntity {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type we do return from the repositories to the outside world

Comment on lines +48 to +49
export const Change =
(mongoose.models.Change as mongoose.Model<ChangeDBModel>) || mongoose.model<ChangeDBModel>('Change', changeSchema);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why we have here "or", but I guess it's for the case when the model already is created.
So when the first part is true it means that the model is created so we cast it to the correct type.
The second part creates the model passing the correct type and schema.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is mainly for e2e testin for the exact reason you have mentioned 🙏

Comment on lines -7 to -10
class PartialExecutionDetailsEntity extends Omit(ExecutionDetailsEntity, ['_environmentId', '_organizationId']) {}

type EnforceEnvironmentQuery = FilterQuery<PartialExecutionDetailsEntity & Document> &
({ _environmentId: string } | { _organizationId: string });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed these hacks to force the _environmentId or _organizationId, because it breaks all the Mongoose types.
When the query is passed to the model function, the query fields are optional, but we force them to be required and that breaks types.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was implemented in purpose by @djabarovgeorge. @scopsy already flagged it but what's the replacement for this functionality?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've answered here: #2890 (comment)
There is no replacement, when you do code the queries you should know which fields you have to use to get the correct DB result...
So the recommendation is when the document has both fields _environmentId, _organizationId we should query by _environmentId as it's org specific. In other cases by the fields that are accessible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added it mainly to help us (and the community) from missing those fields. While working on the implementation I encountered a couple of those issues in the project that was missing environment fields.

.populate('template', selectTemplate)
.populate('notification', selectNotification)
.populate('subscriber', selectSubscriber)
.populate('environment', selectEnvironment)
.lean()
.exec();

return job as unknown as JobEntityPopulated;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here we don't do mapping, I don't know why, but I see its failing when calling this.mapEntity(job) and returns {} empty object

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to replace this.MongooseModel for this._model in line 157 and see if that's the reason.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@p-fernandez that is not the reason, base repository sets the this._model in the constructor, the same as it does for this.MongooseModel. The problem might be the JSON.stringify(entity) call that does the this.mapEntity

@@ -32,6 +32,7 @@ import { CacheKeyPrefixEnum, InvalidateCacheService } from '../../../shared/serv
import { SendMessageBase } from './send-message.base';
import { ApiException } from '../../../shared/exceptions/api.exception';
import { OrganizationEntity } from '../../../../../../../libs/dal/src/repositories/organization/organization.entity';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know not related but this import is bad, need to be refactored to be from @novu/dal, don't we have a lint rule for banned imports 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't. And this was probably and IDE playing around with a fix with an auto import. It is the only occurrence I could find in a quick search in the codebase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll fix this one, but agree we should enable that rule.

@@ -1,5 +1,6 @@
// eslint-ignore max-len
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { AnyKeys } from 'mongoose';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortuently we should not use mongoose related things in API. Can we re export this thing from the dal package? Ideally we would want the application to know as less as possible about mongoose or mongo

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was going to highlight this. Also what's the difference between AnyKeys type and Partial the one implemented before?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a small difference, Partial still requires the correct types for values to be passed, but the AnyKeys allows either for the correct value type or for any other type. So for this file it might work, but I've used it in another place when we do assign the _feedId: null but the type is string | undefined, so it raises the issue there, but yeah I'll fix that place too.


class PartialLogEntity extends Omit(LogEntity, ['_environmentId', '_organizationId']) {}

type EnforceEnvironmentQuery = FilterQuery<PartialLogEntity & Document> &
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How the enforcement is happening right now? Maybe just missed it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

answered here: #2890 (comment)

@@ -181,7 +182,7 @@ export class GetChanges {
entityId: string,
environmentId: string
): Promise<IViewEntity | Record<string, unknown>> {
let item = await this.layoutRepository.findOne({
let item: LayoutEntity | undefined = await this.layoutRepository.findOne({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to be explicit with the type? Is not infered from the method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the findOne return type is LayoutEntity which is the issue I aim to solve with both PRs.
But in the second PR these types are removed ;)

@@ -32,6 +32,7 @@ import { CacheKeyPrefixEnum, InvalidateCacheService } from '../../../shared/serv
import { SendMessageBase } from './send-message.base';
import { ApiException } from '../../../shared/exceptions/api.exception';
import { OrganizationEntity } from '../../../../../../../libs/dal/src/repositories/organization/organization.entity';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't. And this was probably and IDE playing around with a fix with an auto import. It is the only occurrence I could find in a quick search in the codebase.

@@ -1,5 +1,6 @@
// eslint-ignore max-len
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { AnyKeys } from 'mongoose';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was going to highlight this. Also what's the difference between AnyKeys type and Partial the one implemented before?


export class BaseRepository<T_Query, T_Response> {
public _model: Model<any & Document>;
export class BaseRepository<T_DBModel, T_MappedEntity> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like to differentiate the type of what the repository is expecting and what is returning. Was a pattern I used a lot in the past. I am happy you went that direction.

export class ChangeEntity {
_id: string;

_creatorId: string;

_environmentId: string;
_environmentId: EnvironmentId;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am pending to implement the way that this really enforces the types to be of this kind (or flavour) as right now will still allow any string to be passed, so still open to get mixed.

Comment on lines 30 to 43
export type ChangeDBModel = Omit<
ChangeEntity,
'_creatorId' | '_environmentId' | '_organizationId' | '_entityId' | '_parentId'
> & {
_creatorId: Types.ObjectId;

_environmentId: Types.ObjectId;

_organizationId: Types.ObjectId;

_entityId: Types.ObjectId;

_parentId?: Types.ObjectId;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the replacement of the query enforcement that @djabarovgeorge implemented?

Comment on lines -7 to -10
class PartialExecutionDetailsEntity extends Omit(ExecutionDetailsEntity, ['_environmentId', '_organizationId']) {}

type EnforceEnvironmentQuery = FilterQuery<PartialExecutionDetailsEntity & Document> &
({ _environmentId: string } | { _organizationId: string });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was implemented in purpose by @djabarovgeorge. @scopsy already flagged it but what's the replacement for this functionality?

.populate('template', selectTemplate)
.populate('notification', selectNotification)
.populate('subscriber', selectSubscriber)
.populate('environment', selectEnvironment)
.lean()
.exec();

return job as unknown as JobEntityPopulated;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to replace this.MongooseModel for this._model in line 157 and see if that's the reason.

libs/dal/src/repositories/job/job.schema.ts Show resolved Hide resolved
@@ -17,6 +17,8 @@ export class OrganizationEntity {
partnerConfigurations?: IPartnerConfiguration[];
}

export type OrganizationDBModel = OrganizationEntity;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find weird that the DBModel and the Entity are the same in this case. Shouldn't the entity return with the OrganizationId for the _id?
Also DbModel._id should be of type Types.ObjectId for coherence with the rest of the changes done.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DbModel._id type is not required by the schema, it's created automatically, so the OrganizationEntity just has to have it defined as the _id: string as it does, and because of that the OrganizationDBModel = OrganizationEntity for this case.

Copy link
Contributor

@p-fernandez p-fernandez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great big work again.
For me is approved, but because the query enforcement might be sensitive I'd like to have full agreement with @scopsy and @djabarovgeorge. 👍🏻

@LetItRock
Copy link
Contributor Author

@djabarovgeorge @scopsy @p-fernandez I've found the solution here for the env or org ids enforcement so it will stay ;) please check the latest commit

@djabarovgeorge
Copy link
Contributor

Amazing! Looks great to me will review it again tomorrow 🙃, did you had the chance to test it locally it behaves as expected?

@LetItRock
Copy link
Contributor Author

Amazing! Looks great to me will review it again tomorrow 🙃, did you had the chance to test it locally it behaves as expected?

yes, I've tested it and it works the exact same way ;)

Copy link
Contributor

@p-fernandez p-fernandez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌟

So for future reference the use will be:
Mongoose -> FooEntity -> FooDBModel -> [MongoDB] -> FooDBModel -> FooEntity -> Mongoose?
Keeping all the transformations inside of the repositories?

Comment on lines +1 to +5
import type { EnvironmentId } from '../repositories/environment';
import type { OrganizationId } from '../repositories/organization';

export type EnforceOrgId = { _organizationId: OrganizationId };
export type EnforceEnvOrOrgIds = { _environmentId: EnvironmentId } | EnforceOrgId;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Only still missing thing (related to what I have pending) is that you could pass any string of any type and it will pass the validation, but for the enforcement query right now is enough. We can make it stricter in the future. 💪🏻

Comment on lines -41 to -44
interface ISubscriberDocument extends SubscriberEntity, Document {
_id: never;
__v: never;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋🏻

Comment on lines 22 to 33
export type TopicSubscribersDBModel = Omit<
TopicSubscribersEntity,
'_environmentId' | '_organizationId' | '_subscriberId' | '_topicId'
> & {
_environmentId: Types.ObjectId;

_organizationId: Types.ObjectId;

_subscriberId: Types.ObjectId;

_topicId: Types.ObjectId;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be so cool if we could implement a generic that could do this automatic type (pseudocode):

type CustomOmit<T, <A extends ReadonlyArray<string>, V = Types.ObjectId>> = Omit<T, A> & {
  [K in (A extends ReadonlyArray<infer A> ? A : never)]: V
}

So we could do something like this:

type TopicSubscriberDBModel = CustomOmit<TopicSubscribersEntity, <'_environmentId' | '_organizationId' | '_subscriberId' | '_topicId', Types.ObjectId>>;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

your wish is my order :D

type ChangePropsValueType<T, K extends keyof T, V = Types.ObjectId> = Omit<T, K> & {
  [P in K]: V;
};

type TopicSubscribersDBModel = ChangePropsValueType<
  TopicSubscribersEntity,
  '_environmentId' | '_organizationId' | '_subscriberId' | '_topicId'
>;

@LetItRock
Copy link
Contributor Author

🌟

So for future reference the use will be: Mongoose -> FooEntity -> FooDBModel -> [MongoDB] -> FooDBModel -> FooEntity -> Mongoose? Keeping all the transformations inside of the repositories?

exactly!

@LetItRock LetItRock added this pull request to the merge queue Mar 1, 2023
Merged via the queue into next with commit c814bc3 Mar 1, 2023
@LetItRock LetItRock deleted the nv-1753-dal-update-repository-schema-and-model branch March 1, 2023 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants