prisma / prisma

Next-generation ORM for Node.js & TypeScript | PostgreSQL, MySQL, MariaDB, SQL Server, SQLite, MongoDB and CockroachDB

Home Page:https://www.prisma.io

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Option brand the model name into data

jasonkuhrt opened this issue · comments

Since creating this issue there are updates below:


Problem

Prisma Client does not introduce data classes/model classes, instead just returning/working with simple native JS data structures (plain objects, arrays, etc.).

This is a problem when the identity of data is needed. There are lots of cases where identity is needed. Here are two cases I am currently working with:

  1. While implementing an Oso policy where we want to pattern match on a specific kind of resource.

  2. While implementing polymorphism in GraphQL we want to use the Discriminant Model Field (DMF) Strategy.

Suggested solution

Ideally something as simple as this:

const prisma = new PrismaClient({
  brandTypes: true | { fieldName: string, case: "lower" | "upper" | "pascal" }
})

I don't see how we can enable this by default without being backwards incompatible because there are no namespace guarantees. So alas I guess this would default to false.

#5315 (comment)

We can use $type (or $kind) knowing that it will not break user code (except maybe in a very esoteric user case that we can accept breaking).

I think having this builtin and enabled by default makes sense for Prisma Client because model identity is a fundamental issue and it is currently impossible without resorting to duck-typing and offers no integration with TS discriminant union types.

When enabled via true the default field used could be something like kind or $kind or type or $type.

Users would be able to overwrite this default with additional configuration, passing their desired field name (the second union member above).

An additional option may be the casing of the string. E.g. let the user decide between these:

user.kind === 'user'
user.kind === 'USER'
user.kind === 'User'

The problem I see with this is that it won't show up in the static typing. That is, with this setting enabled, the following should be statically safe:

const user = prisma.user.findUnique(...)

const a: 'user' = user.kind

const org = prisma.org.findUnique(...)  // nested relation example

const b: 'org' = org.kind

const c: 'user'[] = org.members.map(user => user.kind)

// and so on

This is key because it is the only way to leverage TS discriminant union types.

The only way I can see Prisma Client being able to achieve this (without potentially major complexity via runtime reflection to generate typegen like Nexus) is for Prisma to add some new configuration at the generator level.

generator client {
  provider = "prisma-client-js"
  brandTypes = true
}
generator client {
  provider = "prisma-client-js"
  brandTypes {
    fieldName = "kind"
    case = "lower" | "upper" | "pascal"
  }
}

Alternatives

It is currently possible I think to solve this by putting a field on every model that will simply be a constant of the model kind:

enum UserKind {
  user
}

model User {
  kind          UserKind    @default(user)
}

This is suboptimal because:

  1. Application level concern mixed with database level
  2. A constant is wasting space in the database, repeated for every row, the same value
  3. Error prone if that field is ever accidentally set by some operation
  4. Developer needs to remember to always select the kind field on any query.
  5. Related to above point: Any automation/abstract might not and lead to integration issues.

I considered using middleware but this did not seem to work because:

  1. Monkey patching kind fields would not be reflected in the TS types... (but maybe I can leverage TS interface declaration merging? But actually no, not easily, because Prisma model types are not globals. So I would need to create a new module of model types anyways)

  2. When there are nested relations I would need to traverse them and brand them too, which AFASICT is not possible because the model names of nested relations is not available in the middleware (nor is it clear anyways how it would be in a way that I could map to data during the traversal process...).

Additional context

From slack, question/answer:

Is it JavaScript specific? I was thinking that typeof in TypeScript could give the type name but I never checked

This feature would be useful for any language where a plain structure is used that is NOT an instance of a class. So Go would NOT need this because structs are instances whereas in Python this WOULD be useful because dict is like plain object in JS in that you can't trace a dict back to some class/template.

typeof won't work because it will just say that all these things are object. instanceof checks won't work because we don't use classes in the first place for our models.

By the way I'm not advocating that we should use classes 🙂 I think the simple data approach is great. Also, once/if branding lands it will have arguably easier identity usage than class instances with instanceof.

Why?

Because thanks to TS static typing + Prisma generation will allow us to track .kind as not just a string type but a string constant e.g. "user" "comment" etc. In turn this will allow us to leverage TS discriminant union type feature! If I have a type like: User | Comment | Post it will be possible to narrow the types (and leverage TS exhaustive checking) but doing .kind === '...' checks.

image

I just realized that $ prefix mode fields are invalid. Thus I revise my previous suggestion:

I don't see how we can enable this by default without being backwards incompatible because there are no namespace guarantees. So alas I guess this would default to false.

We can use $type (or $kind) knowing that it will not break user code (except maybe in a very esoteric user case that we can accept breaking).

I think having this builtin and enabled by default makes sense for Prisma Client because model identity is a fundamental issue and it is currently impossible without resorting to duck-typing and offers no integration with TS discriminant union types.

Since opening this, a few more thoughts:

  • Prisma fields cannot start with _ giving us another way to namespace special fields

  • I would love if we could default to __typename to matchthe GrpahQL spec because 1) why not, it is an ok name (?) and 2) it would make Prisma Client work out-of-the-box for implementing abstract types in any Node GraphQL implementation (so long as based on graphql package, which all are... :D).

  • I think being able to customize generator to change the __typename field would make fine sense, just nothing something we have for our use-case on cloud right now.

Have a look at #2505 (comment)
This should also be suitable for defining alternatives that include the model name generically.

I use the following on the client to generically lift db calls over the network.
You probably just need to adjust dbm and lift.

type model = Uncapitalize<keyof typeof Prisma.ModelName>;
const models = Object.keys(prisma.Prisma.ModelName).map(s => s[0].toLowerCase() + s.slice(1) as model);
type action = Exclude<Prisma.PrismaAction, 'createMany' | 'executeRaw' | 'queryRaw'>; // why are these not defined on PrismaClient[model]?
const actions = ['findMany', 'create', 'update', 'delete', 'findUnique', 'findFirst', 'updateMany', 'upsert', 'deleteMany', 'aggregate', 'count'] as const; // these are the actions defined on each model. TODO get from prisma? PrismaAction is just a type.

type dbm<M extends model> = Pick<PrismaClient[M], action>;

// dbm('model').action runs db.model.action on the server
const dbm = <M extends model> (model: M) : dbm<M> => {
  const lift = <A extends action> (action: A) => ((args: {}) => rest('POST', `/db/${model}/${action}`, args)) as PrismaClient[M][A];
  return Object.fromEntries(actions.map(s => [s, lift(s)]));
};
export const db = Object.fromEntries(models.map(s => [s, dbm(s)])) as { [M in model]: dbm<M> };
// db.foo.findMany().then(console.log);

We find the extra table column to be the simplest solution right now.

Just for the record:

I played with the idea of using middlewares to manipulate the data returned by queries (add the model name as a property) and the data written to the database (remove it again) - add __model and remove it again. That was very simple on the first level where the Model is available as param of the query even, but becomes a lot harder for nested objects. I am not sure if could even be done for all layers (although for many via include and select and knowing the Prisma schema we should be able to get the information what model we are dealing with).

Code that no one should use and does not work for this problem anyway
prisma.$use(async (params, next) => {
  // remove __model before handing over to database
  // TODO also remove in deeper input objects
  if(params.args.data?.__model) {
    delete params.args.data.__model
  }

  // execute actual query
  let result = await next(params);

  // add __model before returning to application
  // TODO also apply to deeper returned objects
  if (Array.isArray(result)) {
    result.forEach(el => {
      el.__model = params.model
    });
  } else {
    result.__model = params.model
  }

  return result;
})

Bigger problem are of course the Typescript types: A __model property will not match the original User type - it does not exist there. Again, I can mess with the type of the object on runtime, but that scales even worse to nested objects with other types, and also codebase wide. You can't really expect a developer to do that after each query.

Code that no one should use and does not work for this problem anyway
  let user1 = await prisma.user.create({
    data: {
      email: 'alice@prisma.io',
      name: 'Alice',
      posts: {
        create: {
          title: 'Watch the talks from Prisma Day 2019',
          content: 'https://www.prisma.io/blog/z11sg6ipb3i1/',
          published: true,
        },
      },
    },
    include: {
      posts: true,
    },
  })

  // copy paste from StackOverflow - no idea what this breaks!
  let user_modified = user1 as typeof user1 & { [key: string]: string }; 
  console.log(user_modified.__model)

So summary: I wasted some time, learned something about middlewares, but this most probably does not help to solve this problem here in any way.

I need similar functionality in CASL. Usually, there is some property (e.g., __typename that describes object type) or class instance has reference to its constructor. CASL needs this information to be available on an object/resource it checks in order to detect its type, to understand what permission rules to apply to that object.

I also tried middleware approach which is kind of good for top level but as I've already understood it does not work on nested models.

I see only 3 ways on how this information can be added to objects:

  1. Manually go through an object graph and add this information using some details from provided PrismaQuery. For partial responses, we could add additional meta property that data is not full

    PROS:

    • relatively easy to write recursive function that will walk over graph and add types everywhere (dmmf should help here)

    CONS:

    • will influence performance, however if Prisma walks over that graph and manually builds it could go into that logic
  2. If you control driver level (or one you use supports customization), this could be added on driver row parsing level.

    PROS:

    • very unlikely to influence performance

    CONS:

    • leaking abstractions? some DMMF/meta information should be passed from client level to driver level
  3. Add a special property to SQL SELECT statement, so database return it as part of the response:

    PROS:

    • everything is delegated to db, the only part for custom code is to generate correct SQL. Example:
      SELECT 'User' as "__typename" ...
    • mimics extra table column solution but without actually adding that column

    CONS:

    • may be tricky to generate for joins (if Prisma uses them) but if all queries is sent as separate queries then it should be the easiest option.

Any updates?

This is an old issue, my current position/suggestion.

  1. __typename field on all Prisma models at all times by default. Prisma fields cannot start with _ so __typename will NEVER conflict with a userland property name.

  2. Support configuration within the Prisma Client class constructor.

      /**
    		* @example
    		*
    		* const prisma = new PrismaClient({
    		* 	typeBrand: { ... }
    		* })
    		*/
    		type TypeBrandSettings = {
    			/**
    			*
    			* @default "__typename"
    			*
    			* @example
    			*
    			* // default
    			*
    			* const foo = prisma.foo.findUnique({ where: { id }})
    			* foo.__typename // 'Foo'
    			* type __Typename = typeof foo.__typename // 'Foo'
    			*
    			* @example
    			*
    			* // custom
    			*
    			* const prisma = new PrismaClient({
    			* 	typeBrand: { propertyName: '_tag' }
    			* })
    			*
    			* const foo = prisma.foo.findUnique({ where: { id }})
    			*
    			* foo._tag // 'Foo'
    			* type _Tag = typeof foo._tag // 'Foo'
    			*/
    			propertyName?: string
    			/**
    			* @default false
    			*
    			* @example
    			*
    			* // custom
    			*
    			* const prisma = new PrismaClient({
    			* 	typeBrand: { enumerable: true }
    			* })
    			*
    			* const foo = prisma.foo.findUnique({ where: { id }})
    			* Object.entries(foo) // [["__typename", "Foo"], ...]
    			*
    			* @example
    			*
    			* // default
    			*
    			* const foo = prisma.foo.findUnique({ where: { id }})
    			* Object.entries(foo) // [...]
    			*/
    			enumerable?: boolean
    			/**
    			* @default true
    			*/
    			enabled? boolean
    		}

Would be great to have this part of the Prisma spec.

Adding onto this. I am trying to implement authorization with CASL and need to discriminate type. I'm stuck on how to address my problem otherwise.

CASL even mentions in its documentation how unfortunate it is that Prisma does not provide any type information (and it links to this issue): https://casl.js.org/v5/en/package/casl-prisma#note-on-subject-helper

Prisma has been really fantastic otherwise so far, especially with https://prisma.typegraphql.com

I really like the idea proposed by @jasonkuhrt.

commented

Likewise - this is a major pain point - I don't really want to have to have a database field just for my model's type, but it really seems like the only option at the moment.

@jasonkuhrt's solution looks great.

Still keeping an eye on this issue

Is this being considered as part of Prisma's roadmap this year?

Likewise - this is a major pain point - I don't really want to have to have a database field just for my model's type, but it really seems like the only option at the moment.

@jasonkuhrt's solution looks great.

I don't like the idea of putting that logic on the DB layer. I really hope Prisma team comes with a better solution.

Any update on a timeline for working on this? I have to think anyone using Prisma to implement a GraphQL schema using best practices is going to run into this issue. The Node interface pattern from Relay (part of https://relay.dev/graphql/objectidentification.htm) is heavily used even outside the Relay world to identify types in the Graph that are directly fetch-able and allowing clients to re-fetch singular nodes without a proliferation of root fields.

Did client extensions fix this issue by any chance?

@sepehr500 IIRC from some recent discussion it potentially could/will. But until someone builds a working POC that others here can test against their use-cases we should maintain healthy skepticism about if this issue is fully resolved or not by it.

Unfortunately in the meantime we've moved away from prisma so I can't comment on whether client extensions have resolved the issue. Good luck for those still waiting!

@rosscooperman What did you end up moving to?

@rosscooperman What did you end up moving to?

We shifted our whole stack toward Rails w/ Gusto's Apollo federation library.

I've somehow worked around this using the new client extensions. Fair warning: It's still a very suboptimal solution since it requires feeding the model names into it manually to build the extension (since $allModels doesn't give any useful information either about the model name) and doesn't follow much DRY. Currently looks like this:

const buildTypenameExtension = <T extends string>(model: T) => ({
  __typename: {
    needs: {},
    compute: () => model,
  },
});
const xprisma = prisma.$extends({
  name: 'add-model-name',
  result: {
    user: buildTypenameExtension('User'),
    post: buildTypenameExtension('Post'),
    // and so on...
  },
});

I've also tried something a little cleaner using Object.fromEntries(), which didn't quite work however as it broke the TypeScript types of the models (due to the nature of fromEntries():

Doesn't work, do not use
function camelize(str) {
  return str
    .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
      return index === 0 ? word.toLowerCase() : word.toUpperCase();
    })
    .replace(/\s+/g, '');
}

const xprisma = prisma.$extends({
  name: 'add-model-name',
  result: Object.fromEntries(
    ['User', 'Post', '...'].map((model) => [
      camelize(model),
      {
        __typename: {
          needs: {},
          compute: () => model,
        },
      },
    ])
  ),
});

const user = await xprisma.user.findFirstOrThrow();
console.log(user.__typename, user.id); // works, but TypeScript doesn't see those fields

This extension will create a property __typename on the specified models. It even works for nested models:

const user = await xprisma.user.findFirstOrThrow({
  include: { posts: true },
});
console.log(user.posts[0].__typename); // Post

In my case, I'm using it for CASL and feed this __typename property into a custom subject type detection. Works great so far.

If anyone has an better idea to make this a little cleaner or even completely eliminate the need to explicitly specify all the models, that'd be great. If Prisma did this on its own would be even better though of course, so +1.

commented

Hey everyone 👋, here's some good news: I created a Prisma Client extension that adds branded types to your result types at runtime and on type-level, and it obviously handles nested data. In order to use this Prisma Client extension, you'll need to hook it in. So for example, in this sample project, this is how it would look:

import { PrismaClient, Prisma } from "@prisma/client"

async function main() {
    const prisma = new PrismaClient().$extends(brandExtension)

    const result = await prisma.user.findFirst({ include: { accounts: true } })

    console.log(result?.$kind) // user
    console.log(result?.accounts[0].$kind) // account
}

Here's the extension code for you to copy somewhere into your project:

import { PrismaClient, Prisma } from "@prisma/client"

const brandExtension = Prisma.defineExtension((client) => {
    type ModelKey = Exclude<keyof typeof client, `$${string}` | symbol>
    type Result = { [K in ModelKey]: { $kind: { needs: {}, compute: () => K } } }

    const result = {} as Result
    const modelKeys = Object.keys(client).filter((key) => !key.startsWith("$")) as ModelKey[]
    modelKeys.forEach((k) => { result[k] = { $kind: { needs: {}, compute: () => k as any } } })

    return client.$extends({ result })
})

ℹ️ Prisma Client extensions are currently in preview, so feel free to give feedback. Also let us know if this extension worked for you, and feel free to improve it (or ask us to improve it). In the mean time, I hope it helps.

Hi @millsp, thanks for that.

I'm trying to use your solution together with jest-mock-extended, but I've gotten into an endless typing issues.

Can you provide something that would help me to use this solution together with jest-mock-extended?

commented

Hey @boemekeld, I'd be happy to help you. Could you please open a github discussion or contact me via our public slack with all the details (so that we can move this conversation out of this issue). Thanks!

Hey everyone 👋, here's some good news: I created a Prisma Client extension that adds branded types to your result types at runtime and on type-level, and it obviously handles nested data. In order to use this Prisma Client extension, you'll need to hook it in. So for example, in this sample project, this is how it would look:

import { PrismaClient, Prisma } from "@prisma/client"

async function main() {
    const prisma = new PrismaClient().$extends(brandExtension)

    const result = await prisma.user.findFirst({ include: { accounts: true } })

    console.log(result?.$kind) // user
    console.log(result?.accounts[0].$kind) // account
}

Here's the extension code for you to copy somewhere into your project:

import { PrismaClient, Prisma } from "@prisma/client"

const brandExtension = Prisma.defineExtension((client) => {
    type ModelKey = Exclude<keyof typeof client, `$${string}` | symbol>
    type Result = { [K in ModelKey]: { $kind: { needs: {}, compute: () => K } } }

    const result = {} as Result
    const modelKeys = Object.keys(client).filter((key) => !key.startsWith("$")) as ModelKey[]
    modelKeys.forEach((k) => { result[k] = { $kind: { needs: {}, compute: () => k as any } } })

    return client.$extends({ result })
})

ℹ️ Prisma Client extensions are currently in preview, so feel free to give feedback. Also let us know if this extension worked for you, and feel free to improve it (or ask us to improve it). In the mean time, I hope it helps.

This kinda works, I guess, but I would prefer to use the model name - User, not user. Couldn't find a way to do it, currently

Hey everyone 👋, here's some good news: I created a Prisma Client extension that adds branded types to your result types at runtime and on type-level, and it obviously handles nested data. In order to use this Prisma Client extension, you'll need to hook it in. So for example, in this sample project, this is how it would look:

import { PrismaClient, Prisma } from "@prisma/client"

async function main() {
    const prisma = new PrismaClient().$extends(brandExtension)

    const result = await prisma.user.findFirst({ include: { accounts: true } })

    console.log(result?.$kind) // user
    console.log(result?.accounts[0].$kind) // account
}

Here's the extension code for you to copy somewhere into your project:

import { PrismaClient, Prisma } from "@prisma/client"

const brandExtension = Prisma.defineExtension((client) => {
    type ModelKey = Exclude<keyof typeof client, `$${string}` | symbol>
    type Result = { [K in ModelKey]: { $kind: { needs: {}, compute: () => K } } }

    const result = {} as Result
    const modelKeys = Object.keys(client).filter((key) => !key.startsWith("$")) as ModelKey[]
    modelKeys.forEach((k) => { result[k] = { $kind: { needs: {}, compute: () => k as any } } })

    return client.$extends({ result })
})

ℹ️ Prisma Client extensions are currently in preview, so feel free to give feedback. Also let us know if this extension worked for you, and feel free to improve it (or ask us to improve it). In the mean time, I hope it helps.

This kinda works, I guess, but I would prefer to use the model name - User, not user. Couldn't find a way to do it, currently

Using TypeScript's Capitalize, you can capitalize the first letter and preserve type-safety:

const brandExtension = Prisma.defineExtension((client) => {
  type ModelKey = Exclude<keyof typeof client, `$${string}` | symbol>;
  type Result = {
    [K in ModelKey]: {
      $kind: {
        needs: Record<string, never>;
        compute: () => Capitalize<K>;
      };
    };
  };

  const result = {} as Result;
  const modelKeys = Object.keys(client).filter((key) => !key.startsWith('$')) as ModelKey[];
  modelKeys.forEach((k) => {
    const capK = k.charAt(0).toUpperCase() + k.slice(1);
    result[k] = {
      $kind: { needs: {}, compute: () => capK as any },
    };
  });

  return client.$extends({ result });
});

Since 4.16.0 2023-06-20, Prisma Extensions are Generally Available.

In my opinion (my Casl uses), this problem resolved.

Problems:

  • 1. Runtime/Type __typename at client response (from @mfkrause and @millsp comments solves)
  • 2. 🧵⬇️ How to extract types from extended client, once the generator dont generate it?

Solution 2.1: An utility type that load the type from the extended prisma client.

// ./prisma/extendedModelTypes.ts
import { prisma } from "@/db"

type Prisma = typeof prisma
type PrismaKeys = keyof Prisma
type ExtractStrings<T> = T extends string ? T : never;
type ExtractModelKeys<T extends string | symbol> = T extends `$${string}`
 ? never
 : T;

type StringsKeys = ExtractStrings<PrismaKeys>
type ModelKeys = ExtractModelKeys<StringsKeys>

type CapitalizeKeys<T> = {
 [K in keyof T as `${Capitalize<string & K>}`]: T[K]
};

type PrismaModelsUncapitalized = { [T in ModelKeys]: Awaited<ReturnType<typeof prisma[T]['findUniqueOrThrow']>> };

export type PrismaModels = CapitalizeKeys<PrismaModelsUncapitalized>
// src/common/authorization/abilities.ts
import { PureAbility } from '@casl/ability';
import { PrismaQuery } from '@casl/prisma';
import { PrismaModels } from '@/db/extendedModelTypes.ts';

export type AppAbilities =
  | ["create" | "read" | "update" | "delete" | "publish", "Article" | PrismaModels['Article']]
  | ["create" | "read" | "update" | "delete" | "publish", "Post" | PrismaModels['Post']]
  | ["create" | "read" | "update" | "delete" | "publish", "User" | PrismaModels['User']]
  | ["read" | "update" | "delete" | "publish", "Comment" | PrismaModels['Comment']]
  | ["read" | "update" | "delete" | "transfer_ownership", "Team" | PrismaModels['Team']]
  | ["create", "Comment"]
  | ["manage", "all"];

export type AppAbility = PureAbility<AppAbilities, PrismaQuery>;

Check the types for your own

Paste that code in prisma playground here

Solution 2.2: A custom generator that load prisma dmmf, read models and write a file like this:

// ./prisma/extendedModelTypes.ts
import { prisma } from "../../../prisma/db"

export type User = Awaited<ReturnType<typeof prisma.user['findUniqueOrThrow']>>
export type Team = Awaited<ReturnType<typeof prisma.team['findUniqueOrThrow']>>
export type TeamMember = Awaited<ReturnType<typeof prisma.teamMember['findUniqueOrThrow']>>
export type Post = Awaited<ReturnType<typeof prisma.post['findUniqueOrThrow']>>
export type Article = Awaited<ReturnType<typeof prisma.article['findUniqueOrThrow']>>
export type Comment = Awaited<ReturnType<typeof prisma.comment['findUniqueOrThrow']>>

I've written a generator for Pothos+Prisma. And there you can run a script "afterGenerate" over dmmf.

See code
afterGenerate: (dmmf) => {
  fs.writeFile(
    path.join(__dirname, `../common/authorization/prismaExtesionTypes.ts`),
    `import { prisma } from "../../../prisma/db"

${dmmf.datamodel.models.map(el => `export type ${el.name} = Awaited<ReturnType<typeof prisma.${firstToLower(el.name)}['findUniqueOrThrow']>>`).join('\n')}
`,
    {},
    (err) => {
      console.log({ err });
    }
  );
}

2.3 Suggested "perfect" solution:

A native option to extract types from extended client, maybe something like Pothos does here.

@millsp @mfkrause I want to thank you both for making a solution here. I still had to manually make object types for my app to use, but this make it possible!

eg

import * as PrismaNamespace from '@prisma/client'
import { Prisma, PrismaClient } from '@prisma/client'

// brandExtension def here

export const prisma = new PrismaClient().$extends(brandExtension)

export type Prisma = typeof prisma
export type User = PrismaNamespace.User & { type: 'User' }
export type Team = PrismaNamespace.Team & { type: 'Team' }
export type TeamMembership = PrismaNamespace.TeamMembership & { type: 'TeamMembership' }