mswjs / data

Data modeling and relation library for testing JavaScript applications.
https://npm.im/@mswjs/data
MIT License
773 stars 51 forks source link

Support model type generic for "factory" #36

Open kettanaito opened 3 years ago

kettanaito commented 3 years ago

It should be possible to approach data modeling type-first by reusing existing TypeScript interfaces to annotate the data. This is useful when there are types generated from the server (i.e. with GraphQL Codegen).

// generated-types.ts
export interface User {
  id: string
  firstName: string
  age: number
}
import { factory, primaryKey } from '@mswjs/data'
import { User } from './generated-types'

interface ModelTypes {
  user: User
}

const db = facotory<ModelTypes>({
  // ERROR: "user" is missing the "firstName" property.
  user: {
    id: primaryKey(String),
    age: String, // ERROR, "age" must be number.
  }
})
kettanaito commented 3 years ago

Note that factory already supports the ModelDictionary generic. This task may be as much as exporting the Limit utility type publicly, so it's possible to construct your own custom types with the built-in relations/internal typings:

import { Limit } from '@mswjs/data'

type MyDictionary = Limit<{
  user: { id: string }
}>

When publicly exported, Limit stops being a good naming choice. We should name it in a less general way.

marcosvega91 commented 3 years ago

Could we rename Limit to ModelDictionary? like

diff --git a/src/glossary.ts b/src/glossary.ts
index fa730e0..a9cb2ef 100644
--- a/src/glossary.ts
+++ b/src/glossary.ts
@@ -54,9 +54,9 @@ export type EntityInstance<
 > = InternalEntityProperties<ModelName> &
   Value<Dictionary[ModelName], Dictionary>

-export type ModelDictionary = Limit<Record<string, Record<string, any>>>
-
-export type Limit<T extends Record<string, any>> = {
+export type ModelDictionary<
+  T extends Record<string, any> = Record<string, Record<string, any>>
+> = {
   [RK in keyof T]: {
     [SK in keyof T[RK]]: T[RK][SK] extends
       | (() => BaseTypes)
kettanaito commented 3 years ago

@marcosvega91, hm, I suppose we can. What benefit do you see in renaming it?

jgoux commented 3 years ago

I was coming to talk exactly about this workflow. 😄

At my company we're using codegen heavily to generate :

Our main goal now for frontend testing would be to leverage all this existing codegen and being able to easily map the existing types to factories. And I think I'd go a step further and also make a codegen utility to generate factories out of the types. But passing the generic is a great first step!

As a quick win, could you already expose the Limit type?

cloud-walker commented 3 years ago

We don't currently generate the types already, but I personally maintain a set of entity related types, so yes, it would be nice to be able to communicate these types to factory.

jogelin commented 2 years ago

Note that factory already supports the ModelDictionary generic. This task may be as much as exporting the Limit utility type publicly, so it's possible to construct your own custom types with the built-in relations/internal typings:

import { Limit } from '@mswjs/data'

type MyDictionary = Limit<{
  user: { id: string }
}>

When publicly exported, Limit stops being a good naming choice. We should name it in a less general way.

@kettanaito is it still valid?

I am tried something like:

type UserDictionary = Limit<{
  user: {
    id: string;
    name: string;
  }
}>;

const userMockFactory = factory<UserDictionary>({
    user: {
      id: primaryKey(datatype.uuid),
      name: () => 'name'
    }
  });

but I have:

TS2739: Type 'PrimaryKeyDeclaration<string>' is missing the following properties from type '{ error: "expected a value or a relation"; oneOf: "user"; }': error, oneOf

To me, ModelDictionary isn't usable because we cannot provide any generic type...

How can I provide the interface that I would like to mock?

kettanaito commented 2 years ago

Hey @jogelin. I'd advise against using those internal types to annotate your structures at the moment. We need to design a much better typing experience for manual types, and specifically for the data structure generic support in the factory. You shouldn't be using Limit and friends, just the shape of your data:

// Once again, an intention, not the current implementation
interface User {
  id: string
  firstName: string
}

const db = factory<{ user: User }>({ user: {...} })

We are welcoming contributors so this may be a great time to consider becoming one!

jpribyl commented 2 years ago

From what I can see, the ManyOf and OneOf relationships make this a bit difficult to infer. I think that we will wind up needing partial type argument inference ( https://github.com/microsoft/TypeScript/issues/26242 ) to make the syntax much nicer than this:

// https://github.com/type-challenges/type-challenges/issues/2990
type IsRequiredKey<T, K extends keyof T> = (
  K extends keyof T ? (T extends Required<Pick<T, K>> ? true : false) : never
) extends true
  ? true
  : false;

type GetNullableProperty<T> = T extends ModelValueType
  ? NullableProperty<T>
  : () => T;

type Limit<Schema, Definition> = {
  [Key in keyof Definition]: Definition[Key] extends
    | PrimaryKey<any>
    | ManyOf<any, boolean>
    | OneOf<any, boolean>
    | NullableProperty<any>
    ? Definition[Key]
    : Key extends keyof Schema
    ? IsRequiredKey<Schema, Key> extends true
      ? () => Schema[Key]
      : GetNullableProperty<Schema[Key]> // schema should be source of truth?
    : never;
};

type FactoryAPIFromSchema<
  Schema extends Record<string, any>,
  Dictionary extends ModelDictionary = ModelDictionary,
> = FactoryAPI<{
  [ModelName in keyof Dictionary]: Limit<
    ModelName extends keyof Schema ? Schema[ModelName] : never,
    Dictionary[ModelName]
  >;
}>;

// Usage requires dictionary to be defined prior to inferring relationships
const dict = {
  user: {
    id: primaryKey(Number),
    phone_number: () => '1',
    name: () => 'ferris bueller',
  },
  truancy: {
    id: primaryKey(String),
    user: oneOf('user'),
  },
};

export const db: FactoryAPIFromSchema<
  { user: Schemas['User'], truancy: Schemas['Truancy'] },
  typeof dict // we need to be able to infer this, without inferring the schema
> = factory(dict);

In the mean time, I guess this can be a workaround. Or maybe someone else can figure out how to use currying in order to simulate partial type inference. I've heard it can be done but not quite sure if it would work here or not

MatejBransky commented 2 years ago

@jpribyl I can confirm it works! Thank you! 🙂

I just don't know how to solve an enum. 🙁 ..Can you help me, please?

enum OperationStatus {
  Running = 'RUNNING',
  Finished = 'FINISHED',
}

type LoadOperation = {
  id: string
  status: OperationStatus
}

const dict = {
  task: {
    id: primaryKey(String),
    status: OperationStatus, // <=
    startedAt: nullable(String),
    finishedAt: nullable(String),
  },
}

export const db: FactoryAPIFromSchema<
  { task: OperationTask },
  typeof dict // <= error
> = factory(dict)

Error:

Type '{ loadOperation: { id: PrimaryKey<string>; name: StringConstructor; description: NullableProperty<string>; lastSucceededAt: NullableProperty<string>; avgDuration: NullableProperty<...>; runningTasks: ManyOf<...>; }; operationTask: { ...; }; }' does not satisfy the constraint 'ModelDictionary'.
  Property 'operationTask' is incompatible with index signature.
    Type '{ id: PrimaryKey<string>; operationId: OneOf<"loadOperation", false>; status: typeof OperationStatus; transactionId: NullableProperty<string>; startedAt: NullableProperty<...>; finishedAt: NullableProperty<...>; duration: NullableProperty<...>; }' is not assignable to type 'Limit<ModelDefinition>'.
      Property 'status' is incompatible with index signature.
        Type 'typeof OperationStatus' is not assignable to type 'ModelDefinitionValue'.
          Type 'typeof OperationStatus' is not assignable to type 'NestedModelDefinition'.
            Property 'Aborted' is incompatible with index signature.
              Type 'import("/home/brany/projects/one-metadata-frontend/apps/mdm-admin-frontend/src/generated/graphql").OperationStatus' is not assignable to type 'ManyOf<any, boolean> | OneOf<any, boolean> | NullableProperty<any> | ModelValueTypeGetter | NestedModelDefinition'.
strozw commented 2 years ago

This is my current compromise.

import { factory, primaryKey } from 'mswjs/data'
import { ModelDefinitionValue } from '@mswjs/data/lib/glossary'

export function defineModel<T>(
  generator: () => { [key in keyof T]: ModelDefinitionValue }
) {
  return generator()
}

type User {
  id: number
  name: string
}

// All keys of `User` type need to be defined here
const user = defineModel<User>(() => ({
  id: primaryKey(Number),
  name: String
}))

export const db = factory({
  user
})

This helper cannot strictly type check up to the value type of each property, but it can eliminate the omission of definition of each property.

I hope it helps.

alexis-regnaud commented 2 years ago

Do we have any update about that one ? I tried the upper solutions but the user created by user.create() give a user where all fields have ModelDefinitionValue type, which cause an issue when we want to pass it to generic Graphql implementation :

export function defineModel<T>(
  generator: () => { [key in keyof T]: ModelDefinitionValue }
) {
  return generator()
}

type User {
  id: number
  name: string
}

// All keys of `User` type need to be defined here
const user = defineModel<User>(() => ({
  id: primaryKey(Number),
  name: String
}))

export const db = factory({
  user
})

const user1 = user.create();

//User !== typeof user1

It would be nice if the generate object would have the exact same type that User. Having a similar approach that test-data-bot (https://github.com/jackfranklin/test-data-bot#typescript-support)

interface User {
  id: number;
  name: string;
}

const userBuilder = build<User>('User', {
  fields: {
    id: sequence(),
    name: fake(f => f.name.findName()),
  },
});

const user1 = userBuilder();

//User === typeof user1
Shaglock commented 1 year ago

I just don't know how to solve an enum. 🙁 ..Can you help me, please?

Hello @MatejBransky did you manage to solve this somehow? I have the same problem

MatejBransky commented 1 year ago

Unfortunately, I didn't solve it. We ended up doing without @mswjs/data and making our own db with lodash/chain.

Shaglock commented 1 year ago

Unfortunately, I didn't solve it. We ended up doing without @mswjs/data and making our own db with lodash/chain.

I think it would be very interesting to know more about your custom solution if thats possible :)