Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handling relationships #134

Open
RiftLurker opened this issue May 6, 2024 · 4 comments
Open

Handling relationships #134

RiftLurker opened this issue May 6, 2024 · 4 comments
Labels
enhancement New feature or request

Comments

@RiftLurker
Copy link

RiftLurker commented May 6, 2024

Are there any examples on how relationships between Collections are supposed to be handled? The configs prepare hook briefly mentions them but doesn't give any more details.
It's definitely possible to add them in there but this sounds like it would get cumbersome and be prone to errors.

Ideally I would like to define them directly in the schema, similar to Astro's content references to easily ensure data integrity.

@zce
Copy link
Owner

zce commented May 8, 2024

First of all, Velite does not want to intrude into the user's runtime code. Its purpose is only the intermediate process from content to data layer. Velite will not interfere how users use the data generated by Velite.

e.g.

const authors = {
  name: 'Author',
  pattern: 'authors/index.yml',
  schema: s
    .object({
      name: s.unique('authors'),
      slug: slug,
      avatar: s.image().optional()
    })
}

const posts = {
  name: 'Post',
  pattern: 'posts/**/*.md',
  schema: s
    .object({
      title: title,
      slug: s.slug('post'),
      description: paragraph.optional(),
      author: s.string(), // <= author.name
      content: s.markdown()
    })
}

in your app

import { posts, authors } from '.velite'

const hydratedPosts = posts.map(item => ({ ... posts, author: authors.find(a => a.name === item.author) })

You can query the data in a functional way, and you can even let Velite help you write it to the database and then use SQL to query the data.

Secondly, from the perspective of the data source, dealing with related data is indeed a common requirement. I am also considering adding a s.reference() schema to fulfill this need.

@zce
Copy link
Owner

zce commented May 8, 2024

here is my previous helper for access generated data

import { authors, categories, pages, plans, posts, tags } from '#/.velite'

import type { Author, Category, Page, Plan, Post, Tag } from '#/.velite'

type Taxonomy = {
  authors: { [P in 'name' | 'slug' | 'email' | 'avatar' | 'bio' | 'permalink']: Author[P] }[]
  categories: { [P in 'name' | 'slug' | 'permalink']: Category[P] }[]
  tags: { [P in 'name' | 'slug' | 'permalink']: Tag[P] }[]
}

type Filter<T> = (value: T, index: number, array: T[]) => boolean
type Sorter<T> = (a: T, b: T) => number

const available = (item: { draft: boolean; private: boolean }) => process.env.NODE_ENV !== 'production' || (!item.draft && !item.private)

export const filters = {
  none: (): boolean => true,
  featured: (item: { featured: boolean }) => item.featured
}

export const sorters = {
  dateAsc: <I extends { date: string }>(a: I, b: I): number => (a.date > b.date ? 1 : -1),
  dateDesc: <I extends { date: string }>(a: I, b: I): number => (a.date > b.date ? -1 : 1),
  nameAsc: <I extends { name: string }>(a: I, b: I): number => (a.name > b.name ? 1 : -1),
  nameDesc: <I extends { name: string }>(a: I, b: I): number => (a.name > b.name ? -1 : 1),
  priceAsc: <I extends { prices: { yearly: number } }>(a: I, b: I): number => (a.prices.yearly > b.prices.yearly ? 1 : -1),
  priceDesc: <I extends { prices: { yearly: number } }>(a: I, b: I): number => (a.prices.yearly > b.prices.yearly ? -1 : 1),
  countAsc: <I extends { count: { total: number } }>(a: I, b: I): number => (a.count.total > b.count.total ? 1 : -1),
  countDesc: <I extends { count: { total: number } }>(a: I, b: I): number => (a.count.total > b.count.total ? -1 : 1),
  titleAsc: <I extends { title: string }>(a: I, b: I): number => (a.title > b.title ? 1 : -1),
  titleDesc: <I extends { title: string }>(a: I, b: I): number => (a.title > b.title ? -1 : 1)
}

const pick = <T extends object, K extends keyof T>(obj: T, keys?: K[]): { [P in K]: T[P] } => {
  if (keys == null) return obj
  return Object.fromEntries(keys.map(k => [k, obj[k]])) as { [P in K]: T[P] }
}

const include = async <I extends keyof Taxonomy = never>(data: { [P in keyof Taxonomy]: string[] }, includes?: I[]): Promise<{ [P in I]: Taxonomy[P] }> => {
  if (includes == null) return {} as { [P in I]: Taxonomy[P] }
  const entities = await Promise.all(
    includes.map(async include => {
      if (include === 'authors') {
        return [
          include,
          (await getAuthors(['name', 'slug', 'email', 'avatar', 'bio', 'permalink'], i => data.authors.includes(i.name))) satisfies Taxonomy['authors']
        ]
      } else if (include === 'categories') {
        return [include, (await getCategories(['name', 'slug', 'permalink'], i => data.categories.includes(i.name))) satisfies Taxonomy['categories']]
      } else if (include === 'tags') {
        return [include, (await getTags(['name', 'slug', 'permalink'], i => data.tags.includes(i.name))) satisfies Taxonomy['tags']]
      }
      return [include, []]
    })
  )
  return Object.fromEntries(entities)
}

export const getAuthors = async <F extends keyof Author>(
  fields?: F[],
  filter: Filter<Author> = filters.none,
  limit: number = Infinity,
  offset: number = 0
): Promise<{ [P in F]: Author[P] }[]> => {
  return authors
    .filter(filter)
    .sort((a, b) => (a.count.total > b.count.total ? -1 : 1))
    .slice(offset, offset + limit)
    .map(author => pick(author, fields))
}

export const getAuthorsCount = async (filter: Filter<Author> = filters.none): Promise<number> => {
  return authors.filter(filter).length
}

export const getAuthor = async <F extends keyof Author>(filter: Filter<Author>, fields?: F[]): Promise<{ [P in F]: Author[P] } | undefined> => {
  const author = authors.find(filter)
  return author && pick(author, fields)
}

export const getAuthorByName = async <F extends keyof Author>(name: string, fields?: F[]): Promise<{ [P in F]: Author[P] } | undefined> => {
  return getAuthor(i => i.name === name, fields)
}

export const getAuthorBySlug = async <F extends keyof Author>(slug: string, fields?: F[]): Promise<{ [P in F]: Author[P] } | undefined> => {
  return getAuthor(i => i.slug === slug, fields)
}

export const getCategories = async <F extends keyof Category>(
  fields?: F[],
  filter: Filter<Category> = filters.none,
  limit: number = Infinity,
  offset: number = 0
): Promise<{ [P in F]: Category[P] }[]> => {
  return categories
    .filter(filter)
    .sort((a, b) => (a.count.total > b.count.total ? -1 : 1))
    .slice(offset, offset + limit)
    .map(author => pick(author, fields))
}

export const getCategoriesCount = async (filter: Filter<Category> = filters.none): Promise<number> => {
  return categories.filter(filter).length
}

export const getCategory = async <F extends keyof Category>(filter: Filter<Category>, fields?: F[]): Promise<{ [P in F]: Category[P] } | undefined> => {
  const category = categories.find(filter)
  return category && pick(category, fields)
}

export const getCategoryByName = async <F extends keyof Category>(name: string, fields?: F[]): Promise<{ [P in F]: Category[P] } | undefined> => {
  return getCategory(i => i.name === name, fields)
}

export const getCategoryBySlug = async <F extends keyof Category>(slug: string, fields?: F[]): Promise<{ [P in F]: Category[P] } | undefined> => {
  return getCategory(i => i.slug === slug, fields)
}

export const getTags = async <F extends keyof Tag>(
  fields?: F[],
  filter: Filter<Tag> = filters.none,
  limit: number = Infinity,
  offset: number = 0
): Promise<{ [P in F]: Tag[P] }[]> => {
  return tags
    .filter(filter)
    .sort((a, b) => (a.count.total > b.count.total ? -1 : 1))
    .slice(offset, offset + limit)
    .map(tag => pick(tag, fields))
}

export const getTagsCount = async (filter: Filter<Tag> = filters.none): Promise<number> => {
  return tags.filter(filter).length
}

export const getTag = async <F extends keyof Tag>(filter: Filter<Tag>, fields?: F[]): Promise<{ [P in F]: Tag[P] } | undefined> => {
  const tag = tags.find(filter)
  return tag && pick(tag, fields)
}

export const getTagByName = async <F extends keyof Tag>(name: string, fields?: F[]): Promise<{ [P in F]: Tag[P] } | undefined> => {
  return getTag(i => i.name === name, fields)
}

export const getTagBySlug = async <F extends keyof Tag>(slug: string, fields?: F[]): Promise<{ [P in F]: Tag[P] } | undefined> => {
  return getTag(i => i.slug === slug, fields)
}

export const getPages = async <F extends keyof Page>(
  fields?: F[],
  filter: Filter<Page> = filters.none,
  sorter: Sorter<Page> = sorters.titleAsc,
  limit: number = Infinity,
  offset: number = 0
): Promise<{ [P in F]: Page[P] }[]> => {
  return pages
    .filter(available)
    .filter(filter)
    .sort(sorter)
    .slice(offset, offset + limit)
    .map(page => pick(page, fields))
}

export const getPagesCount = async (filter: Filter<Page> = filters.none): Promise<number> => {
  return pages.filter(available).filter(filter).length
}

export const getPage = async <F extends keyof Page>(filter: Filter<Page>, fields?: F[]): Promise<{ [P in F]: Page[P] } | undefined> => {
  const page = pages.find(filter)
  return page && pick(page, fields)
}

export const getPageBySlug = async <F extends keyof Page>(slug: string, fields?: F[]): Promise<{ [P in F]: Page[P] } | undefined> => {
  return getPage(i => i.slug === slug, fields)
}

export const getPlans = async <F extends keyof Plan>(
  fields?: F[],
  filter: Filter<Plan> = filters.none,
  sorter: Sorter<Plan> = sorters.priceAsc,
  limit: number = Infinity,
  offset: number = 0
): Promise<{ [P in F]: Plan[P] }[]> => {
  return plans
    .filter(available)
    .filter(filter)
    .sort(sorter)
    .slice(offset, offset + limit)
    .map(plan => pick(plan, fields))
}

export const getPlansCount = async (filter: Filter<Plan> = filters.none): Promise<number> => {
  return plans.filter(available).filter(filter).length
}

export const getPlan = async <F extends keyof Plan>(filter: Filter<Plan>, fields?: F[]): Promise<{ [P in F]: Plan[P] } | undefined> => {
  const plan = plans.find(filter)
  return plan && pick(plan, fields)
}

export const getPlanBySlug = async <F extends keyof Plan>(slug: string, fields?: F[]): Promise<{ [P in F]: Plan[P] } | undefined> => {
  return getPlan(i => i.slug === slug, fields)
}

export const getPosts = async <F extends keyof Omit<Post, I>, I extends keyof Taxonomy = never>(
  fields?: F[],
  includes?: I[],
  filter: Filter<Post> = filters.none,
  sorter: Sorter<Post> = sorters.dateDesc,
  limit: number = Infinity,
  offset: number = 0
): Promise<({ [P in F]: Post[P] } & { [P in I]: Taxonomy[P] })[]> => {
  return Promise.all(
    posts
      .filter(available)
      .filter(filter)
      .sort(sorter)
      .slice(offset, offset + limit)
      .map(async post => ({ ...pick(post, fields), ...(await include(post, includes)) }))
  )
}

export const getPostsCount = async (filter: Filter<Post> = filters.none): Promise<number> => {
  return posts.filter(available).filter(filter).length
}

export const getPost = async <F extends keyof Omit<Post, I>, I extends keyof Taxonomy = never>(
  filter: Filter<Post>,
  fields?: F[],
  includes?: I[]
): Promise<({ [P in F]: Post[P] } & { [P in I]: Taxonomy[P] }) | undefined> => {
  const post = posts.find(filter)
  return post && { ...pick(post, fields), ...(await include(post, includes)) }
}

export const getPostBySlug = async <F extends keyof Omit<Post, I>, I extends keyof Taxonomy = never>(
  slug: string,
  fields?: F[],
  includes?: I[]
): Promise<({ [P in F]: Post[P] } & { [P in I]: Taxonomy[P] }) | undefined> => {
  return getPost(i => i.slug === slug, fields, includes)
}

@RiftLurker
Copy link
Author

I realize I wasn't quite clear in my initial message as I'm definitely most interested in the s.reference schema you mentioned. My brief attempts at implementing it myself didn't quite work out as well as I wanted due to cyclical type references.
Thank you for the snippet though.

@zce zce added the enhancement New feature or request label May 9, 2024
@RiftLurker
Copy link
Author

RiftLurker commented May 14, 2024

I've got a somewhat working solution, it is still very far from perfect but at least it'll let me start working on my project without having to worry about messed up data integrity.

import { ZodType, logger } from "velite";

let CACHE: Map<string, string> | null = null;

const PREFIX_REFERENCE_KEY = "references:key";
const PREFIX_REFERENCE_VALUE = "references:value";

export default function createReference(
  reference: string,
  key: ZodType,
  ref: ZodType,
): [ZodType, ZodType] {
  return [
    key.superRefine((value, { meta }) => {
      const { cache } = meta.config;
      cache.set(`${PREFIX_REFERENCE_KEY}:${reference}:${value}`, meta.path);

      // cache is not accessible in prepare hook
      if (CACHE === null) {
        CACHE = cache;
      }
    }),
    ref.superRefine((value, { meta }) => {
      const { cache } = meta.config;
      cache.set(`${PREFIX_REFERENCE_VALUE}:${reference}:${value}`, meta.path);

      // cache is not accessible in prepare hook
      if (CACHE === null) {
        CACHE = cache;
      }
    }),
  ];
}

export function validateReferences() {
  let error = false;

  for (const [key, file] of CACHE?.entries() ?? []) {
    if (!key.startsWith(PREFIX_REFERENCE_VALUE)) {
      continue;
    }

    const [_0, _1, reference, value] = key.split(":");
    if (!CACHE?.get(`${PREFIX_REFERENCE_KEY}:${reference}:${value}`)) {
      logger.error(
        `Referenced key ${value} does not exist for reference ${reference} (file: ${file})`,
      );
      error = true;
    }
  }

  if (error) {
    return false;
  }
}

And it's then being used like this:

const [authorKey, authorRef] = createReference(
  'authors',
  s.slug('authors'), // type of the key
  s.string(), // type of the reference
)

const authors = {
  name: 'Author',
  pattern: 'authors/index.yml',
  schema: s
    .object({
      name: s.unique('authors'),
      slug: authorKey,
      avatar: s.image().optional(),
    }),
};

const posts = {
  name: 'Post',
  pattern: 'posts/**/*.md',
  schema: s
    .object({
      title: title,
      slug: s.slug('post'),
      description: paragraph.optional(),
      author: authorRef,
      content: s.markdown(),
    }),
};

validateReferences is then being called as the prepare hook in the config:

export default defineConfig({
  // [...collections]
  prepare: validateReferences,
});

I'm still looking forward to an official solution on how to handle references!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants