From 794729b660e4450d2455ad5b0a258112194a3c8d Mon Sep 17 00:00:00 2001 From: nuzzlet Date: Thu, 9 Jan 2025 12:02:20 -0500 Subject: [PATCH] feat: Added useSupabaseQuery to simplify fetching --- .../4.usage/composables/useSupabaseQuery.md | 41 ++++++ src/runtime/composables/useSupabaseQuery.ts | 119 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 docs/content/4.usage/composables/useSupabaseQuery.md create mode 100644 src/runtime/composables/useSupabaseQuery.ts diff --git a/docs/content/4.usage/composables/useSupabaseQuery.md b/docs/content/4.usage/composables/useSupabaseQuery.md new file mode 100644 index 000000000..753f92e25 --- /dev/null +++ b/docs/content/4.usage/composables/useSupabaseQuery.md @@ -0,0 +1,41 @@ +--- +title: useSupabaseQuery +description: Abstraction around the Supabase select API similar to useAsyncData +--- + +This composable is using [postgres-js](https://github.com/supabase/postgrest-js) under the hood. + +## Usage + +The useSupabaseQuery composable is wrapping the methods and return types of `useSupabaseClient().schema('...').from('...').select('...')`. For a more convinient developer experience similar to using useAsyncData. + +```vue + +``` +## Options +### Count +When using `{count: 'exact' | 'estimated' | 'planned'}` an additional count ref will be returned with a number. +### Single +When `single: true` .single() is automatically called, and data is properly typed for a single result instead of an Array +### Limit +When specifying a `limit`, an additional loadMore object is returned which will make an additional request using `.range(data.value.length, limit)`, and push it to `data.value` +### Schema +A schema can be provied, although `public` is already selected as the default. +## Typescript + +Database typings are handled automatically. + +You can also pass Database typings and Schema manually: + +```vue + +``` diff --git a/src/runtime/composables/useSupabaseQuery.ts b/src/runtime/composables/useSupabaseQuery.ts new file mode 100644 index 000000000..c871d8c41 --- /dev/null +++ b/src/runtime/composables/useSupabaseQuery.ts @@ -0,0 +1,119 @@ +import type { PostgrestError, PostgrestFilterBuilder, PostgrestTransformBuilder, UnstableGetResult } from '@supabase/postgrest-js' +import type { GenericSchema, GenericTable, GenericView } from '@supabase/supabase-js/dist/module/lib/types' +import type { AsyncDataRequestStatus } from 'nuxt/app' +import { reactive, toRefs, watchEffect, type Ref } from 'vue' +import { useNuxtApp, useSupabaseClient } from '#imports' +import type { Database as ImportedDB } from '#build/types/supabase-database' + +type _StrippedPostgrestFilterBuilder, Result, RelationName, Relationships > = Omit, Exclude, 'order' | 'range' | 'limit'>> + +type StrippedPostgrestFilterBuilder, Result, RelationName, Relationships > = { + [K in keyof _StrippedPostgrestFilterBuilder]: PostgrestFilterBuilder[K] +} + +export function useSupabaseQuery< + const Database extends Record & ImportedDB, + _single extends boolean = false, + _count extends 'exact' | 'planned' | 'estimated' | undefined = undefined, + _limit extends number | undefined = undefined, + + const SchemaName extends string & keyof Database = 'public', + const Schema extends GenericSchema = Database[SchemaName], + const RelationName extends string = string & (keyof Database[SchemaName]['Tables'] | keyof Database[SchemaName]['Views']), + const Relation extends GenericTable | GenericView = RelationName extends keyof Schema['Tables'] ? Schema['Tables'][RelationName] : RelationName extends keyof Schema['Views'] ? Schema['Views'][RelationName] : never, + const Query extends string = '*', + ResultOne = UnstableGetResult, + _returning = { + data: Ref<_single extends true ? ResultOne : ResultOne[]> + error: Ref + status: Ref + count: _count extends undefined ? undefined : Ref + loadMore: _limit extends undefined ? undefined : (() => Promise) + }, +>( + relation: RelationName, + query: Query, + filter: (builder: StrippedPostgrestFilterBuilder) => StrippedPostgrestFilterBuilder, + { single, count, limit, schema }: { + single?: _single + count?: _count + limit?: _limit + schema?: SchemaName + } = {}, +): Promise<_returning> & _returning { + const nuxtApp = useNuxtApp() + const client = useSupabaseClient() + + const asyncData = reactive({ status: 'idle', data: null, error: null }) + const returning = toRefs(asyncData) + + function makeRequest() { + const request = schema + ? client.schema(schema).from(relation).select(query, { count }) + : client.from(relation).select(query, { count }) + const filteredRequest = filter(request as unknown as StrippedPostgrestFilterBuilder) as unknown as PostgrestTransformBuilder + if (single) filteredRequest.single() + if (limit) filteredRequest.limit(limit) + return filteredRequest + } + + const req = makeRequest() + // @ts-expect-error Property 'url' is protected and only accessible within class 'PostgrestBuilder' and its subclasses. + const key = req.url.pathname + req.url.search + if (import.meta.browser) { + // Watch for changes + let reqInProgress: ReturnType + watchEffect(async () => { + if (reqInProgress) await reqInProgress + reqInProgress = handleRequest(makeRequest()) + }) + + // loadMore + if (limit && !single) + Object.assign(returning, { + async loadMore() { + if (!Array.isArray(asyncData.data)) throw new Error('asyncData.data is not an array, so more values cannot be loaded into it.') + asyncData.status = 'pending' + const { data, error, count } = await makeRequest().range(asyncData.data.length, asyncData.data.length + limit) + Object.assign(asyncData, { error, count }) + asyncData.data.push(data) + asyncData.status = error ? 'error' : 'success' + }, + }) + } + + async function handleRequest(request: ReturnType) { + asyncData.status = 'pending' + // @ts-expect-error Property 'url' is protected and only accessible within class 'PostgrestBuilder' and its subclasses. + const { data, error, count } = nuxtApp.payload.data[req.url.pathname + req.url.search] || await request + Object.assign(asyncData, { data, error, count }) + asyncData.status = error ? 'error' : 'success' + return returning + } + + const promise = new Promise(resolve => handleRequest(req).then(resolve)) + + if (import.meta.server) { + promise.finally(() => nuxtApp.payload.data[key] ??= returning) + nuxtApp.hook('app:created', async () => { await promise }) + } + + return Object.assign(promise, returning) +} + +export interface useSupabaseQueryOptions< + _single extends boolean = false, + _count extends 'exact' | 'planned' | 'estimated' | undefined = undefined, + _limit extends number | undefined = undefined, + _schema extends string = 'public', +> { + // immediate?: AsyncDataOptions['immediate'] + // deep?: AsyncDataOptions['deep'] + // lazy?: AsyncDataOptions['lazy'] + // server?: AsyncDataOptions['server'] + // watch?: AsyncDataOptions['watch'] + single?: _single + schema?: _schema + limit?: _limit + count?: _count +}