diff --git a/packages/dialects/sql.js/README.md b/packages/dialects/sql.js/README.md new file mode 100644 index 000000000..f0ed1317d --- /dev/null +++ b/packages/dialects/sql.js/README.md @@ -0,0 +1,25 @@ +Forked from https://github.com/betarixm/kysely-sql-js + +## Usage + +```ts +import { type GeneratedAlways, Kysely } from 'kysely'; +import initSqlJs from 'sql.js'; + +import { SqlJsDialect } from 'kysely-sql-js'; + +interface Database { + person: { + id: GeneratedAlways; + first_name: string | null; + last_name: string | null; + age: number; + }; +} + +const SqlJsStatic = await initSqlJs(); + +export const db = new Kysely({ + dialect: new SqlJsDialect({ sqlJs: new SqlJsStatic.Database() }), +}); +``` diff --git a/packages/dialects/sql.js/eslint.config.js b/packages/dialects/sql.js/eslint.config.js new file mode 100644 index 000000000..5698b9910 --- /dev/null +++ b/packages/dialects/sql.js/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json new file mode 100644 index 000000000..c1433653b --- /dev/null +++ b/packages/dialects/sql.js/package.json @@ -0,0 +1,42 @@ +{ + "name": "@zenstackhq/kysely-sql-js", + "version": "3.0.0-alpha.16", + "description": "Kysely dialect for sql.js", + "type": "module", + "scripts": { + "build": "tsup-node", + "watch": "tsup-node --watch", + "lint": "eslint src --ext ts", + "pack": "pnpm pack" + }, + "keywords": [], + "author": "ZenStack Team", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "devDependencies": { + "@types/sql.js": "^1.4.9", + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*", + "sql.js": "^1.13.0", + "kysely": "catalog:" + }, + "peerDependencies": { + "sql.js": "^1.13.0", + "kysely": "catalog:" + } +} diff --git a/packages/dialects/sql.js/src/connection.ts b/packages/dialects/sql.js/src/connection.ts new file mode 100644 index 000000000..94e1fa45b --- /dev/null +++ b/packages/dialects/sql.js/src/connection.ts @@ -0,0 +1,30 @@ +import type { DatabaseConnection, QueryResult } from 'kysely'; +import type { BindParams, Database } from 'sql.js'; + +import { CompiledQuery } from 'kysely'; + +export class SqlJsConnection implements DatabaseConnection { + private database: Database; + + constructor(database: Database) { + this.database = database; + } + + async executeQuery(compiledQuery: CompiledQuery): Promise> { + const executeResult = this.database.exec(compiledQuery.sql, compiledQuery.parameters as BindParams); + const rowsModified = this.database.getRowsModified(); + return { + numAffectedRows: BigInt(rowsModified), + rows: executeResult + .map(({ columns, values }) => + values.map((row) => columns.reduce((acc, column, i) => ({ ...acc, [column]: row[i] }), {}) as R), + ) + .flat(), + }; + } + + // eslint-disable-next-line require-yield + async *streamQuery() { + throw new Error('Not supported with SQLite'); + } +} diff --git a/packages/dialects/sql.js/src/dialect.ts b/packages/dialects/sql.js/src/dialect.ts new file mode 100644 index 000000000..922b08b09 --- /dev/null +++ b/packages/dialects/sql.js/src/dialect.ts @@ -0,0 +1,26 @@ +import type { Dialect } from 'kysely'; + +import type { SqlJsDialectConfig } from './types'; + +import { Kysely, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } from 'kysely'; + +import { SqlJsDriver } from './driver'; + +/** + * The SqlJsDialect is for testing purposes only and should not be used in production. + */ +export class SqlJsDialect implements Dialect { + private config: SqlJsDialectConfig; + + constructor(config: SqlJsDialectConfig) { + this.config = config; + } + + createAdapter = () => new SqliteAdapter(); + + createDriver = () => new SqlJsDriver(this.config); + + createIntrospector = (db: Kysely) => new SqliteIntrospector(db); + + createQueryCompiler = () => new SqliteQueryCompiler(); +} diff --git a/packages/dialects/sql.js/src/driver.ts b/packages/dialects/sql.js/src/driver.ts new file mode 100644 index 000000000..b998d7967 --- /dev/null +++ b/packages/dialects/sql.js/src/driver.ts @@ -0,0 +1,38 @@ +import type { DatabaseConnection, Driver } from 'kysely'; + +import { CompiledQuery } from 'kysely'; + +import { SqlJsConnection } from './connection'; +import type { SqlJsDialectConfig } from './types'; + +export class SqlJsDriver implements Driver { + private config: SqlJsDialectConfig; + + constructor(config: SqlJsDialectConfig) { + this.config = config; + } + + async acquireConnection(): Promise { + return new SqlJsConnection(this.config.sqlJs); + } + + async beginTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('BEGIN')); + } + + async commitTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('COMMIT')); + } + + async rollbackTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('ROLLBACK')); + } + + async destroy(): Promise { + this.config.sqlJs.close(); + } + + async init() {} + + async releaseConnection(_connection: DatabaseConnection): Promise {} +} diff --git a/packages/dialects/sql.js/src/index.ts b/packages/dialects/sql.js/src/index.ts new file mode 100644 index 000000000..096b4fc01 --- /dev/null +++ b/packages/dialects/sql.js/src/index.ts @@ -0,0 +1,4 @@ +export * from './connection'; +export * from './dialect'; +export * from './driver'; +export * from './types'; diff --git a/packages/dialects/sql.js/src/types.ts b/packages/dialects/sql.js/src/types.ts new file mode 100644 index 000000000..2405bb258 --- /dev/null +++ b/packages/dialects/sql.js/src/types.ts @@ -0,0 +1,5 @@ +import type { Database } from 'sql.js'; + +export interface SqlJsDialectConfig { + sqlJs: Database; +} diff --git a/packages/dialects/sql.js/test/getting-started/database.ts b/packages/dialects/sql.js/test/getting-started/database.ts new file mode 100644 index 000000000..7ab61248a --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/database.ts @@ -0,0 +1,12 @@ +import type { Database } from './types'; + +import { Kysely } from 'kysely'; +import initSqlJs from 'sql.js'; + +import { SqlJsDialect } from '../../src'; + +const SqlJsStatic = await initSqlJs(); + +export const db = new Kysely({ + dialect: new SqlJsDialect({ sqlJs: new SqlJsStatic.Database() }), +}); diff --git a/packages/dialects/sql.js/test/getting-started/person-repository.test.ts b/packages/dialects/sql.js/test/getting-started/person-repository.test.ts new file mode 100644 index 000000000..4d00eb1f4 --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/person-repository.test.ts @@ -0,0 +1,94 @@ +import { sql } from 'kysely'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { db } from './database'; +import * as PersonRepository from './person-repository'; + +describe('person-repository', () => { + beforeEach(async () => { + await db + .insertInto('person') + .values({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }) + .executeTakeFirstOrThrow(); + }); + + beforeAll(async () => { + await db.schema + .createTable('person') + .addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement().notNull()) + .addColumn('first_name', 'varchar(255)', (cb) => cb.notNull()) + .addColumn('last_name', 'varchar(255)') + .addColumn('gender', 'varchar(50)', (cb) => cb.notNull()) + .addColumn('created_at', 'timestamp', (cb) => cb.notNull().defaultTo(sql`current_timestamp`)) + .execute(); + }); + + afterEach(async () => { + await sql`delete from ${sql.table('person')}`.execute(db); + }); + + afterAll(async () => { + await db.schema.dropTable('person').execute(); + }); + + it('should find a person with a given id', async () => { + expect(await PersonRepository.findPersonById(123)).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }); + }); + + it('should find all people named Arnold', async () => { + const people = await PersonRepository.findPeople({ first_name: 'Arnold' }); + + expect(people).toHaveLength(1); + expect(people[0]).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'other', + }); + }); + + it('should update gender of a person with a given id', async () => { + await PersonRepository.updatePerson(123, { gender: 'woman' }); + + expect(await PersonRepository.findPersonById(123)).toMatchObject({ + id: 123, + first_name: 'Arnold', + last_name: 'Schwarzenegger', + gender: 'woman', + }); + }); + + it('should create a person', async () => { + await PersonRepository.createPerson({ + first_name: 'Jennifer', + last_name: 'Aniston', + gender: 'woman', + }); + + expect(await PersonRepository.findPeople({ first_name: 'Jennifer' })).toHaveLength(1); + }); + + it('should create multiple persons', async () => { + const created = await PersonRepository.createPersons([ + { first_name: 'Brad', last_name: 'Pitt', gender: 'man' }, + { first_name: 'Angelina', last_name: 'Jolie', gender: 'woman' }, + ]); + await expect(PersonRepository.findPeople({ first_name: 'Brad' })).resolves.toBeTruthy(); + await expect(PersonRepository.findPeople({ first_name: 'Angelina' })).resolves.toBeTruthy(); + }); + + it('should delete a person with a given id', async () => { + await PersonRepository.deletePerson(123); + + expect(await PersonRepository.findPersonById(123)).toBeUndefined(); + }); +}); diff --git a/packages/dialects/sql.js/test/getting-started/person-repository.ts b/packages/dialects/sql.js/test/getting-started/person-repository.ts new file mode 100644 index 000000000..f062a816c --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/person-repository.ts @@ -0,0 +1,48 @@ +import type { PersonUpdate, Person, NewPerson } from './types'; + +import { db } from './database'; + +export async function findPersonById(id: number) { + return await db.selectFrom('person').where('id', '=', id).selectAll().executeTakeFirst(); +} + +export async function findPeople(criteria: Partial) { + let query = db.selectFrom('person'); + + if (criteria.id) { + query = query.where('id', '=', criteria.id); // Kysely is immutable, you must re-assign! + } + + if (criteria.first_name) { + query = query.where('first_name', '=', criteria.first_name); + } + + if (criteria.last_name !== undefined) { + query = query.where('last_name', criteria.last_name === null ? 'is' : '=', criteria.last_name); + } + + if (criteria.gender) { + query = query.where('gender', '=', criteria.gender); + } + + if (criteria.created_at) { + query = query.where('created_at', '=', criteria.created_at); + } + + return await query.selectAll().execute(); +} + +export async function updatePerson(id: number, updateWith: PersonUpdate) { + await db.updateTable('person').set(updateWith).where('id', '=', id).execute(); +} + +export async function createPerson(person: NewPerson) { + return await db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); +} + +export async function createPersons(persons: NewPerson[]) { + return await db.insertInto('person').values(persons).executeTakeFirstOrThrow(); +} +export async function deletePerson(id: number) { + return await db.deleteFrom('person').where('id', '=', id).returningAll().executeTakeFirst(); +} diff --git a/packages/dialects/sql.js/test/getting-started/types.ts b/packages/dialects/sql.js/test/getting-started/types.ts new file mode 100644 index 000000000..abe8bee28 --- /dev/null +++ b/packages/dialects/sql.js/test/getting-started/types.ts @@ -0,0 +1,53 @@ +import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely'; + +export interface Database { + person: PersonTable; + pet: PetTable; +} + +// This interface describes the `person` table to Kysely. Table +// interfaces should only be used in the `Database` type above +// and never as a result type of a query!. See the `Person`, +// `NewPerson` and `PersonUpdate` types below. +export interface PersonTable { + // Columns that are generated by the database should be marked + // using the `Generated` type. This way they are automatically + // made optional in inserts and updates. + id: Generated; + + first_name: string; + gender: 'man' | 'woman' | 'other'; + + // If the column is nullable in the database, make its type nullable. + // Don't use optional properties. Optionality is always determined + // automatically by Kysely. + last_name: string | null; + + // You can specify a different type for each operation (select, insert and + // update) using the `ColumnType` + // wrapper. Here we define a column `created_at` that is selected as + // a `Date`, can optionally be provided as a `string` in inserts and + // can never be updated: + created_at: ColumnType; +} + +// You should not use the table schema interfaces directly. Instead, you should +// use the `Selectable`, `Insertable` and `Updateable` wrappers. These wrappers +// make sure that the correct types are used in each operation. +// +// Most of the time you should trust the type inference and not use explicit +// types at all. These types can be useful when typing function arguments. +export type Person = Selectable; +export type NewPerson = Insertable; +export type PersonUpdate = Updateable; + +export interface PetTable { + id: Generated; + name: string; + owner_id: number; + species: 'dog' | 'cat'; +} + +export type Pet = Selectable; +export type NewPet = Insertable; +export type PetUpdate = Updateable; diff --git a/packages/dialects/sql.js/tsconfig.json b/packages/dialects/sql.js/tsconfig.json new file mode 100644 index 000000000..2125902f5 --- /dev/null +++ b/packages/dialects/sql.js/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/dialects/sql.js/tsup.config.ts b/packages/dialects/sql.js/tsup.config.ts new file mode 100644 index 000000000..5a74a9dd1 --- /dev/null +++ b/packages/dialects/sql.js/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + outDir: 'dist', + splitting: false, + sourcemap: true, + clean: true, + dts: true, + format: ['cjs', 'esm'], +}); diff --git a/packages/dialects/sql.js/vitest.config.ts b/packages/dialects/sql.js/vitest.config.ts new file mode 100644 index 000000000..23c01e729 --- /dev/null +++ b/packages/dialects/sql.js/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from '@zenstackhq/vitest-config/base'; + +export default mergeConfig(base, defineConfig({}));