diff --git a/.changeset/tame-plums-invite.md b/.changeset/tame-plums-invite.md new file mode 100644 index 00000000..5a2574fe --- /dev/null +++ b/.changeset/tame-plums-invite.md @@ -0,0 +1,5 @@ +--- +'@opensaas/stack-core': patch +--- + +Fix virtual fields named in `include`/`select` throwing a Prisma "Unknown field" error. Virtual field keys are now stripped from the query payload while their value is still computed via `resolveOutput`. diff --git a/packages/core/src/access/access-filter.ts b/packages/core/src/access/access-filter.ts index 5e3f43d3..8f0d8ae3 100644 --- a/packages/core/src/access/access-filter.ts +++ b/packages/core/src/access/access-filter.ts @@ -251,3 +251,57 @@ export function mergeIncludeWithAccessControl( return merged } + +/** + * Remove keys that correspond to `virtual` fields from a Prisma `include` + * object, recursing into nested relationship includes using the related + * list's field configs. + * + * Virtual fields are computed in JavaScript (via `resolveOutput` in + * `field-visibility.ts`) and have no database column. Naming one in `include` + * type-checks — the generated `Include` type lists every field the config + * declares — but Prisma throws `Unknown field '' for include statement` + * at runtime. This is applied as the final step on whatever `include` a read + * op ends up with (fragment-built, access-controlled merge, or sudo + * passthrough) so a virtual key never reaches the Prisma client, regardless + * of which path produced it. The virtual value is still computed + * unconditionally by `filterReadableFields`, so stripping the include key has + * no effect on whether the value appears in the result (#628). + */ +export function stripVirtualFieldsFromInclude( + include: Record | undefined, + fieldConfigs: Record, + config: OpenSaasConfig, +): Record | undefined { + if (!include) return include + + const result: Record = {} + + for (const [key, value] of Object.entries(include)) { + const fieldConfig = fieldConfigs[key] + + // Virtual fields have no database column — drop them from the include. + if (fieldConfig?.virtual) continue + + const isDeclaredRelationship = + fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref + const entry = asEntryObject(value) + + if (isDeclaredRelationship && entry?.include) { + const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config) + if (relatedConfig) { + const nestedInclude = stripVirtualFieldsFromInclude( + entry.include, + relatedConfig.listConfig.fields, + config, + ) + result[key] = { ...(value as Record), include: nestedInclude } + continue + } + } + + result[key] = value + } + + return result +} diff --git a/packages/core/src/access/index.ts b/packages/core/src/access/index.ts index 7e61839d..d3d2956c 100644 --- a/packages/core/src/access/index.ts +++ b/packages/core/src/access/index.ts @@ -22,6 +22,10 @@ export { // Canonical field-level access evaluation (shared by read and write paths). export { checkFieldAccess, filterWritableFields } from './field-access.js' // Phase 1 — Access Filter (pre-query row/relation scoping). -export { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js' +export { + buildIncludeWithAccessControl, + mergeIncludeWithAccessControl, + stripVirtualFieldsFromInclude, +} from './access-filter.js' // Phase 2 — Field Visibility (post-query field stripping + resolveOutput). export { filterReadableFields } from './field-visibility.js' diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index 42a03be2..e6646a4d 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -6,6 +6,7 @@ import { filterReadableFields, buildIncludeWithAccessControl, mergeIncludeWithAccessControl, + stripVirtualFieldsFromInclude, } from '../access/index.js' import { ValidationError, DatabaseError } from '../hooks/index.js' import { getDbKey } from '../lib/case-utils.js' @@ -688,6 +689,13 @@ function createFindUnique( : accessControlledInclude } + // Virtual fields have no database column. Whichever path produced + // `include` (fragment, access-controlled merge, or sudo passthrough), a + // virtual key must never reach Prisma — it would throw "Unknown field" + // (#628). The virtual value is still computed unconditionally below by + // `filterReadableFields`, independent of what was requested here. + include = stripVirtualFieldsFromInclude(include, listConfig.fields, config) + // Execute query with optimized includes // Access Prisma model dynamically - required because model names are generated at runtime // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -811,6 +819,13 @@ function createFindMany( : accessControlledInclude } + // Virtual fields have no database column. Whichever path produced + // `include` (fragment, access-controlled merge, or sudo passthrough), a + // virtual key must never reach Prisma — it would throw "Unknown field" + // (#628). The virtual value is still computed unconditionally below by + // `filterReadableFields`, independent of what was requested here. + include = stripVirtualFieldsFromInclude(include, listConfig.fields, config) + // Execute query with optimized includes // Access Prisma model dynamically - required because model names are generated at runtime // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/tests/context.test.ts b/packages/core/tests/context.test.ts index acf0959d..ea25ca31 100644 --- a/packages/core/tests/context.test.ts +++ b/packages/core/tests/context.test.ts @@ -705,6 +705,112 @@ describe('getContext', () => { // identical `accessControlledInclude === undefined` branch verified above, // so the resolveOutput probe covers all three non-denial cases. }) + + // Regression for #628: a virtual field named in `include` used to be + // forwarded straight through to Prisma, which throws "Unknown field" + // because virtual fields have no database column. The runtime must + // strip virtual keys from the include payload while still computing + // the virtual value (via resolveOutput) and leaving real relationship + // includes — access-controlled or not — untouched. + describe('virtual fields named in include no longer reach Prisma (#628)', () => { + function configWithVirtualDisplayName(): OpenSaasConfig { + return { + ...relConfig, + lists: { + ...relConfig.lists, + Author: { + ...relConfig.lists.Author, + fields: { + ...relConfig.lists.Author.fields, + displayName: virtual({ + type: 'string', + hooks: { + resolveOutput: ({ item }) => `Author: ${item.name}`, + }, + }), + }, + }, + }, + } + } + + it('findMany: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => { + const vConfig = configWithVirtualDisplayName() + relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }]) + + const context = await getContext(vConfig, relPrisma, null) + const result = await context.db.author.findMany({ + include: { displayName: true, posts: true }, + }) + + const call = relPrisma.author.findMany.mock.calls[0][0] + expect(call.include).not.toHaveProperty('displayName') + expect(call.include.posts).toBeDefined() + expect(result[0].displayName).toBe('Author: Jo') + }) + + it('findUnique: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => { + const vConfig = configWithVirtualDisplayName() + relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] }) + + const context = await getContext(vConfig, relPrisma, null) + const result = await context.db.author.findUnique({ + where: { id: 'a1' }, + include: { displayName: true, posts: true }, + }) + + const call = relPrisma.author.findFirst.mock.calls[0][0] + expect(call.include).not.toHaveProperty('displayName') + expect(call.include.posts).toBeDefined() + expect(result.displayName).toBe('Author: Jo') + }) + + it('the virtual value is populated even when omitted from include', async () => { + const vConfig = configWithVirtualDisplayName() + relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' }) + + const context = await getContext(vConfig, relPrisma, null) + const result = await context.db.author.findUnique({ where: { id: 'a1' } }) + + expect(result.displayName).toBe('Author: Jo') + }) + + it('sudo: the virtual key is stripped from the Prisma include even though it bypasses access control', async () => { + const vConfig = configWithVirtualDisplayName() + relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }]) + + const context = await getContext(vConfig, relPrisma, null).sudo() + const result = await context.db.author.findMany({ + include: { displayName: true, posts: true }, + }) + + const call = relPrisma.author.findMany.mock.calls[0][0] + expect(call.include).not.toHaveProperty('displayName') + // The real relationship is unaffected — sudo still passes it through as-is. + expect(call.include.posts).toBe(true) + expect(result[0].displayName).toBe('Author: Jo') + }) + + it('read access control on the virtual field is still enforced (denied field is omitted)', async () => { + const vConfig = configWithVirtualDisplayName() + vConfig.lists.Author.fields.displayName = virtual({ + type: 'string', + access: { read: () => false }, + hooks: { + resolveOutput: ({ item }) => `Author: ${item.name}`, + }, + }) + relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' }) + + const context = await getContext(vConfig, relPrisma, null) + const result = await context.db.author.findUnique({ + where: { id: 'a1' }, + include: { displayName: true }, + }) + + expect(result).not.toHaveProperty('displayName') + }) + }) }) it('should delegate create to prisma with access control and hooks', async () => {