Skip to content

Instantly share code, notes, and snippets.

@danielres
Last active December 16, 2023 22:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save danielres/f0bc46f2bd2620f2c975d56944fd5e0b to your computer and use it in GitHub Desktop.
Save danielres/f0bc46f2bd2620f2c975d56944fd5e0b to your computer and use it in GitHub Desktop.
Generate pocketbase record options automatically (fields+expand) from zod schemas.

extractRecordOptions(anyZodSchema) generates automatically a correct object { fields: "...", expand: "..." } which can be passed to the pocketbase sdk.

We can use the same schema to parse/validate and strongly type the response.

Other tools

Tradeoffs

Contrary to the tools above, this approach is frontend-only. You have to manually keep your zod schemas in sync with your backend collections. However, as I use zod already, I get this for "free", without having to rely on any extra dependencies.

This level of type safety fullfills my needs, but YMMV.

Example

  import { page } from '$app/stores'
  import { getAppContext } from '$lib/appContext'
  import { extractRecordOptions } from '$lib/utils/zod'
  import z from 'zod'

  const app = getAppContext()

  const traitSchema = z.object({
    levels: z.record(z.number()),
    desc: z.string(),
    expand: z.object({
      topic: z.object({
        label: z.string(),
        slug: z.string(),
      }),
    }),
  })

  const traitsPromise = (postSlug: string) =>
    app.pb
      .collection<z.infer<typeof traitSchema>>('traits')
      .getFullList({
        ...extractRecordOptions(traitSchema),
        filter: app.pb.filter('post.slug={:slug}', { slug: postSlug }),
      })
      .then(z.array(traitSchema).parse)
// $lib/utils/zod.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { extractRecordOptions } from './zod'
describe('extractRecordOptions(zodSchema)', () => {
describe('with one expand', () => {
const schema = z.object({
label: z.string(),
slug: z.string(),
expand: z.object({
space: z.object({
label: z.string(),
slug: z.string(),
}),
}),
})
it('extracts the fields', () => {
const actual = extractRecordOptions(schema).fields
const expected = ['label', 'slug', 'expand.space.label', 'expand.space.slug']
expect(actual.split(',')).toEqual(expected)
})
it('extracts the expand keys', () => {
const actual = extractRecordOptions(schema).expand
const expected = 'space'
expect(actual).toEqual(expected)
})
})
describe('with nested expands', () => {
const schema = z.object({
username: z.string(),
email: z.string(),
expand: z.object({
posts: z.array(
z.object({
slug: z.string(),
expand: z.object({
comments: z.array(
z.object({
label: z.string(),
expand: z.object({
author: z.object({
slug: z.string(),
}),
}),
})
),
}),
})
),
profiles: z.array(
z.object({
label: z.string(),
slug: z.string(),
expand: z.object({
space: z.object({
label: z.string(),
slug: z.string(),
}),
}),
})
),
}),
})
it('extracts the fields', () => {
const actual = extractRecordOptions(schema).fields
const expected = [
'username',
'email',
'expand.posts.slug',
'expand.posts.expand.comments.label',
'expand.posts.expand.comments.expand.author.slug',
'expand.profiles.label',
'expand.profiles.slug',
'expand.profiles.expand.space.label',
'expand.profiles.expand.space.slug',
]
expect(actual.split(',')).toEqual(expected)
})
it('extracts the expand keys', () => {
const actual = extractRecordOptions(schema).expand
const expected = 'posts.comments.author,profiles.space'
expect(actual).toEqual(expected)
})
})
})
// $lib/utils/zod.ts
import { ZodObject, z, type ZodSchema, type ZodTypeAny, ZodArray } from 'zod'
import { renderDate } from '$lib/utils/date'
export const string2PrettyDate = z
.string()
.transform((str) => renderDate(new Date(str), { time: false }))
export function extractRecordOptions(schema: ZodSchema) {
const fields = extractFields(schema)
return {
expand: extractExpandKeys(fields).join(','),
fields: fields.join(','),
}
}
export function extractFields(schema: ZodSchema): string[] {
function traverse(obj: ZodTypeAny, prefix = ''): string[] {
if (!isZodObject(obj)) return []
return Object.keys(obj.shape).reduce<string[]>((acc, key) => {
const field = obj.shape[key]
const newPrefix = prefix ? prefix + '.' + key : key
if (isZodObject(field)) return acc.concat(traverse(field, newPrefix))
if (isZodArray(field) && isZodObject(field.element))
return acc.concat(traverse(field.element, newPrefix))
return acc.concat(newPrefix)
}, [])
}
return traverse(schema)
}
function extractExpandKeys(fields: string[]): string[] {
const expandKeyGroups = fields
.filter((field) => field.includes('expand.'))
.map((field) =>
field
.split('.')
.reduce((acc: string[], curr, index, arr) => {
if (curr === 'expand' && index < arr.length - 1) acc.push(arr[index + 1])
return acc
}, [])
.join('.')
)
// Remove subpaths that are implied in longer subpaths
const filteredGroups = expandKeyGroups.filter(
(group) =>
!expandKeyGroups.some((otherGroup) => otherGroup.includes(group) && otherGroup !== group)
)
// Remove duplicates
return Array.from(new Set(filteredGroups))
}
function isZodArray(field: ZodTypeAny): field is ZodArray<ZodTypeAny> {
return field instanceof ZodArray
}
function isZodObject(field: ZodTypeAny): field is ZodObject<{ [key: string]: ZodTypeAny }> {
return field instanceof ZodObject
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment