dipscope / TypeManager.TS

Transform JSON strings or plain objects into JS class instances.
https://dipscope.com/type-manager/what-issues-it-solves
Apache License 2.0
24 stars 3 forks source link

Lazy Decorator Broken #14

Closed DellanX closed 1 year ago

DellanX commented 1 year ago

Describe the bug

I'm attempting to eliminate a few circular dependencies and am running into an error. Given that I am matching the configuration in the documentation, I figure something is wrong.

Cannot read properties of undefined (reading 'hasOwnProperty')

To Reproduce

Steps to reproduce the behavior: I have my user class

import Model from './model';
import { Type, Property } from '@dipscope/type-manager';
import { JsonApiResource } from '@dipscope/json-api-entity-provider';
import type { IModel } from '../interfaces';
//import type { Friendship } from '@friends/services/v2/data/models/friendship';

@Type({ alias: 'users' })
@JsonApiResource({ type: 'users' })
export class User extends Model {
  type = 'users';

  @Property(String)
  declare email: string;

  @Property(Array, [User])
  declare 'issued-friends': User[];
  @Property(Array, [User])
  declare 'held-friends': User[];
  // @Property(Array, [() => Friendship])
  // declare 'issued-friendships': Friendship[];
  // @Property(Array, [() => Friendship])
  // declare 'held-friendships': Friendship[];
}

export default User;

export function isUser(x: IModel): x is User {
  return (x as User).email ? true : false;
}

Next, I have my Friendship Class

import { User } from '@core/services/v2/data/models/user';
import { Type, Property } from '@dipscope/type-manager';

export abstract class Friendship extends Model{
  @Property([String, Number])
  declare userId: string | number;
  @Property([String, Number])
  declare friendId: string | number;

  @Property(() => User)
  declare user: User;
  @Property(() => User)
  declare friend: User;
}

export default Friendship;

Expected behavior I'd expect the library to work as it was when I was using my old Friendship class

import { User } from '@core/services/v2/data/models/user';
import { Type, Property } from '@dipscope/type-manager';

export abstract class Friendship extends Model{
  @Property([String, Number])
  declare userId: string | number;
  @Property([String, Number])
  declare friendId: string | number;

  @Property(User)
  declare user: User;
  @Property(User)
  declare friend: User;
}

export default Friendship;

Instead, I am seeing the error in the screenshots section. Screenshots

image

Desktop (please complete the following information):

Additional context Error Log.log

dpimonov commented 1 year ago

Hi DellanX, thanks for the issue. Unfortunatelly I was not able to reproduce it. Can you provide isolated example so I can copy and debug? Maybe issue is caused by another class and not this one. So far with my local config using your definitions everything works fine.

Also note that this definition is incorrect.

@Property([String, Number])
declare userId: string | number;

It works because string and number does not have generic arguments. Properties with such definition are serialized as Unknown which means directly. You can remove [String, Number]. Array in the property decorator is used for generic arguments (K, V) like Map<K, V>, or Array.

@Property(Map, [String, Number])
declare map: Map<string, number>;
DellanX commented 1 year ago

Oh wow! I had misunderstood the array parameter. That's amazing and will allow me to simplify a few portions of my code! (I have a few classes that could be generics)

I did some experiments and found that this is not an issue with TypeManager, but rather, something wrong with the JsonApiEntityProvider.

I am able to run my code by initializing the classes myself with new User() and new Friendship()

Let me know if you want me to close this issue out and open it on that project instead.

DellanX commented 1 year ago

Okay, I have pretty much reduced my code to the lowest level I was able to. Unfortunately, I don't really know how to make a mock JsonAPI server to test it against. Otherwise, I'd upload a demo for the project.

Working Version

user.ts

import { Type, Property } from '@dipscope/type-manager';
import { JsonApiResource } from '@dipscope/json-api-entity-provider';

@Type()
@JsonApiResource({ type: 'users' })
export class User {
  type = 'users';

  @Property()
  public id?: string;
  @Property()
  public alias?: string;
  @Property()
  public imageUrl?: string;
  @Property(Date)
  public createdAt?: Date;
  @Property(Date)
  public updatedAt?: Date;

  @Property()
  public email?: string;
}

export default User;

My EntityStore setup file:

import { EntityStore } from '@dipscope/entity-store';
import { JsonApiEntityProvider } from '@dipscope/json-api-entity-provider';
import {
  JsonApiNetFilterExpressionVisitor,
  JsonApiNetPaginateExpressionVisitor,
} from '@dipscope/json-api-entity-provider';

// Create entity provider.
const jsonApiEntityProvider = new JsonApiEntityProvider({
  baseUrl: import.meta.env.VITE_APP_URL + '/api/web/v2', // Url to you backend endpoint.
  jsonApiRequestInterceptor: (request: Request) => {
    const token = document.getElementsByName('csrf-token')[0].getAttribute('content');
    request.headers.append('X-CSRF-TOKEN', token ?? '');
    return request;
  }, // You might intercept requests by adding headers.
  jsonApiFilterExpressionVisitor: new JsonApiNetFilterExpressionVisitor(), // You might override filtering strategy used by a server.
  jsonApiPaginateExpressionVisitor: new JsonApiNetPaginateExpressionVisitor(), // You might override pagination strategy used by a server.
});
// Create entity store.
export const entityStore = new EntityStore(jsonApiEntityProvider);
export default entityStore;

My Vue file runs the following call:

import { entityStore } from '@core/services/v2/data/store/entity-store';
const route = useRoute<'/users/[id]'>(); // Retrieves id from URL, route.params.id = 2
const userPromise = entityStore.createEntitySet(User).find(route.params.id).then(u => {
  console.log(u);
  return u;
});

It executes the query as expected, receiving the following as the response:

{
  "jsonapi": {
    "version": "1.0"
  },
  "links": {
    "self": "http://localhost/api/v2/users/2"
  },
  "data": {
    "type": "users",
    "id": "2",
    "attributes": {
      "alias": "John Hancock",
      "imageUrl": "https://ui-avatars.com/api/?name=J+H&color=7F9CF5&background=EBF4FF",
      "email": "test@test.com",
      "createdAt": "2023-08-06T15:21:34.000000Z",
      "updatedAt": "2023-08-09T15:33:21.000000Z"
    },
    "relationships": {},
    "links": {
      "self": "http://localhost/api/v2/users/2"
    }
  }
}

It looks like everything Deserializes correctly:

image

Breaking the code

Let's make a singular change:

import { Type, Property } from '@dipscope/type-manager';
import { JsonApiResource } from '@dipscope/json-api-entity-provider';

@Type()
@JsonApiResource({ type: 'users' })
export class User {
  type = 'users';

  @Property()
  public id?: string;
  @Property()
  public alias?: string;
  @Property()
  public imageUrl?: string;
- @Property(Date)
+ @Property(() => Date)
  public createdAt?: Date;
  @Property(Date)
  public updatedAt?: Date;

  @Property()
  public email?: string;
}

export default User;

Now the library dies:

type-manager.ts:482  Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'hasOwnProperty')
    at TypeManager2.extractTypeMetadata (type-manager.ts:482:47)
    at Metadata2.resolveTypeMetadataUsingTypeFn (metadata.ts:122:33)
    at PropertyMetadata2.get (property-metadata.ts:374:21)
    at PropertyMetadata2.get (property-metadata.ts:304:62)
    at PropertyMetadata2.get (property-metadata.ts:220:43)
    at JsonApiAdapter2.createResourceObjectSerializedEntity (json-api-adapter.ts:448:61)
    at JsonApiAdapter2.createResourceObjectEntity (json-api-adapter.ts:418:39)
    at JsonApiAdapter2.createDocumentObjectEntity (json-api-adapter.ts:356:60)
    at JsonApiEntityProvider2.<anonymous> (json-api-entity-provider.ts:286:52)
    at step (tslib.es6.mjs:147:21)
TypeManager2.extractTypeMetadata @ type-manager.ts:482
Metadata2.resolveTypeMetadataUsingTypeFn @ metadata.ts:122
get @ property-metadata.ts:374
get @ property-metadata.ts:304
get @ property-metadata.ts:220
JsonApiAdapter2.createResourceObjectSerializedEntity @ json-api-adapter.ts:448
JsonApiAdapter2.createResourceObjectEntity @ json-api-adapter.ts:418
JsonApiAdapter2.createDocumentObjectEntity @ json-api-adapter.ts:356
(anonymous) @ json-api-entity-provider.ts:286
step @ tslib.es6.mjs:147
(anonymous) @ tslib.es6.mjs:128
fulfilled @ tslib.es6.mjs:118
Promise.then (async)
wrapByRef @ wrap-by-ref.ts:6
setup @ [id].edit.vue:60
callWithErrorHandling @ runtime-core.esm-bundler.js:158
setupStatefulComponent @ runtime-core.esm-bundler.js:7236
setupComponent @ runtime-core.esm-bundler.js:7197
mountComponent @ runtime-core.esm-bundler.js:5599
processComponent @ runtime-core.esm-bundler.js:5565
patch @ runtime-core.esm-bundler.js:5040
componentUpdateFn @ runtime-core.esm-bundler.js:5773
run @ reactivity.esm-bundler.js:178
instance.update @ runtime-core.esm-bundler.js:5814
callWithErrorHandling @ runtime-core.esm-bundler.js:158
flushJobs @ runtime-core.esm-bundler.js:357
Promise.then (async)
queueFlush @ runtime-core.esm-bundler.js:270
queuePostFlushCb @ runtime-core.esm-bundler.js:290
queueEffectWithSuspense @ runtime-core.esm-bundler.js:1603
scheduler @ runtime-core.esm-bundler.js:1773
triggerEffect @ reactivity.esm-bundler.js:373
triggerEffects @ reactivity.esm-bundler.js:363
triggerRefValue @ reactivity.esm-bundler.js:974
(anonymous) @ reactivity.esm-bundler.js:1135
triggerEffect @ reactivity.esm-bundler.js:373
triggerEffects @ reactivity.esm-bundler.js:358
triggerRefValue @ reactivity.esm-bundler.js:974
(anonymous) @ reactivity.esm-bundler.js:1135
triggerEffect @ reactivity.esm-bundler.js:373
triggerEffects @ reactivity.esm-bundler.js:358
triggerRefValue @ reactivity.esm-bundler.js:974
set value @ reactivity.esm-bundler.js:1018
finalizeNavigation @ vue-router.mjs:3355
(anonymous) @ vue-router.mjs:3220
Promise.then (async)
pushWithRedirect @ vue-router.mjs:3187
push @ vue-router.mjs:3112
install @ vue-router.mjs:3551
use @ runtime-core.esm-bundler.js:3752
(anonymous) @ app-cataclysma.ts:4
Show 45 more frames
Show less

Additional Context

I tried making different Properties lazy. Regardless of what I change, the deserialization fails. My next step will be to create a branch where I nuke the entire migration to TypeManager to clean up any oddities with the TypeManager Static Class

I tried looking at the CONTRIBUTING.md file to attempt making a sandboxed area; however, an running into an issue there, related to ports?

If you could explain how to setup a dev area for JsonApiEntityFramework, I could probably work towards a reproducible scenario.

What I tried was:

  1. git clone ...
  2. Opened in VS Code
  3. npm run build
  4. npm run test - tests fail due to port not being accessible
  5. run the launch command in VS Code - fails, error code 1 wasn't able to get much more useful information. Figured I was probably barking up the wrong tree.
dpimonov commented 1 year ago

Thank you very much for detailed description. It is more or less clear now. I will try to reproduce it.

I have a few classes that could be generics

The trick btw which is still not documented that it works with nested generics also. For example:

@Property(Map, [Number, [Map, [String, Boolean]]]) 
public map: Map<number, Map<string, boolean>>;

But it is another story...

Let me know if you want me to close this issue out and open it on that project instead.

Let keep it here. So far it is not really clear from where issue comes from. Normally the method which fails should never go there if config is undefined and this is really weird as all undefined functions must resolve to Unknown by design. But we will see.

My next step will be to create a branch where I nuke the entire migration to TypeManager to clean up any oddities with the TypeManager Static Class

There is a way to use non static instances and different configs in the TypeManager and EntityStore but it is not fully propogated to JsonApiEntityProvider. I still think about clear way to extend api related to the plugins.

If you could explain how to setup a dev area for JsonApiEntityFramework, I could probably work towards a reproducible scenario.

In the JsonApiEntityProvider project there is backend folder with .NetCore project which creates a sandbox. Without it all tests will always fail. You need a standard Visual Studio (not VS Code) for this with related SDK's installed. Everything else including ports is configured. One can simply run it by clicking run button in the visual studio. Also all test cases in JsonApiEntityProvider are connected to this backend and models defined there. So it always works with real data and not mock one. I can provide more details for tricky parts if there will be any.

dpimonov commented 1 year ago

Hmm, interesting. I cannot reproduce it no matter what I do. Providing response directly and creating a sandbox with exact same config does not help either. Can you provide your compilation config? Maybe something wrong with concrete compiled target as there are several in the library.

image

dpimonov commented 1 year ago

Also extracting TypeMetadata directly from User type somewhere right before sending a request might help. There are properties typeMetadata and typeArgument in createdAt property metadata in which I am interested.

DellanX commented 1 year ago

it works with nested generics

Wow, that's awesome

I'll see if I can figure out sandboxing when I get some more time. I tried to grab the data you asked for by adding console.log(TypeManager.extractTypeMetadata(User)); before the request on my vue page.

Data Collection

For reference, here is the UpdatedAt property:

image

Here is the CreatedAt property:

image

As for compilation, it's a bit complicated. I am using Vite to build all of the assets. Do let me know if I need to build your assets in any special way.

DellanX commented 1 year ago

Looks like the build targets are:

['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14']

https://vitejs.dev/config/build-options.html#build-target

I am testing on Edge right now, so probably the es2020 or edge88 target on my side. Testing on Chrome also resulted in the same error

image

I tried to figure out which specific build target I am using from this package; however, am struggling to extract that information. (I also noticed that the .map.js files point to the /src directory, which isn't available to me due to me installing the release artifact, which only has the /dist directory)

image

I am going to see if I can force it to recompile with the ES2015 build target, and have added in a few debug flags to attempt to get more information.

DellanX commented 1 year ago

Looks like it is the ES5 build target I am consuming from you.

image
dpimonov commented 1 year ago
I tried to grab the data you asked for by adding console.log(TypeManager.extractTypeMetadata(User)); before the request on my vue page.

Sorry, I have to be more precise. I require info from propertyMetadataMap->createdAt->[typeArgument + typeMetadata]. Passed options transformed into concrete classes and this classes used during serialization and deserialization. I want to see if typeArgument represents an arrow function and what typeMetadata tries to resolve. Btw arrow function is not only way to configure circular reference handling. You can use type aliases (if suitable) while we try to find a root cause.

import { Type, Property } from '@dipscope/type-manager';

@Type({
    alias: 'UserStatus'
})
export class UserStatus
{
    @Property(String) public title: string;
}

@Type()
export class User
{
    @Property('UserStatus') public userStatus: UserStatus;
}

Thanks for the build target info. My guess right now that due to different versions of JS - function which is used to define lazy resolver returns false in your case. Thats why it falls back to the default type. Here is code which sets resolver when decorators are used.

type TypeArgument<TType> = Alias | TypeFn<TType> | TypeResolver<TType> | undefined;

public defineTypeMetadataResolver(typeArgument: TypeArgument<any>): TypeMetadataResolver<any>
{
    if (isNil(typeArgument))
    {
        return this.resolveTypeMetadataUsingUnknownTypeFn.bind(this);
    }

    if (isString(typeArgument))
    {
        return this.resolveTypeMetadataUsingAlias.bind(this);
    }

    if (isArrowFunction(typeArgument))
    {
        return this.resolveTypeMetadataUsingTypeResolver.bind(this);
    }

    return this.resolveTypeMetadataUsingTypeFn.bind(this);
}

As you can see if you pass nothing - it uses Unknown resolver. If you pass an alias - then Alias resolver. If arrow function - then lazy type resolver. For some reason there is something wrong in your case and maybe something missing in arrow function check for certain build targets.

dpimonov commented 1 year ago

Yep, with your help I was able to reproduce an issue with ES5 build so I am going to debug it. Thanks!

dpimonov commented 1 year ago

So, the fix is included in v7.0.1. Thanks again for helpful details. Let me know if the fix works for you so we can close an issue.

DellanX commented 1 year ago

Yay! I am experimenting with it now and am no longer able to reproduce the issue!

Thank you very much for patching this so quickly! Your framework has such an elegant way to declare the schema for data models, that I am looking forward to finishing switching over! 😊 (Previously I was using 3 different frameworks together to achieve what this does, with me ad-hoc doing data normalization, was not fun)

dpimonov commented 1 year ago

I am glad that you like it! If you will find more issues or have some improvements in mind - feel free to open an issue. 😊