kristiandupont / kanel

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

Generating iterable union types with kanel-kysely #567

Open ersinakinci opened 3 months ago

ersinakinci commented 3 months ago

I've switched from using enums to iterable type unions, as outlined in this post. I find them much more ergonomic than traditional TS enums and they have the benefit of being iterable.

Any chance that we could get an option in the kanel-kysely plugin to generate this kind of union for PostgreSQL enums instead of native TS enums when using a custom type? For example, instead of generating:

enum EntityType {
  person = 'person',
  company = 'company',
};

export default EntityType

We could generate:

export const EntityType = [
  "person",
  "company",
] as const;

export type EntityType = (typeof EntityType)[number];

Opt-in API:

// .kanelrc.js

export default {
   ...,
   preRenderHooks: [makeKyselyHook({ iterableUnionTypes: true })],
}
ersinakinci commented 3 months ago

Searching through the code, I discovered the enumStyle option:

// invokeKanelProgrammatically.js

import kanel from "kanel";
import config from "../.kanelrc";

const { processDatabase } = kanel;

async function run() {
  await processDatabase({ ...config, enumStyle: "type" });
}

run();

For some reason, I can't use it in my .kanelrc.js file, I have to use it in a programmatically-invoked Kanel config.

The result is:

type EntityType = 
  | 'person'
  | 'company';

export default EntityType;

Which gets us really close!

Could we just simply add a readonly array with the enum types as strings and export that as a named export when using enumStyle: "type"? And also export the type as a named export, the same way that I indicated in my original post? (No need to change the default export.)

And could we also allow setting enumStyle from within .kanelrc.js?

kristiandupont commented 3 months ago

And could we also allow setting enumStyle from within .kanelrc.js?

I thought it was, that's strange!

But this is a good point. I was actually contemplating removing the enum option altogether because I don't use it myself but I acknowledge that others do and I don't want to cause too many breaking changes. The thing that annoys me the most, which would apply to your suggestion as well, is that it's the only place where Kanel emits runable code (as opposed to just types). But as it's like that already, your suggestion doesn't make this any worse :-D

I guess this could be a third enum style. I would also want to rename the type type to union as I think that's more intuitive.

ersinakinci commented 3 months ago

I thought it was, that's strange!

Maybe you can set it from the config file? I just know that it doesn't show up in IntelliSense 😛, so at least the types are missing.

I guess this could be a third enum style. I would also want to rename the type type to union as I think that's more intuitive.

Works for me! That would be great. I hope it doesn't go against your design goals too much 😅.

acro5piano commented 3 months ago

It's really good if the feature is natively implemented!

Current my workaround hook is here:

const extractEnumValuesHook = (path, lines) => {
  let isEnumFile = lines.some((line) => line.includes('Represents the enum'))
  if (!isEnumFile) {
    return lines
  }
  const l = lines.length
  for (let i = 0; i < l; i++) {
    {
      const match = lines[i].match(/export type (.+) =/)
      if (match) {
        lines.push(`export const ${match[1]}Values = [`)
      }
    }
    {
      const match = lines[i].match(/\| '(.+)'/)
      if (match) {
        lines.push(`  '${match[1]}', `)
      }
    }
  }
  lines.push('] as const')
  return lines
}

module.exports = {
  connection: process.env['DATABASE_URL'],
  preRenderHooks: [makeKyselyHook(), kyselyCamelCaseHook],
  postRenderHooks: [
    extractEnumValuesHook,
  ],
  enumStyle: 'type',
}

Outputs this:

/** Represents the enum public.gender_enum */
export type GenderEnum = 
  | 'MALE'
  | 'FEMALE'
  | 'UNKNOWN';

export const GenderEnumValues = [
  'MALE', 
  'FEMALE', 
  'UNKNOWN', 
] as const
ersinakinci commented 1 month ago

@acro5piano's hook didn't work for me. I'm not sure about his setup, but it looks to me like the type files are exported using export default, which the code above doesn't detect.

I expanded on @acro5piano's hook:

// .kanelrc.ts

const extractEnumValuesHook = (_path: string, lines: string[]) => {
  let l = lines.length;
  const isTableFile = lines.some((line: string) =>
    line.includes("Represents the table")
  );
  const isEnumFile = lines.some((line: string) =>
    line.includes("Represents the enum")
  );

  if (isTableFile) {
    for (let i = 0; i < l; i++) {
      const match = lines[i].match(/^import type { default as (.+) }/);
      if (match) {
        lines[i] = `import type { ${match[1]} } from './${match[1]}';`;
      }
    }
  }

  if (isEnumFile) {
    for (let i = 0; i < l; i++) {
      {
        const match = lines[i].match(/^type (.+) =/);
        if (match) {
          lines[i] = `export const ${match[1]} = [`;
          lines.push(`export type ${match[1]} = (typeof ${match[1]})[number];`);
        }
      }
      {
        const match = lines[i].match(/^export default/);
        if (match) {
          lines.splice(i, 1);
          l--;
        }
      }
      {
        const match = lines[i].match(/\| '(.+)'$/);
        if (match) {
          lines[i] = `  '${match[1]}', `;
        }
      }
      {
        const match = lines[i].match(/\| '(.+)';$/);
        if (match) {
          lines[i] = `  '${match[1]}',`;
          lines.splice(i + 1, 0, `] as const;`);
          l++;
        }
      }
    }
  }

  return lines;
};

// Kanel config
export default {
  ...,
  postRenderHooks: [extractEnumValuesHook],
  enumStyle: "type",
};

Example output:

EntityType.ts (enum file)

/** Represents the enum public.entity_type */
export const EntityType = [
  'person', 
  'sole-proprietorship', 
  'gp', 
  'lp', 
  'corporation', 
  'llc', 
  'llp', 
  'cooperative', 
  'other',
] as const;

export type EntityType = (typeof EntityType)[number];

Entity.ts (table file)

import type { EntityType } from './EntityType';

/** Represents the table public.entity */
export default interface EntityTable {
  type: ColumnType<EntityType, EntityType, EntityType>;

  ...
}

Would be great to have this built into Kanel 😄

kristiandupont commented 1 month ago

Nice work! I'm happy you got it working.