Implementing a base model in Typescript with Kysely

1k Views Asked by At

I'm trying to create an inheritance based base models for my application. The goal being that the base model has similar functions to ActiveRecord in Rails present and a new model can just extend that but I'm running into a few issues.

I'm using purely kysely and I'm not going to add any ORMs. Firstly I'm thinking of doing something like:

import { Exercise } from "../backend/interface/Exercise";
import { BaseModel } from "../BaseModel"

export class ExerciseModel extends BaseModel<Exercise> {
  constructor() {
    super("exercise");
  }
}

rather than this model:

import { Exercise } from "../../interface/Exercise/Exercise";

export class ExerciseModel {
    static async findById(id: number) {
        const result = await db
            .selectFrom('exercise')
            .where('id', '=', id)
            .execute()
        return result[0] as Exercise
    }

    static async delete(id: number) {
        await db.deleteFrom("exercise").where("id", "=", id).execute();
    }

    static async findAll() {
        const result = await db.selectFrom("exercise").execute();
        return result as Exercise[];
    }

    static async update(id: number, name: string) {
        await db
            .updateTable("exercise")
            .set({ name })
            .where("id", "=", id)
            .execute();
    }

    static async create(name: string, muscle_group_id: number) {
        await db
            .insertInto("exercise")
            .values({
                name: name,
                muscle_group_id: muscle_group_id
            })
            .returningAll()
            .executeTakeFirstOrThrow();
    }
}

which works however apart from the string name of the database being provided (ok the create and update but I could just put them in the ExerciseModel and not the BaseModel), everything would have to be replicated for all my models.

I tried doing something like this:

export class BaseModel {
    protected readonly tableName: string;

    constructor(tablename: string){
        this.tableName = tablename
    }
    static async findById(id: number) {
      const result = await db
        .selectFrom(this.tableName)
        .where('id', '=', id)
        .execute()
      return result[0]
    }
    static tableName(tableName: any) {
        throw new Error("Method not implemented.");
    }
  
    static async delete(id: number) {
      await db.deleteFrom(this.tableName).where('id', '=', id).execute()
    }
  }

But I ran into errors:

Overload 1 of 2, '(from: TableExpression<Models, "exercise">[]): SelectQueryBuilder<From<Models, TableExpression<Models, "exercise">>, any, {}>', gave the following error.
    Argument of type '(tableName: any) => void' is not assignable to parameter of type 'TableExpression<Models, "exercise">[]'.
  Overload 2 of 2, '(from: TableExpression<Models, "exercise">): SelectQueryBuilder<From<Models, TableExpression<Models, "exercise">>, any, {}>', gave the following error.
    Argument of type '(tableName: any) => void' is not assignable to parameter of type 'TableExpression<Models, "exercise">'.

For reference the db object was created like


import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import * as dotenv from 'dotenv'
import { Models } from "./models/Models";

dotenv.config()

export const db = new Kysely<Models>({
    dialect: new PostgresDialect({
        pool: new Pool({
            connectionString: process.env.DATABASE_URL,
        }),
    }),
});

while the Models interface looks like this for the time being

export interface Models{
    exercise: Exercise
}

I would love to see better ideas as well, but any help would be appreciated

1

There are 1 best solutions below

0
On

Here's one way to implement it -

import { Kysely, PostgresDialect, Insertable, Selectable, Updateable, Generated } from "kysely";
import { Pool } from "pg";

type Keys<T> = keyof T;

type DiscriminatedUnionOfRecord<
    A,
    B = {
        [Key in keyof A as "_"]: {
            [K in Key]: [
                { [S in K]: A[K] extends A[Exclude<K, Keys<A>>] ? never : A[K] }
            ];
        };
    }["_"]
> = Keys<A> extends Keys<B>
    ? B[Keys<A>] extends Array<any>
    ? B[Keys<A>][number]
    : never
    : never;

export interface ExerciseTable {
    id: Generated<number>;
    name: string;
}

export interface PersonTable {
    id: Generated<number>;
    firstName: string;
    lastName: string;
    age: number;
}

type TablesOperationMap = {
    person: { select: Selectable<PersonTable>, insert: Insertable<PersonTable>, update: Updateable<PersonTable> },
    exercise: { select: Selectable<ExerciseTable>, insert: Insertable<ExerciseTable>, update: Updateable<ExerciseTable> }
};

type TableOpsUnion = DiscriminatedUnionOfRecord<TablesOperationMap>;

export interface Models {
    exercise: ExerciseTable;
    person: PersonTable;
}

export const db = new Kysely<Models>({
    dialect: new PostgresDialect({
        pool: new Pool({
            connectionString: "something",
        }),
    }),
});

type OperationDataType<T extends keyof Models, Op extends "select" | "update" | "insert"> = T extends keyof TableOpsUnion ? TableOpsUnion[T][Op] : never;

type GetOperandType<T extends keyof TablesOperationMap, Op extends keyof TablesOperationMap[T], Key extends keyof TablesOperationMap[T][Op]> = unknown extends TablesOperationMap[T][Op][Key] ? never : TablesOperationMap[T][Op][Key] extends never ? never : TablesOperationMap[T][Op][Key];

export class BaseModel<T extends keyof Models> {
    protected readonly tableName: T;

    constructor(tablename: T) {
        this.tableName = tablename
    }

    async insertOne(data: OperationDataType<T, "insert">) {
        return await db.insertInto(this.tableName).values(data).returningAll().executeTakeFirstOrThrow();
    }

    async findById(id: GetOperandType<T, "select", "id">) {
        const result = await db
            .selectFrom(this.tableName)
            .selectAll()
            .where('id', '=', id)
            .execute()
        if (result.length) {
            return result[0];
        } else {
            return null;
        }
    }

    async updateOne(id: GetOperandType<T, "update", "id">, data: OperationDataType<T, "update">) {
        const [{ numUpdatedRows, numChangedRows }] = await db.updateTable(this.tableName).set(data).where("id", "=", id).execute();
        return { numChangedRows, numUpdatedRows };

    }

    async deleteOne(id: GetOperandType<T, "select", "id">) {
        const [{ numDeletedRows }] = await db.deleteFrom(this.tableName).where('id', '=', id).execute();
        return { numDeletedRows };
    }

    static get tableName(): keyof Models {
        return this.tableName;
    }
}

export class ExerciseModel extends BaseModel<"exercise"> {
    constructor() {
        super("exercise");
    }
}

export class PersonModel extends BaseModel<"person"> {
    constructor() {
        super("person");
    }
}

const person = (new PersonModel()).findById(1)
//    ^? const person: Promise<{ id: number; firstName: string; LastName: string; age: number; } | null>
const exercise = (new ExerciseModel()).findById(1);
//    ^? const exercise: Promise<{ id: number; name: string; } | null>

Here's a Playground link.

NOTE: The class members are not static, because Typescript itself is a static language, and it determines types statically, so if you have static methods, the Generic type is never inferred and you'll end up getting intellisense for the all the models for each method at once, even the return types, so it's best to instantiate the class and use its member functions.