zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
2.07k stars 89 forks source link

on v2.6.0 some fields on concrete model inherited from polymorphic base model disappear #1734

Closed tmax22 closed 2 weeks ago

tmax22 commented 2 weeks ago

for example, for the given input zmodel schema:

generator client {
    provider = "prisma-client-js"
    binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

datasource db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
}

abstract model Base {
    id        String   @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt()
}

model ProductStage extends Base {
    stage      String

    stageTable String @default("")
    @@delegate(stageTable)
}

model ClientRequirementsStage extends ProductStage {
    someField String
}

you would get:

//////////////////////////////////////////////////////////////////////////////////////////////
// DO NOT MODIFY THIS FILE                                                                  //
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
//////////////////////////////////////////////////////////////////////////////////////////////

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

/// @@delegate(stageTable)
model ProductStage {
  id                                   String                   @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  stage                                String
  stageTable                           String                   @default("")
  delegate_aux_clientRequirementsStage ClientRequirementsStage?
}

model ClientRequirementsStage {
  id                        String       @id() @default(uuid())
  someField                 String
  delegate_aux_productStage ProductStage @relation(fields: [id], references: [id], onDelete: Cascade, onUpdate: Cascade)
}

note that ClientRequirementsStage is missing createdAt and updatedAt. the expected output would be:

//////////////////////////////////////////////////////////////////////////////////////////////
// DO NOT MODIFY THIS FILE                                                                  //
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
//////////////////////////////////////////////////////////////////////////////////////////////

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

/// @@delegate(stageTable)
model ProductStage {
  id                                   String                   @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  stage                                String
  stageTable                           String                   @default("")
  delegate_aux_clientRequirementsStage ClientRequirementsStage?
}

model ClientRequirementsStage {
  id                        String       @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  someField                 String
  delegate_aux_productStage ProductStage @relation(fields: [id], references: [id], onDelete: Cascade, onUpdate: Cascade)
}
svetch commented 2 weeks ago

Hi!

A similar issue happend with me. I'm not sure that these fields should be included in a concrete model at database level, but this causing an error.

If a concrete model field access policy has a relation condition, Prisma throws an error:

  console.log
    prisma:error
    Invalid `prisma.profile.findFirst()` invocation:

    {
      where: {
        id: "cm1f40s6t0000dofw8o2j6ozw"
      },
      include: {
        delegate_aux_organization: {
          where: {
            AND: []
          },
          select: {
            id: true,
            createdAt: true,
            ~~~~~~~~~
            updatedAt: true,
            displayName: true,
            type: true,
            ownerId: true,
            published: true,
            access: {
              select: {
                user: {
                  select: {
                    id: true
                  }
                }
              }
            },
    ?       owner?: true,
    ?       delegate_aux_profile?: true,
    ?       _count?: true
          }
        },
        delegate_aux_user: {
          where: {
            AND: []
          }
        }
      }
    }

    Unknown field `createdAt` for select statement on model `Organization`. Available options are marked with ?.

I created a regression test that reproduces the issue:

import { loadSchema } from '@zenstackhq/testtools';
describe('issue new', () => {
    it('regression', async () => {
        const { enhance, enhanceRaw, prisma } = await loadSchema(
            `
                abstract model Base {
                    id        String   @id @default(cuid())
                    createdAt DateTime @default(now())
                    updatedAt DateTime @updatedAt
                }

                model Profile extends Base {
                    displayName String
                    type        String

                    @@allow('read', true)
                    @@delegate(type)
                }

                model User extends Profile {
                    username     String         @unique
                    access       Access[]
                    organization Organization[]
                }

                model Access extends Base {
                    user           User         @relation(fields: [userId], references: [id])
                    userId         String

                    organization   Organization @relation(fields: [organizationId], references: [id])
                    organizationId String

                    manage         Boolean      @default(false)

                    superadmin     Boolean      @default(false)

                    @@unique([userId,organizationId])
                }

                model Organization extends Profile {
                    owner     User     @relation(fields: [ownerId], references: [id])
                    ownerId   String   @default(auth().id)
                    published Boolean  @default(false) @allow('read', access?[user == auth()]) // <-- this policy is causing the issue
                    access    Access[]
                }

            `,
            {
                logPrismaQuery: true,
            }
        );
        const db = enhance();
        const rootDb = enhanceRaw(prisma, undefined, {
            kinds: ['delegate'],
        });

        const user = await rootDb.user.create({
            data: {
                username: 'test',
                displayName: 'test',
            },
        });

        const organization = await rootDb.organization.create({
            data: {
                displayName: 'test',
                owner: {
                    connect: {
                        id: user.id,
                    },
                },
                access: {
                    create: {
                        user: {
                            connect: {
                                id: user.id,
                            },
                        },
                        manage: true,
                        superadmin: true,
                    },
                },
            },
        });

        const foundUser = await db.profile.findFirst({
            where: {
                id: user.id,
            },
        });

        expect(foundUser).toBeTruthy();
    });
});
ymc9 commented 2 weeks ago

for example, for the given input zmodel schema:

generator client {
    provider = "prisma-client-js"
    binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

datasource db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
}

abstract model Base {
    id        String   @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt()
}

model ProductStage extends Base {
    stage      String

    stageTable String @default("")
    @@delegate(stageTable)
}

model ClientRequirementsStage extends ProductStage {
    someField String
}

you would get:

//////////////////////////////////////////////////////////////////////////////////////////////
// DO NOT MODIFY THIS FILE                                                                  //
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
//////////////////////////////////////////////////////////////////////////////////////////////

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

/// @@delegate(stageTable)
model ProductStage {
  id                                   String                   @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  stage                                String
  stageTable                           String                   @default("")
  delegate_aux_clientRequirementsStage ClientRequirementsStage?
}

model ClientRequirementsStage {
  id                        String       @id() @default(uuid())
  someField                 String
  delegate_aux_productStage ProductStage @relation(fields: [id], references: [id], onDelete: Cascade, onUpdate: Cascade)
}

note that ClientRequirementsStage is missing createdAt and updatedAt. the expected output would be:

//////////////////////////////////////////////////////////////////////////////////////////////
// DO NOT MODIFY THIS FILE                                                                  //
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
//////////////////////////////////////////////////////////////////////////////////////////////

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-3.0.x"]
}

/// @@delegate(stageTable)
model ProductStage {
  id                                   String                   @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  stage                                String
  stageTable                           String                   @default("")
  delegate_aux_clientRequirementsStage ClientRequirementsStage?
}

model ClientRequirementsStage {
  id                        String       @id() @default(uuid())
  createdAt                            DateTime                 @default(now())
  updatedAt                            DateTime                 @updatedAt()
  someField                 String
  delegate_aux_productStage ProductStage @relation(fields: [id], references: [id], onDelete: Cascade, onUpdate: Cascade)
}

Hi @tmax22 ,

The generated Prisma schema looks correct to me. ProductStage inherits the abstract Base so it gets the createdAt and updatedAt fields into its model. However, since ClientRequirementsStage is a polymorphic inheritance, it doesn't copy over the fields from its base, and instead at runtime will fetch them through reading the delegate_aux relation.

Have you run into any runtime issue?

ymc9 commented 2 weeks ago

Hi!

A similar issue happend with me. I'm not sure that these fields should be included in a concrete model at database level, but this causing an error.

If a concrete model field access policy has a relation condition, Prisma throws an error:

  console.log
    prisma:error
    Invalid `prisma.profile.findFirst()` invocation:

    {
      where: {
        id: "cm1f40s6t0000dofw8o2j6ozw"
      },
      include: {
        delegate_aux_organization: {
          where: {
            AND: []
          },
          select: {
            id: true,
            createdAt: true,
            ~~~~~~~~~
            updatedAt: true,
            displayName: true,
            type: true,
            ownerId: true,
            published: true,
            access: {
              select: {
                user: {
                  select: {
                    id: true
                  }
                }
              }
            },
    ?       owner?: true,
    ?       delegate_aux_profile?: true,
    ?       _count?: true
          }
        },
        delegate_aux_user: {
          where: {
            AND: []
          }
        }
      }
    }

    Unknown field `createdAt` for select statement on model `Organization`. Available options are marked with ?.

I created a regression test that reproduces the issue:

import { loadSchema } from '@zenstackhq/testtools';
describe('issue new', () => {
    it('regression', async () => {
        const { enhance, enhanceRaw, prisma } = await loadSchema(
            `
                abstract model Base {
                    id        String   @id @default(cuid())
                    createdAt DateTime @default(now())
                    updatedAt DateTime @updatedAt
                }

                model Profile extends Base {
                    displayName String
                    type        String

                    @@allow('read', true)
                    @@delegate(type)
                }

                model User extends Profile {
                    username     String         @unique
                    access       Access[]
                    organization Organization[]
                }

                model Access extends Base {
                    user           User         @relation(fields: [userId], references: [id])
                    userId         String

                    organization   Organization @relation(fields: [organizationId], references: [id])
                    organizationId String

                    manage         Boolean      @default(false)

                    superadmin     Boolean      @default(false)

                    @@unique([userId,organizationId])
                }

                model Organization extends Profile {
                    owner     User     @relation(fields: [ownerId], references: [id])
                    ownerId   String   @default(auth().id)
                    published Boolean  @default(false) @allow('read', access?[user == auth()]) // <-- this policy is causing the issue
                    access    Access[]
                }

            `,
            {
                logPrismaQuery: true,
            }
        );
        const db = enhance();
        const rootDb = enhanceRaw(prisma, undefined, {
            kinds: ['delegate'],
        });

        const user = await rootDb.user.create({
            data: {
                username: 'test',
                displayName: 'test',
            },
        });

        const organization = await rootDb.organization.create({
            data: {
                displayName: 'test',
                owner: {
                    connect: {
                        id: user.id,
                    },
                },
                access: {
                    create: {
                        user: {
                            connect: {
                                id: user.id,
                            },
                        },
                        manage: true,
                        superadmin: true,
                    },
                },
            },
        });

        const foundUser = await db.profile.findFirst({
            where: {
                id: user.id,
            },
        });

        expect(foundUser).toBeTruthy();
    });
});

Hi @svetch , I appreciate the test case! Looking into it now.

ymc9 commented 2 weeks ago

Hey, could you guys help check if the new 2.6.1 release resolves the issue for you? @svetch @tmax22

svetch commented 2 weeks ago

@ymc9 The new release resolves the issue on my end. Thanks for the quick fix!

ymc9 commented 2 weeks ago

@ymc9 The new release resolves the issue on my end. Thanks for the quick fix!

Awesome. Thanks for the confirmation!

tmax22 commented 2 weeks ago

ProductStage inherits the abstract Base so it gets the createdAt and updatedAt fields into its model. However, since ClientRequirementsStage is a polymorphic inheritance, it doesn't copy over the fields from its base, and instead at runtime will fetch them through reading the delegate_aux relation

actually, it make sense, but it does introduce a breaking change(since v2.2.4 introduces the schema with the polymorphic model fields). it means that i need to write my schema like this:

abstract model Base {
    id        String   @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt()
}

model ProductStage extends Base {
    stage      String

    stageTable String @default("")
    @@delegate(stageTable)
}

model ClientRequirementsStage extends ProductStage,Base {
    someField String
}

(note: ClientRequirementsStage inherents both from ProductStage and Base) is should be documented as well.

however, it does make some issues. in this example, you get Model can include at most one field with @id attribute error:

image

how should i make ClientRequirementsStage inherent both from ProductStage and Base in this case?

ymc9 commented 2 weeks ago

ProductStage inherits the abstract Base so it gets the createdAt and updatedAt fields into its model. However, since ClientRequirementsStage is a polymorphic inheritance, it doesn't copy over the fields from its base, and instead at runtime will fetch them through reading the delegate_aux relation

actually, it make sense, but it does introduce a breaking change(since v2.2.4 introduces the schema with the polymorphic model fields). it means that i need to write my schema like this:

abstract model Base {
    id        String   @id @default(uuid())
    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt()
}

model ProductStage extends Base {
    stage      String

    stageTable String @default("")
    @@delegate(stageTable)
}

model ClientRequirementsStage extends ProductStage,Base {
    someField String
}

(note: ClientRequirementsStage inherents both from ProductStage and Base) is should be documented as well.

however, it does make some issues. in this example, you get Model can include at most one field with @id attribute error:

image

how should i make ClientRequirementsStage inherent both from ProductStage and Base in this case?

For such usage, the Base model is inherited twice, causing a conflict ... Right now it's an unsupported scenario. Please consider creating a separate issue for it. I guess you'll have to avoid inheriting Base from ClientRequirementStage for now.

tmax22 commented 2 weeks ago

actually, after checking, it does not currently pose an issue as long we use zenstack to fetch the data because these 'hidden' fields are really fetched at runtime via the delegate_aux_... relation as you explained.
and again thanks for your awsome work!

j0rdanba1n commented 1 day ago

I'm encountering a similar issue. My zmodels are:

abstract model BaseAuth {
    id             String        @id @default(uuid())
    dateCreated    DateTime      @default(now())
    dateUpdated    DateTime      @updatedAt @default(now())

    organizationId String?
    organization   Organization? @relation(fields: [organizationId], references: [id], name: "organization")

    @@allow('all', organization.users?[user == auth()])
}

enum ResourceType {
    Personnel
}

model Resource extends BaseAuth {
    name     String?
    type     ResourceType?
    costRate Int?

    budgets  ResourceBudget[]

    @@delegate(type)
}

model Personnel extends Resource {
}

I get this error when trying to query a budget object, including resources:

  const {
    data: budget,
    isLoading,
    refetch,
  } = Api.budget.findUnique.useQuery({
    where: { id: budgetId as string },
    include: {
      periods: true,
      resourceBudgets: {
        include: { resource: true },
      },
    },
  })
Invalid `prisma.budget.findUnique()` invocation:

{
  include: {
    periods: {
      where: {
        OR: [
          {
            budget: {
              OR: [
                {
                  organization: {
                    users: {
                      some: {
                        user: {
                          is: {
                            id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                          }
                        }
                      }
                    }
                  }
                }
              ]
            }
          }
        ]
      }
    },
    resourceBudgets: {
      include: {
        resource: {
          where: {
            OR: [
              {
                OR: [
                  {
                    organization: {
                      users: {
                        some: {
                          user: {
                            is: {
                              id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                            }
                          }
                        }
                      }
                    }
                  }
                ]
              }
            ]
          },
          include: {
            delegate_aux_personnel: {
              where: {
                OR: [
                  {
                    OR: [
                      {
                        organization: {
                          users: {
                            some: {
                              user: {
                                is: {
                                  id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                                }
                              }
                            }
                          }
                        }
                      }
                    ]
                  }
                ]
              },
              include: {
                delegate_aux_resource: {}
              }
            }
          }
        }
      },
      where: {
        OR: [
          {
            OR: [
              {
                budget: {
                  OR: [
                    {
                      organization: {
                        users: {
                          some: {
                            user: {
                              is: {
                                id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              },
              {
                resource: {
                  OR: [
                    {
                      organization: {
                        users: {
                          some: {
                            user: {
                              is: {
                                id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                              }
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            ]
          }
        ]
      }
    }
  },
  where: {
    id: "6e278f65-0b19-43f6-9eab-1530795339d5",
    AND: [
      {
        OR: [
          {
            OR: [
              {
                organization: {
                  users: {
                    some: {
                      user: {
                        is: {
                          id: "e2802b95-b51d-4474-a821-8a79172bbae5"
                        }
                      }
                    }
                  }
                }
              }
            ]
          }
        ]
      }
    ],
    organizationId: "aab8670a-f927-494a-90c1-eac9258eae09"
  }
}

I believe the problem is that the policy @@allow('all', organization.users?[user == auth()]) is being applied to delegate_aux_personnel instead of delegate_aux_resource.