Type-Safe SQL with Kysely
Supabase Edge Functions can connect directly to your Postgres database to execute SQL queries. Kysely is a type-safe and autocompletion-friendly typescript SQL query builder.
Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.
Code#
Find the example on GitHub
Get your database connection credentials from your Supabase Dashboard and store them in an .env
file:
DB_HOSTNAME=
DB_PASSWORD=
DB_SSL_CERT="-----BEGIN CERTIFICATE-----
GET YOUR CERT FROM YOUR PROJECT DASHBOARD
-----END CERTIFICATE-----"
Create a DenoPostgresDriver.ts
file to manage the connection to Postgres via deno-postgres:
import {
CompiledQuery,
DatabaseConnection,
Driver,
PostgresCursorConstructor,
QueryResult,
TransactionSettings,
} from 'https://esm.sh/kysely@0.23.4'
import { freeze, isFunction } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/object-utils.js'
import { extendStackTrace } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/stack-trace-utils.js'
import { Pool, PoolClient } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
export interface PostgresDialectConfig {
pool: Pool | (() => Promise<Pool>)
cursor?: PostgresCursorConstructor
onCreateConnection?: (connection: DatabaseConnection) => Promise<void>
}
const PRIVATE_RELEASE_METHOD = Symbol()
export class PostgresDriver implements Driver {
readonly #config: PostgresDialectConfig
readonly #connections = new WeakMap<PoolClient, DatabaseConnection>()
#pool?: Pool
constructor(config: PostgresDialectConfig) {
this.#config = freeze({ ...config })
}
async init(): Promise<void> {
this.#pool = isFunction(this.#config.pool) ? await this.#config.pool() : this.#config.pool
}
async acquireConnection(): Promise<DatabaseConnection> {
const client = await this.#pool!.connect()
let connection = this.#connections.get(client)
if (!connection) {
connection = new PostgresConnection(client, {
cursor: this.#config.cursor ?? null,
})
this.#connections.set(client, connection)
// The driver must take care of calling `onCreateConnection` when a new
// connection is created. The `pg` module doesn't provide an async hook
// for the connection creation. We need to call the method explicitly.
if (this.#config?.onCreateConnection) {
await this.#config.onCreateConnection(connection)
}
}
return connection
}
async beginTransaction(
connection: DatabaseConnection,
settings: TransactionSettings
): Promise<void> {
if (settings.isolationLevel) {
await connection.executeQuery(
CompiledQuery.raw(`start transaction isolation level ${settings.isolationLevel}`)
)
} else {
await connection.executeQuery(CompiledQuery.raw('begin'))
}
}
async commitTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw('commit'))
}
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw('rollback'))
}
async releaseConnection(connection: PostgresConnection): Promise<void> {
connection[PRIVATE_RELEASE_METHOD]()
}
async destroy(): Promise<void> {
if (this.#pool) {
const pool = this.#pool
this.#pool = undefined
await pool.end()
}
}
}
interface PostgresConnectionOptions {
cursor: PostgresCursorConstructor | null
}
class PostgresConnection implements DatabaseConnection {
#client: PoolClient
#options: PostgresConnectionOptions
constructor(client: PoolClient, options: PostgresConnectionOptions) {
this.#client = client
this.#options = options
}
async executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
try {
const result = await this.#client.queryObject<O>(compiledQuery.sql, [
...compiledQuery.parameters,
])
if (
result.command === 'INSERT' ||
result.command === 'UPDATE' ||
result.command === 'DELETE'
) {
const numAffectedRows = BigInt(result.rowCount || 0)
return {
numUpdatedOrDeletedRows: numAffectedRows,
numAffectedRows,
rows: result.rows ?? [],
} as any
}
return {
rows: result.rows ?? [],
}
} catch (err) {
throw extendStackTrace(err, new Error())
}
}
async *streamQuery<O>(
_compiledQuery: CompiledQuery,
chunkSize: number
): AsyncIterableIterator<QueryResult<O>> {
if (!this.#options.cursor) {
throw new Error(
"'cursor' is not present in your postgres dialect config. It's required to make streaming work in postgres."
)
}
if (!Number.isInteger(chunkSize) || chunkSize <= 0) {
throw new Error('chunkSize must be a positive integer')
}
// stream not available
return null
}
[PRIVATE_RELEASE_METHOD](): void {
this.#client.release()
}
}
Create an index.ts
file to execute a query on incoming requests:
import { serve } from 'https://deno.land/std@0.175.0/http/server.ts'
import { Pool } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
import {
Kysely,
Generated,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
} from 'https://esm.sh/kysely@0.23.4'
import { PostgresDriver } from './DenoPostgresDriver.ts'
console.log(`Function "kysely-postgres" up and running!`)
interface AnimalTable {
id: Generated<bigint>
animal: string
created_at: Date
}
// Keys of this interface are table names.
interface Database {
animals: AnimalTable
}
// Create a database pool with one connection.
const pool = new Pool(
{
tls: { caCertificates: [Deno.env.get('DB_SSL_CERT')!] },
database: 'postgres',
hostname: Deno.env.get('DB_HOSTNAME'),
user: 'postgres',
port: 5432,
password: Deno.env.get('DB_PASSWORD'),
},
1
)
// You'd create one of these when you start your app.
const db = new Kysely<Database>({
dialect: {
createAdapter() {
return new PostgresAdapter()
},
createDriver() {
return new PostgresDriver({ pool })
},
createIntrospector(db: Kysely<unknown>) {
return new PostgresIntrospector(db)
},
createQueryCompiler() {
return new PostgresQueryCompiler()
},
},
})
serve(async (_req) => {
try {
// Run a query
const animals = await db.selectFrom('animals').select(['id', 'animal', 'created_at']).execute()
// Neat, it's properly typed \o/
console.log(animals[0].created_at.getFullYear())
// Encode the result as pretty printed JSON
const body = JSON.stringify(
animals,
(key, value) => (typeof value === 'bigint' ? value.toString() : value),
2
)
// Return the response with the correct content type header
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
})
} catch (err) {
console.error(err)
return new Response(String(err?.message ?? err), { status: 500 })
}
})