kristiandupont / kanel

Generate Typescript types from Postgres
https://kristiandupont.github.io/kanel/
MIT License
829 stars 57 forks source link

kanel-zod doesn't play nice with kanel-kesely #563

Open thesmart opened 1 month ago

thesmart commented 1 month ago

In .kanelrc I'm using preRenderHooks: [makeKyselyHook(), generateZodSchemas], because I want both zod and kysely types. However, when using the makeGenerateZodSchemas config property castToSchema: true, the Schema types are missing.

Here is an example:

export const prismaMigrationsInitializer = z.object({
  id: prismaMigrationsId,
  checksum: z.string(),
  finished_at: z.date().optional().nullable(),
  migration_name: z.string(),
  logs: z.string().optional().nullable(),
  rolled_back_at: z.date().optional().nullable(),
  started_at: z.date().optional(),
  applied_steps_count: z.number().optional(),
}) as unknown as z.Schema<PrismaMigrationsInitializer>;

In the output, PrismaMigrationsInitializer is not defined.

If I remove makeKyselyHook(), plugin, then the schema types appear as expected:

/** Represents the initializer for the table public._prisma_migrations */
export interface PrismaMigrationsInitializer {
  id: PrismaMigrationsId;

  checksum: string;

  finished_at?: Date | null;

  migration_name: string;

  logs?: string | null;

  rolled_back_at?: Date | null;

  /** Default value: now() */
  started_at?: Date;

  /** Default value: 0 */
  applied_steps_count?: number;
}

Is there a way to make these plugins work with each other?

kristiandupont commented 1 month ago

Without looking into the details here, have you tried reversing the order? Hooks are applied serially, which is why it's important, say, to run the index generator last.

ersinakinci commented 3 weeks ago

I came here to say the same thing.

@kristiandupont, I just tried reversing the order of the hooks and I get the *Initializer/*Mutator classes aren't defined.

kristiandupont commented 3 weeks ago

@ersinakinci are you referring to the "original" types or the Zod schemas?

tefkah commented 3 weeks ago

@kristiandupont I think they are referring to the fact that when using kanel-kysely, the "original" *Intializer and *Mutator interfaces get replaced by the New* and *Update types from kanel-kysely.

Here in my example, running only kanel-zod for this Members table, i get

// @generated
// This file is automatically generated by Kanel. Do not modify manually.

import { communitiesId, type CommunitiesId } from './Communities';
import { usersId, type UsersId } from './Users';
import { z } from 'zod';

/** Identifier type for public.members */
export type MembersId = string & { __brand: 'MembersId' };

/** Represents the table public.members */
export default interface Members {
  id: MembersId;

  createdAt: Date;

  updatedAt: Date;

  canAdmin: boolean;

  communityId: CommunitiesId;

  userId: UsersId;
}

/** Represents the initializer for the table public.members */
export interface MembersInitializer {
  /** Default value: gen_random_uuid() */
  id?: MembersId;

  /** Default value: CURRENT_TIMESTAMP */
  createdAt?: Date;

  /** Default value: CURRENT_TIMESTAMP */
  updatedAt?: Date;

  canAdmin: boolean;

  communityId: CommunitiesId;

  userId: UsersId;
}

/** Represents the mutator for the table public.members */
export interface MembersMutator {
  id?: MembersId;

  createdAt?: Date;

  updatedAt?: Date;

  canAdmin?: boolean;

  communityId?: CommunitiesId;

  userId?: UsersId;
}

export const membersId = z.string() as unknown as z.Schema<MembersId>;

export const members = z.object({
  id: membersId,
  createdAt: z.date(),
  updatedAt: z.date(),
  canAdmin: z.boolean(),
  communityId: communitiesId,
  userId: usersId,
}) as unknown as z.Schema<Members>;

export const membersInitializer = z.object({
  id: membersId.optional(),
  createdAt: z.date().optional(),
  updatedAt: z.date().optional(),
  canAdmin: z.boolean(),
  communityId: communitiesId,
  userId: usersId,
}) as unknown as z.Schema<MembersInitializer>;

export const membersMutator = z.object({
  id: membersId.optional(),
  createdAt: z.date().optional(),
  updatedAt: z.date().optional(),
  canAdmin: z.boolean().optional(),
  communityId: communitiesId.optional(),
  userId: usersId.optional(),
}) as unknown as z.Schema<MembersMutator>;

which works.

However, when i also run kanel-kysely (either before or after, does not really matter, here i've done before), i get

// @generated
// This file is automatically generated by Kanel. Do not modify manually.

import { communitiesId, type CommunitiesId } from './Communities';
import { usersId, type UsersId } from './Users';
import { type ColumnType, type Selectable, type Insertable, type Updateable } from 'kysely';
import { z } from 'zod';

/** Identifier type for public.members */
export type MembersId = string & { __brand: 'MembersId' };

/** Represents the table public.members */
export default interface MembersTable {
  id: ColumnType<MembersId, MembersId | undefined, MembersId>;

  createdAt: ColumnType<Date, Date | string | undefined, Date | string>;

  updatedAt: ColumnType<Date, Date | string | undefined, Date | string>;

  canAdmin: ColumnType<boolean, boolean, boolean>;

  communityId: ColumnType<CommunitiesId, CommunitiesId, CommunitiesId>;

  userId: ColumnType<UsersId, UsersId, UsersId>;
}

export type Members = Selectable<MembersTable>;

export type NewMembers = Insertable<MembersTable>;

export type MembersUpdate = Updateable<MembersTable>;

export const membersId = z.string() as unknown as z.Schema<MembersId>;

export const members = z.object({
  id: membersId,
  createdAt: z.date(),
  updatedAt: z.date(),
  canAdmin: z.boolean(),
  communityId: communitiesId,
  userId: usersId,
}) as unknown as z.Schema<Members>;

export const membersInitializer = z.object({
  id: membersId.optional(),
  createdAt: z.date().optional(),
  updatedAt: z.date().optional(),
  canAdmin: z.boolean(),
  communityId: communitiesId,
  userId: usersId,
}) as unknown as z.Schema<MembersInitializer>;

export const membersMutator = z.object({
  id: membersId.optional(),
  createdAt: z.date().optional(),
  updatedAt: z.date().optional(),
  canAdmin: z.boolean().optional(),
  communityId: communitiesId.optional(),
  userId: usersId.optional(),
}) as unknown as z.Schema<MembersMutator>;

As you can see, the schemas created by kanel-zod are referring to MembersInitializer and MembersMutator, but those get replaced by kanel-kysely by NewMembers and MembersMutator.

This can be easily solved by either

  1. Using the same names in kanel-kysely as the default
  2. Allowing some customization option .kanelrc.js for the names of the types for kanel-kysely or kanel-zod and mention this in the docs
  3. Have kanel-zod check whether kanel-kysely is being used and use the other type names.

I think a configuration option is probably the easiest. For now I will solve this by creating my own hook that renames the types used by the zod schemas

tefkah commented 3 weeks ago

Another possible fix, is disabling the cast entirely in .kanelrc.js like so

module.exports = {
    connection: process.env["DATABASE_URL"],
    schemas: ["public"],
    preDeleteOutputFolder: true,
    preRenderHooks: [
        makeKyselyHook(),
        makeGenerateZodSchemas({
            getZodSchemaMetadata: defaultGetZodSchemaMetadata,
            getZodIdentifierMetadata: defaultGetZodIdentifierMetadata,
            castToSchema: false,
            zodTypeMap: defaultZodTypeMap,
        }),
    ],
    outputPath: "../packages/db/src",
};
tefkah commented 3 weeks ago

For anyone interested, here is a postRender hook that solves this issue the ugly way, by just renaming the types after they're generated.

// .kanelrc.js

const { makeKyselyHook } = require("kanel-kysely");
const { generateZodSchemas } = require("kanel-zod");

const kanelZodCastRegex = /as unknown as z.Schema<(.*?)(Mutator|Initializer)>/;

/**
 * @type {import("kanel").PostRenderHook}
 *
 * Renames the type of the `as unknown as z.Schema` casts from `kanel-zod` to
 * to be compatible with `kanel-kysely`, turning
 * 1. `as unknown as z.Schema<TableMutator>` into `as unknown as z.Schema<TableUpdate>`
 * 2. `as unknown as z.Schema<TableInitializer>` into `as unknown as z.Schema<NewTable>`
 */
function kanelKyselyZodCompatibilityHook(path, lines, instantiatedConfig) {
    return lines.map((line) => {
        if (!line.includes("as unknown as z.Schema")) {
            return line;
        }

        const replacedLine = line.replace(
            kanelZodCastRegex,
            (_, typeName, mutatorOrInitializer) => {
                if (!mutatorOrInitializer) {
                    return `as unknown as z.Schema<${typeName}>`;
                }

                if (mutatorOrInitializer === "Mutator") {
                    return `as unknown as z.Schema<${typeName}Update>`;
                }

                return `as unknown as z.Schema<New${typeName}>`;
            }
        );

        return replacedLine;
    });
}

/** @type {import('kanel').Config} */
module.exports = {
    connection: process.env["DATABASE_URL"],
    schemas: ["public"],

    preDeleteOutputFolder: true,
    preRenderHooks: [makeKyselyHook(), generateZodSchemas],
    postRenderHooks: [kanelKyselyZodCompatibilityHook],
    outputPath: "../packages/db/src",
};
ersinakinci commented 3 weeks ago

I think they are referring to the fact that when using kanel-kysely, the "original" Intializer and Mutator interfaces get replaced by the New and Update types from kanel-kysely.

I didn't even realize that's what's going on, but yes, to answer your question @kristiandupont, that's exactly it.

kristiandupont commented 3 weeks ago

Ah, I see. Yeah, the thing is that even though I did a pretty big rewrite two years ago, the architecture is already a bit dated for what I and you guys are trying to do with it already. Both of these extensions work in a a bit of a hacky way. It's a constant battle between making things flexible and keeping necessary configuration at a minimum.

I will try to look into this when I have some time but I am not making any promises as to when that might be :-)