Skip to content

Wrong Zod import generated for delegated model with self-relation #2226

@jantoney

Description

@jantoney

Massive Note: Because I can't get the dev environment running on my machine. I can't run tests, so have no idea if this introduces any other bugs. Please test to ensure it's all good.


Wrong Zod import generated for delegated model with self-relation (import points to non-existent UpdateOneWithout<ThisSide>... type)

Summary

When a model uses delegation (@@delegate) and also has a self-relation defined on the base model, the Zod generator produces incorrect nested input imports in the delegated subtype’s UpdateInput schema.

Specifically, the generated file for the delegated subtype (e.g. RegistrationFrameworkUpdateInput.schema.ts) imports:

RegistrationUpdateOneWithoutReplacedRegistrationNestedInput

…but that file does not exist. The correct import should be:

RegistrationUpdateOneWithoutReplacementsNestedInput

(i.e. Without the opposite-side field, not the same side), or it should use the newer UpdateToOneWithWhereWithout... variants as appropriate. This leads to ERR_MODULE_NOT_FOUND at runtime.

The behaviour flips depending on field order and naming, which strongly suggests the opposite-side resolution is wrong for delegated + self-relation scenarios.


>> Repo Example <<

Here is a repo with a minimal schema and scripts to show the test.
Also includes a patched transformer.ts file for ./packagesschema/src/plugins/zod/

https://github.com/jantoney/zenstack_issue_min_repository


Environment

  • Node: v24.5.0
  • Package manager: pnpm@10.14.0
  • OS: Linux (container/host; root shell) WSL on Windows 11
  • Prisma: prisma@6.14.0
  • @prisma/client: 6.14.0 (same version)
  • ZenStack CLI: zenstack@^2.18.1
  • @zenstackhq/sdk: ^2.18.1
  • @zenstackhq/runtime: ^2.18.1
  • Zod: ^3.25.0
  • tsx: 4.20.4
  • Database: Postgresql

Minimal Reproduction

1) Minimal schema showing the bug

Base model has a self-relation and is delegated; delegated subtype is empty.

datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
plugin zod { provider = '@core/zod' }
generator  client { provider = "prisma-client-js" }

model Registration {
  id      String @id @default(uuid())
  regType String
  @@delegate(regType)

  // Self-relation on the base (names don’t matter; see experiments below)
  replacedRegistrationId String?
  replacedRegistration   Registration?  @relation("ReplacedBy", fields: [replacedRegistrationId], references: [id])
  replacements           Registration[] @relation("ReplacedBy")
}

model RegistrationFramework extends Registration {}

2) Generate

rm -rf src/generated/zenstack
pnpm exec zenstack generate --no-compile --output ./src/generated/zenstack

3) Inspect the result

echo "----------------------------------------------"

sed -n '1,160p' src/generated/zenstack/zod/objects/RegistrationFrameworkUncheckedUpdateInput.schema.ts || true

echo "----------------------------------------------"
# Extract import paths from grep, remove quotes and './', append .ts, and check if file exists
grep "^import { Re" src/generated/zenstack/zod/objects/RegistrationFrameworkUncheckedUpdateInput.schema.ts | \
awk -F"from '" '{print $2}' | sed "s/'//;s|^\./||;s/;$//" | while read relpath; do
    filepath="src/generated/zenstack/zod/objects/${relpath}.ts"
    if [ -f "$filepath" ]; then
        echo "✅ Exists: $filepath"
    else
        echo "❌ Missing: $filepath"
    fi
done

echo "-------------------- END --------------------------"

Production build - incorrect

 Missing: src/generated/zenstack/zod/objects/RegistrationUncheckedUpdateOneWithoutReplacedRegistrationNestedInput.schema.ts
 Exists: src/generated/zenstack/zod/objects/RegistrationUncheckedUpdateManyWithoutReplacedRegistrationNestedInput.schema.ts

Patched build - Correct

✅ Exists: src/generated/zenstack/zod/objects/RegistrationUpdateOneWithoutReplacementsNestedInput.schema.ts
✅ Exists: src/generated/zenstack/zod/objects/RegistrationUncheckedUpdateManyWithoutReplacedRegistrationNestedInput.schema.ts

Runtime error from dev server / build:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/.../RegistrationUpdateOneWithoutReplacedRegistrationNestedInput.schema'
  imported from '/.../RegistrationFrameworkUpdateInput.schema.ts'

Additional Experiments (all reproduced consistently)

  1. Self-relation removed (keep delegation) → No issue.
  2. Delegation removed (keep self-relation on the model) → No issue.
  3. Self-relation moved to the delegated subtype (base has no self-relation) → No issue.
    • Generated imports become (or analogous names, depending on field names):
      RegistrationFrameworkUpdateOneWithoutChildrenNestedInput
      RegistrationFrameworkUpdateManyWithoutParentNestedInput
      
      and all referenced files exist.
  4. Field order matters when the self-relation is on the base + delegation enabled:
    • Declaring the to-many field first often makes the to-one import correct (uses the opposite side), but then the to-many import may flip to a non-existent ...ManyWithout<ThisSide>... file.
    • Swapping order flips which side breaks.
  5. Naming tweaks:
    • Renaming the to-many replacements to something else (e.g. replacementList) or pluralising to match the to-one stem (replacedRegistrations) changes which wrong file is imported, but doesn’t fix the core issue.
    • Using distinct stems for each side (e.g. replacedByreplaces) shows the same pattern: the delegated subtype uses ...Without<ThisSide>... rather than ...Without<OppositeSide>....

Conclusion: the “opposite-side” resolution for nested input types is incorrect specifically when:

  • A self-relation is present on the base model, and
  • The model is delegated (has @@delegate and a subtype extends it).

It appears the delegated subtype’s generator picks the wrong side name when determining UpdateOne/ManyWithout<OppositeSide>... targets.


Impact

  • Builds fail or dev server crashes with:
    Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../RegistrationUpdateOneWithoutReplacedRegistrationNestedInput.schema'
    
  • Affects projects using delegation (@@delegate) with base-level self-relations.
  • Confusing, because the base model’s own UpdateInput imports are correct; only the delegated subtype is wrong.

What would help to fix

  • In the delegated subtype’s Zod generation, ensure the nested input type chosen for a relation field uses the opposite side name for the Without<OppositeSide> suffix (same logic as the base model generator).
  • Add tests for:
    • Delegation + self-relation on base model.
    • Field order permutations (to-many first vs to-one first).
    • Naming permutations (pluralisation and distinct stems).

Fix

I tried forking the repo, cloning it, pnpm install & build - but there are too many issues for me to work through to get tests working. So here are the fixes that seem to work for me.

./packages/schema/src/plugins/zod/transformers.ts

Updated Import statement

import {
    getForeignKeyFields,
    getRelationBackLink,
    getAttributeArgLiteral,
    hasAttribute,
    indentString,
    isDelegateModel,
    isDiscriminatorField,
    type PluginOptions,
} from '@zenstackhq/sdk';

Updated mapDelegateInputType function

private mapDelegateInputType(
        inputType: PrismaDMMF.InputTypeRef,
        contextDataModel: DataModel,
        contextFieldName: string
    ): PrismaDMMF.InputTypeRef {
        const contextField = contextDataModel.fields?.find((f: DataModelField) => f.name === contextFieldName);
        if (!contextField || !isDataModel(contextField.type?.reference?.ref)) {
            return inputType;
        }

        // Only remap relation fields inherited from a delegate base
        if (!contextField.$inheritedFrom || !isDelegateModel(contextField.$inheritedFrom)) {
            return inputType;
        }

        let processedInputType = inputType;

        // Match generator-internal delegated placeholders; keep your existing pattern if you have one.
        // We only need to capture:
        //  - match[1]: the "prefix" before the "Without<...>"
        //  - match[4]: the suffix ("Input" | "NestedInput")
        const match = inputType.type.match(/^(\S+?)((NestedOne)?WithoutDelegate_aux\S+?)((Nested)?Input)$/);
        if (!match) {
            return processedInputType;
        }

        const mappedInputTypeName = match[1]; // prefix before "Without..."
        const suffix: string = match[4] ?? /(NestedInput|Input)$/.exec(inputType.type)?.[1] ?? 'Input';

        // ---------- Robust opposite-side resolution for self-relations ----------
        const targetModel = contextField.type.reference!.ref;
        let opposite: DataModelField | undefined = getRelationBackLink(contextField);

        const isSelfRelation =
            !!targetModel &&
            (targetModel.name === contextDataModel.name ||
                targetModel.name === (contextField.$inheritedFrom as DataModel)?.name);

        if (isSelfRelation && (!opposite || opposite.name === contextField.name)) {
            // Extract @relation name, if present
            const relAttr = contextField.attributes?.find((a) => a.decl.ref?.name === '@relation');
            const relName =
                (relAttr && getAttributeArgLiteral<string>(relAttr, 'name')) ??
                (relAttr?.args?.[0]?.$resolvedParam as string | undefined);

            // Candidates: on the target model, same relation name (or any @relation if unnamed), different field name
            const candidates = targetModel.fields.filter((f: DataModelField) => {
                if (!isDataModel(f.type?.reference?.ref)) return false;
                if (f.name === contextField.name) return false;
                const a = f.attributes?.find((x) => x.decl.ref?.name === '@relation');
                const aName =
                    (a && getAttributeArgLiteral<string>(a, 'name')) ??
                    (a?.args?.[0]?.$resolvedParam as string | undefined);
                return relName ? aName === relName : !!a;
            });

            // Prefer opposite cardinality, else first candidate
            opposite =
                candidates.find((f: DataModelField) => !!f.type?.array !== !!contextField.type?.array) ??
                candidates[0] ??
                opposite;
        }

        if (!opposite) {
            return processedInputType;
        }

        // ---------- Name construction + resilient fallbacks for to-one variants ----------
        const Opp = upperCaseFirst(opposite.name);

        // Helper: does an input type with this exact name exist in DMMF?
        const hasInput = (n: string) =>
            this.inputObjectTypes.some((t: PrismaDMMF.InputType) => upperCaseFirst(t.name) === n || t.name === n);

        // Transform helpers
        const swapToOneWithWhere = (s: string) => s.replace('UpdateOneWithout', 'UpdateToOneWithWhereWithout');
        const dropUnchecked = (s: string) => s.replace('Unchecked', '');
        const addRequired = (s: string) =>
            s
                .replace('UpdateOneWithout', 'UpdateOneRequiredWithout')
                .replace('UpdateToOneWithWhereWithout', 'UpdateToOneWithWhereRequiredWithout');
        const dropRequired = (s: string) =>
            s
                .replace('UpdateOneRequiredWithout', 'UpdateOneWithout')
                .replace('UpdateToOneWithWhereRequiredWithout', 'UpdateToOneWithWhereWithout');

        // Base candidate (current behaviour): "<prefix>Without<Opp><suffix>"
        const primary = `${mappedInputTypeName}Without${Opp}${suffix}`;
        const originalHadUnchecked = primary.includes('Unchecked');

        const candidates: string[] = [];
        const push = (n: string) => {
            if (n && !candidates.includes(n)) candidates.push(n);
        };

        // Ordered fallbacks to cover Prisma 5/6 naming differences
        push(primary);
        push(swapToOneWithWhere(primary));
        if (originalHadUnchecked) push(dropUnchecked(primary));
        push(addRequired(primary));
        push(dropRequired(primary));
        if (originalHadUnchecked) {
            push(dropUnchecked(swapToOneWithWhere(primary)));
            push(dropUnchecked(addRequired(primary)));
        }

        // Last-resort: scan all input types that end with the exact "Without<Opp><suffix>"
        const modelPrefix = primary.split('Update')[0]; // e.g., "Registration" or "RegistrationUnchecked"
        const tailPattern = new RegExp(`Without${Opp}${suffix}$`);
        const pool = this.inputObjectTypes
            .map((t: PrismaDMMF.InputType) => upperCaseFirst(t.name))
            .filter((n: string) => n.startsWith(modelPrefix.replace('Unchecked', '')) && tailPattern.test(n));

        const byPreference = (a: string, b: string) => {
            const aU = a.includes('Unchecked'),
                bU = b.includes('Unchecked');
            if (aU !== bU) return originalHadUnchecked ? (bU ? 1 : -1) : aU ? 1 : -1;
            const aW = a.includes('UpdateToOneWithWhereWithout'),
                bW = b.includes('UpdateToOneWithWhereWithout');
            if (aW !== bW) return bW ? 1 : -1;
            return a.localeCompare(b);
        };

        let finalName = candidates.find(hasInput);
        if (!finalName && pool.length) {
            pool.sort(byPreference);
            finalName = pool[0];
        }

        if (finalName && hasInput(finalName)) {
            processedInputType = { ...inputType, type: finalName };
        }

        return processedInputType;
    }

Test images

Image Image

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions