diff --git a/src/loader/fetch-collection.ts b/src/loader/fetch-collection.ts index 74685a9..2d0af13 100644 --- a/src/loader/fetch-collection.ts +++ b/src/loader/fetch-collection.ts @@ -173,5 +173,10 @@ function buildSearchParams( searchParams.set("fields", combinedFields.join(",")); } + // Add expand parameter if specified + if (loaderOptions.experimental?.expand) { + searchParams.set("expand", loaderOptions.experimental.expand.join(",")); + } + return searchParams; } diff --git a/src/loader/fetch-entry.ts b/src/loader/fetch-entry.ts index 1403273..b8cbba8 100644 --- a/src/loader/fetch-entry.ts +++ b/src/loader/fetch-entry.ts @@ -31,6 +31,11 @@ export async function fetchEntry( entryUrl.searchParams.set("fields", combinedFields.join(",")); } + // Include expanded fields if option is set + if (options.experimental?.expand) { + entryUrl.searchParams.set("expand", options.experimental.expand.join(",")); + } + // Create the headers for the request to append the token (if available) const entryHeaders = new Headers(); if (token) { diff --git a/src/loader/handle-realtime-updates.ts b/src/loader/handle-realtime-updates.ts index 931a662..43d7518 100644 --- a/src/loader/handle-realtime-updates.ts +++ b/src/loader/handle-realtime-updates.ts @@ -20,6 +20,14 @@ export async function handleRealtimeUpdates( return false; } + // Check if a expand is set + if (options.experimental?.expand) { + // Updating an entry directly via realtime updates is not supported when using expand. + // This is because updates to a related entry cannot be tracked here. + // So all (updated) entries must be refreshed when a update is received. + return false; + } + // Check if data was provided via the refresh context if (!context.refreshContextData?.data) { return false; diff --git a/src/loader/loader.ts b/src/loader/loader.ts index d2788ee..56dd9df 100644 --- a/src/loader/loader.ts +++ b/src/loader/loader.ts @@ -17,10 +17,7 @@ export async function loader( context.logger.label = `pocketbase-loader:${options.collectionName}`; // Check if the collection should be refreshed. - const refresh = shouldRefresh( - context.refreshContextData, - options.collectionName - ); + const refresh = shouldRefresh(context.refreshContextData, options); if (refresh === "skip") { return; } diff --git a/src/schema/generate-schema.ts b/src/schema/generate-schema.ts index 82de308..61419f6 100644 --- a/src/schema/generate-schema.ts +++ b/src/schema/generate-schema.ts @@ -6,7 +6,7 @@ import { combineFieldsForRequest } from "../utils/combine-fields-for-request"; import { extractFieldNames } from "../utils/extract-field-names"; import { formatFields } from "../utils/format-fields"; import { getRemoteSchema } from "./get-remote-schema"; -import { parseSchema } from "./parse-schema"; +import { parseSchema, parseSingleOrMultipleValues } from "./parse-schema"; import { readLocalSchema } from "./read-local-schema"; import { transformFiles } from "./transform-files"; @@ -82,10 +82,19 @@ export async function generateSchema( checkUpdatedField(fields, collection, options); // Combine the basic schema with the parsed fields - const schema = z.object({ + const schemaShape = { ...BASIC_SCHEMA, ...fields - }); + }; + + // Generate schema for expanded fields + const expandSchema = await generateExpandSchema(collection, options, token); + if (expandSchema) { + // @ts-expect-error - "expand" is not known yet + schemaShape["expand"] = z.optional(z.object(expandSchema)); + } + + const schema = z.object(schemaShape); // Get all file fields const fileFields = collection.fields @@ -103,6 +112,76 @@ export async function generateSchema( ); } +/** + * Generate schema for expanded fields + */ +async function generateExpandSchema( + collection: PocketBaseCollection, + options: PocketBaseLoaderOptions, + token: string | undefined +): Promise | undefined> { + if ( + !options.experimental?.expand || + options.experimental.expand.length === 0 + ) { + return undefined; + } + + const expandedFields: Record = {}; + + for (const field of options.experimental.expand) { + const fields = field.split("."); + if (fields.length > 6) { + throw new Error( + `Expand value ${field} is not valid, since it exceeds 6 levels of depth. This is not supported by PocketBase.` + ); + } + + const currentField = fields.at(0); + if (!currentField) { + throw new Error(`Expand value ${field} contains an empty block`); + } + + const fieldDefinition = collection.fields.find( + (field) => field.name === currentField + ); + if ( + !fieldDefinition || + fieldDefinition.type !== "relation" || + !fieldDefinition.collectionId + ) { + throw new Error( + `The provided field ${currentField} in ${field} does not exist or has no associated collection. Thus the field cannot be expanded.` + ); + } + + const deeperFields = + fields.length > 1 ? [fields.slice(1).join(".")] : undefined; + const schema = await generateSchema( + { + ...options, + collectionName: fieldDefinition.collectionId, + experimental: { + ...options.experimental, + expand: deeperFields + } + }, + token + ); + + let fieldType = parseSingleOrMultipleValues(fieldDefinition, schema); + if (!fieldDefinition.required) { + fieldType = z.preprocess( + (val) => val || undefined, + z.optional(fieldType) + ); + } + expandedFields[currentField] = fieldType; + } + + return expandedFields; +} + /** * Check if the custom id field is present */ diff --git a/src/schema/parse-schema.ts b/src/schema/parse-schema.ts index d1ad7aa..37c3908 100644 --- a/src/schema/parse-schema.ts +++ b/src/schema/parse-schema.ts @@ -140,12 +140,12 @@ export function parseSchema( * * @returns The parsed field type */ -function parseSingleOrMultipleValues( +export function parseSingleOrMultipleValues( field: PocketBaseSchemaEntry, type: z.ZodType ): z.ZodType { // If the select allows multiple values, create an array of the enum - if (field.maxSelect === undefined || field.maxSelect === 1) { + if (field.maxSelect === undefined || field.maxSelect <= 1) { return type; } diff --git a/src/schema/read-local-schema.ts b/src/schema/read-local-schema.ts index 1ceb015..7775e19 100644 --- a/src/schema/read-local-schema.ts +++ b/src/schema/read-local-schema.ts @@ -27,7 +27,8 @@ export async function readLocalSchema( // Find and return the schema for the collection const schema = fileContent.data.find( - (collection) => collection.name === collectionName + (collection) => + collection.name === collectionName || collection.id === collectionName ); if (!schema) { diff --git a/src/types/pocketbase-loader-options.type.ts b/src/types/pocketbase-loader-options.type.ts index 4f05042..8e7414e 100644 --- a/src/types/pocketbase-loader-options.type.ts +++ b/src/types/pocketbase-loader-options.type.ts @@ -97,6 +97,33 @@ export interface PocketBaseLoaderBaseOptions { */ impersonateToken: string; }; + /** + * Experimental options for the loader. + * + * @experimental All of these options are experimental and may change in the future. + */ + experimental?: { + /** + * Array of relation field names to include (expand) when loading data from PocketBase. + * + * This is only reccomended to use with the live-loader. + * For the default build time loader using a separate collection instead is more efficient. + * + * Example: + * ```ts + * // config: + * expand: ['relatedField1', 'relatedField2'] + * + * // request + * `?expand=relatedField1,relatedField2` + * ``` + * + * @see {@link https://pocketbase.io/docs/api-records/#listsearch-records PocketBase documentation} for valid syntax + * + * @experimental Expand has many edge cases to consinder, especially regarding the schema generation and build cache. So this will be experimental for now. + */ + expand?: Array; + }; } /** diff --git a/src/types/pocketbase-schema.type.ts b/src/types/pocketbase-schema.type.ts index 360c6a2..8543791 100644 --- a/src/types/pocketbase-schema.type.ts +++ b/src/types/pocketbase-schema.type.ts @@ -55,7 +55,12 @@ export const pocketBaseSchemaEntry = z.object({ * Whether the field is updated when the entry is updated. * This is only present on "autodate" fields. */ - onUpdate: z.optional(z.boolean()) + onUpdate: z.optional(z.boolean()), + /** + * Id of the associated collection that the relation is referencing. + * This is only present on "relation" fields. + */ + collectionId: z.optional(z.string()) }); /** @@ -67,6 +72,10 @@ export type PocketBaseSchemaEntry = z.infer; * Schema for a PocketBase collection. */ export const pocketBaseCollection = z.object({ + /** + * Id of the collection. + */ + id: z.string(), /** * Name of the collection. */ diff --git a/src/utils/format-fields.ts b/src/utils/format-fields.ts index 4a78d42..1c154b7 100644 --- a/src/utils/format-fields.ts +++ b/src/utils/format-fields.ts @@ -2,10 +2,9 @@ import type { PocketBaseLoaderBaseOptions } from "../types/pocketbase-loader-opt /** * Format fields option into an array and validate for expand usage. - * Handles wildcard "*" and preserves excerpt field modifiers. * * @param fields The fields option (string or array) - * @returns Formatted fields array, or undefined if no fields specified or "*" wildcard is used + * @returns Formatted fields array, or undefined if no fields specified */ export function formatFields( fields: PocketBaseLoaderBaseOptions["fields"] @@ -26,17 +25,11 @@ export function formatFields( const hasExpand = fieldList.some((field) => field.includes("expand")); if (hasExpand) { console.warn( - 'The "expand" parameter is not currently supported by astro-loader-pocketbase and will be filtered out.' + 'The "expand" parameter is currently experimental in astro-loader-pocketbase.' ); fieldList = fieldList.filter((field) => !field.includes("expand")); } - // Check for "*" wildcard - if found anywhere, include all fields - const hasWildcard = fieldList.some((field) => field === "*"); - if (hasWildcard) { - return undefined; - } - return fieldList; } diff --git a/src/utils/should-refresh.ts b/src/utils/should-refresh.ts index 3761a32..197da87 100644 --- a/src/utils/should-refresh.ts +++ b/src/utils/should-refresh.ts @@ -1,11 +1,12 @@ import type { LoaderContext } from "astro/loaders"; +import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type"; /** * Checks if the collection should be refreshed. */ export function shouldRefresh( context: LoaderContext["refreshContextData"], - collectionName: string + options: Pick ): "refresh" | "skip" | "force" { // Check if the refresh was triggered by the `astro-integration-pocketbase` // and the correct metadata is provided. @@ -18,18 +19,26 @@ export function shouldRefresh( return "force"; } + // If no collection is was provided refresh just in case if (!context.collection) { return "refresh"; } + // Must refresh all collections when expand is set + if (options.experimental?.expand) { + return "refresh"; + } + // Check if the collection name matches the current collection. if (typeof context.collection === "string") { - return context.collection === collectionName ? "refresh" : "skip"; + return context.collection === options.collectionName ? "refresh" : "skip"; } // Check if the collection is included in the list of collections. if (Array.isArray(context.collection)) { - return context.collection.includes(collectionName) ? "refresh" : "skip"; + return context.collection.includes(options.collectionName) + ? "refresh" + : "skip"; } // Should not happen but return true to be safe.