diff --git a/apps/docs/components/FunctionsExamples.tsx b/apps/docs/components/FunctionsExamples.tsx index a741c0c1751..d9326121c34 100644 --- a/apps/docs/components/FunctionsExamples.tsx +++ b/apps/docs/components/FunctionsExamples.tsx @@ -6,6 +6,12 @@ const examples = [ description: 'Use the Supabase client inside your Edge Function.', href: 'https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/select-from-table-with-auth-rls/index.ts', }, + { + name: 'Type-Safe SQL with Kysely', + description: + 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.', + href: '/guides/functions/kysely-postgres', + }, { name: 'With CORS headers', description: 'Send CORS headers for invoking from the browser.', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index feda6da0a93..34c754fadcb 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -591,6 +591,7 @@ export const functions = { { name: 'Browserless.io', url: '/guides/functions/examples/screenshots' }, { name: 'OpenAI API', url: '/guides/functions/examples/openai' }, { name: 'Upstash Redis', url: '/guides/functions/examples/upstash-redis' }, + { name: 'Type-Safe SQL with Kysely', url: '/guides/functions/kysely-postgres' }, ], }, { diff --git a/apps/docs/pages/guides/functions.mdx b/apps/docs/pages/guides/functions.mdx index 34ec6137a52..6040506ef0a 100644 --- a/apps/docs/pages/guides/functions.mdx +++ b/apps/docs/pages/guides/functions.mdx @@ -45,6 +45,12 @@ export const examples = [ description: 'Use the Supabase client inside your Edge Function.', href: '/guides/functions/auth', }, + { + name: 'Type-Safe SQL with Kysely', + description: + 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.', + href: '/guides/functions/kysely-postgres', + }, { name: 'With CORS headers', description: 'Send CORS headers for invoking from the browser.', diff --git a/apps/docs/pages/guides/functions/kysely-postgres.mdx b/apps/docs/pages/guides/functions/kysely-postgres.mdx new file mode 100644 index 00000000000..e343a16f89b --- /dev/null +++ b/apps/docs/pages/guides/functions/kysely-postgres.mdx @@ -0,0 +1,282 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'kysely-postgres', + title: 'Type-Safe SQL with Kysely', + description: + 'Combining Kysely with Deno Postgres gives you a convenient developer experience for interacting directly with your Postgres database.', +} + +
+ +
+ +Supabase Edge Functions can [connect directly to your Postgres database](/guides/functions/connect-to-postgres) to execute SQL queries. [Kysely](https://github.com/kysely-org/kysely#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](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/kysely-postgres) + +Get your database connection credentials from your [Supabase Dashboard](https://app.supabase.com/project/_/settings/database) and store them in an `.env` file: + +```txt .env +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](https://deno-postgres.com/): + +```ts DenoPostgresDriver.ts +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) + cursor?: PostgresCursorConstructor + onCreateConnection?: (connection: DatabaseConnection) => Promise +} + +const PRIVATE_RELEASE_METHOD = Symbol() + +export class PostgresDriver implements Driver { + readonly #config: PostgresDialectConfig + readonly #connections = new WeakMap() + #pool?: Pool + + constructor(config: PostgresDialectConfig) { + this.#config = freeze({ ...config }) + } + + async init(): Promise { + this.#pool = isFunction(this.#config.pool) ? await this.#config.pool() : this.#config.pool + } + + async acquireConnection(): Promise { + 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 { + 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 { + await connection.executeQuery(CompiledQuery.raw('commit')) + } + + async rollbackTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('rollback')) + } + + async releaseConnection(connection: PostgresConnection): Promise { + connection[PRIVATE_RELEASE_METHOD]() + } + + async destroy(): Promise { + 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(compiledQuery: CompiledQuery): Promise> { + try { + const result = await this.#client.queryObject(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( + _compiledQuery: CompiledQuery, + chunkSize: number + ): AsyncIterableIterator> { + 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: + +```ts index.ts +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 + 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({ + dialect: { + createAdapter() { + return new PostgresAdapter() + }, + createDriver() { + return new PostgresDriver({ pool }) + }, + createIntrospector(db: Kysely) { + 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 }) + } +}) +``` + +export const Page = ({ children }) => + +export default Page diff --git a/examples/edge-functions/supabase/.env.local.example b/examples/edge-functions/supabase/.env.local.example index 0031244f9f3..de3b9e65137 100644 --- a/examples/edge-functions/supabase/.env.local.example +++ b/examples/edge-functions/supabase/.env.local.example @@ -10,8 +10,13 @@ IPINFO_TOKEN="your https://ipinfo.io token" # openai OPENAI_API_KEY="" -# postgres-on-the-edge -DATABASE_URL= +# postgres-on-the-edge & kysely-postgres +DB_HOSTNAME= +DB_PASSWORD= +DB_SSL_CERT="-----BEGIN CERTIFICATE----- +GET YOUR CERT FROM YOUR PROJECT DASHBOARD +https://app.supabase.com/project/_/settings/database +-----END CERTIFICATE-----" # puppeteer PUPPETEER_BROWSERLESS_IO_TOKEN= diff --git a/examples/edge-functions/supabase/functions/import_map.json b/examples/edge-functions/supabase/functions/import_map.json index be4b4d84118..f703de7b56c 100644 --- a/examples/edge-functions/supabase/functions/import_map.json +++ b/examples/edge-functions/supabase/functions/import_map.json @@ -11,10 +11,11 @@ "stripe": "https://esm.sh/stripe@11.1.0?target=deno", "sift": "https://deno.land/x/sift@0.6.0/mod.ts", "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.7.1", - "postgres": "https://deno.land/x/postgres@v0.14.2/mod.ts", + "postgres": "https://deno.land/x/postgres@v0.17.0/mod.ts", "puppeteer": "https://deno.land/x/puppeteer@16.2.0/mod.ts", "React": "https://esm.sh/react@18.2.0?deno-std=0.177.0", "upstash_redis": "https://deno.land/x/upstash_redis@v1.19.3/mod.ts", - "xhr_polyfill": "https://deno.land/x/xhr@0.3.0/mod.ts" + "xhr_polyfill": "https://deno.land/x/xhr@0.3.0/mod.ts", + "kysely": "https://esm.sh/kysely@0.23.4" } } diff --git a/examples/edge-functions/supabase/functions/kysely-postgres/DenoPostgresDriver.ts b/examples/edge-functions/supabase/functions/kysely-postgres/DenoPostgresDriver.ts new file mode 100644 index 00000000000..970e2f2f625 --- /dev/null +++ b/examples/edge-functions/supabase/functions/kysely-postgres/DenoPostgresDriver.ts @@ -0,0 +1,152 @@ +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) + cursor?: PostgresCursorConstructor + onCreateConnection?: (connection: DatabaseConnection) => Promise +} + +const PRIVATE_RELEASE_METHOD = Symbol() + +export class PostgresDriver implements Driver { + readonly #config: PostgresDialectConfig + readonly #connections = new WeakMap() + #pool?: Pool + + constructor(config: PostgresDialectConfig) { + this.#config = freeze({ ...config }) + } + + async init(): Promise { + this.#pool = isFunction(this.#config.pool) ? await this.#config.pool() : this.#config.pool + } + + async acquireConnection(): Promise { + 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 { + 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 { + await connection.executeQuery(CompiledQuery.raw('commit')) + } + + async rollbackTransaction(connection: DatabaseConnection): Promise { + await connection.executeQuery(CompiledQuery.raw('rollback')) + } + + async releaseConnection(connection: PostgresConnection): Promise { + connection[PRIVATE_RELEASE_METHOD]() + } + + async destroy(): Promise { + 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(compiledQuery: CompiledQuery): Promise> { + try { + const result = await this.#client.queryObject(compiledQuery.sql, [ + ...compiledQuery.parameters, + ]) + + if ( + result.command === 'INSERT' || + result.command === 'UPDATE' || + result.command === 'DELETE' + ) { + const numAffectedRows = BigInt(result.rowCount || 0) + + return { + // TODO: remove. + numUpdatedOrDeletedRows: numAffectedRows, + numAffectedRows, + rows: result.rows ?? [], + } as any + } + + return { + rows: result.rows ?? [], + } + } catch (err) { + throw extendStackTrace(err, new Error()) + } + } + + async *streamQuery( + _compiledQuery: CompiledQuery, + chunkSize: number + ): AsyncIterableIterator> { + 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() + } +} diff --git a/examples/edge-functions/supabase/functions/kysely-postgres/index.ts b/examples/edge-functions/supabase/functions/kysely-postgres/index.ts new file mode 100644 index 00000000000..4ce01728ddb --- /dev/null +++ b/examples/edge-functions/supabase/functions/kysely-postgres/index.ts @@ -0,0 +1,90 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +import { serve } from 'std/server' +import { Pool } from 'postgres' +import { + Kysely, + Generated, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, +} from 'kysely' +import { PostgresDriver } from './DenoPostgresDriver.ts' + +console.log(`Function "kysely-postgres" up and running!`) + +interface AnimalTable { + id: Generated + 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({ + dialect: { + createAdapter() { + return new PostgresAdapter() + }, + createDriver() { + // You need a driver to be able to execute queries. In this example + // we use the dummy driver that never does anything. + return new PostgresDriver({ pool }) + }, + createIntrospector(db: Kysely) { + 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 }) + } +}) + +// To invoke: navigate to http://localhost:54321/functions/v1/kysely-postgres diff --git a/examples/edge-functions/supabase/functions/postgres-on-the-edge/index.ts b/examples/edge-functions/supabase/functions/postgres-on-the-edge/index.ts index 871d24679fe..2c950dfef4a 100644 --- a/examples/edge-functions/supabase/functions/postgres-on-the-edge/index.ts +++ b/examples/edge-functions/supabase/functions/postgres-on-the-edge/index.ts @@ -1,11 +1,18 @@ -import * as postgres from 'postgres' +import { Pool } from 'postgres' import { serve } from 'std/server' -// Get the connection string from the environment variable "DATABASE_URL" -const databaseUrl = Deno.env.get('DATABASE_URL')! - -// Create a database pool with three connections that are lazily established -const pool = new postgres.Pool(databaseUrl, 3, true) +// Create a database pool with one connection. +const pool = new Pool( + { + tls: { enabled: false }, + database: 'postgres', + hostname: Deno.env.get('DB_HOSTNAME'), + user: 'postgres', + port: 5432, + password: Deno.env.get('DB_PASSWORD'), + }, + 1 +) serve(async (_req) => { try { @@ -16,7 +23,6 @@ serve(async (_req) => { // Run a query const result = await connection.queryObject`SELECT * FROM animals` const animals = result.rows // [{ id: 1, name: "Lion" }, ...] - console.log(animals) // Encode the result as pretty printed JSON const body = JSON.stringify(