diff --git a/README.md b/README.md index 0834515..cec311a 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,50 @@ const blogLive = defineLiveCollection({ }); ``` +### Expanding relations + +The live loader supports expanding relation fields directly in the API request, which will include the related records in the response. +This is useful when you need to access related data without making additional requests. + +```ts +const blogLive = defineLiveCollection({ + loader: experimentalPocketbaseLiveLoader({ + ...options, + experimental: { + // Expand single relation field + expand: ["author"] + + // Expand multiple relation fields + expand: ["author", "category"] + + // Expand nested relations (up to 6 levels deep) + expand: ["author.profile", "category.parent"] + } + }) +}); +``` + +When you fetch entries with expanded relations, the related records will be available in the `expand` property: + +```ts +const entry = await getLiveEntry("blogLive", { id: "" }); + +// Access expanded relation data +console.log(entry.expand.author.name); +console.log(entry.expand.category.name); + +// Access nested expanded relations +console.log(entry.expand.author.expand.profile.bio); +``` + +> [!NOTE] +> The expand parameter: +> +> - Supports up to 6 levels of nested relations (enforced by PocketBase) +> - Must use separate array entries for each field (e.g., `["author", "category"]` not `["author,category"]`) +> - Is not compatible with the `fields` option (yet) +> - Does not support schema generation (yet) - expanded data will have `unknown | undefined` types + ### Error handling The live content loader follows Astro's standard error handling conventions for live collections. For more information on how to handle errors in your components, see the [Astro documentation on error handling](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/#error-handling). diff --git a/package-lock.json b/package-lock.json index 020fd20..93d6320 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astro-loader-pocketbase", - "version": "2.9.0", + "version": "2.10.0-live-expand.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astro-loader-pocketbase", - "version": "2.9.0", + "version": "2.10.0-live-expand.1", "license": "MIT", "devDependencies": { "@commitlint/cli": "20.1.0", diff --git a/package.json b/package.json index 89eb05b..d63b8eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astro-loader-pocketbase", - "version": "2.9.0", + "version": "2.10.0-live-expand.1", "description": "A content loader for Astro that uses the PocketBase API", "keywords": [ "astro", diff --git a/release.config.cjs b/release.config.cjs index 5060161..7bf374d 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -17,7 +17,7 @@ if (customTag) { branches.push({ name: branch, channel: customTag, - prerelease: true + prerelease: customTag }); } diff --git a/src/loader/fetch-collection.ts b/src/loader/fetch-collection.ts index 74685a9..5b92f0b 100644 --- a/src/loader/fetch-collection.ts +++ b/src/loader/fetch-collection.ts @@ -9,8 +9,12 @@ import { } from "../types/pocketbase-api-response.type"; import type { PocketBaseEntry } from "../types/pocketbase-entry.type"; import type { ExperimentalPocketBaseLiveLoaderCollectionFilter } from "../types/pocketbase-live-loader-filter.type"; -import type { PocketBaseLoaderBaseOptions } from "../types/pocketbase-loader-options.type"; +import type { + ExperimentalPocketBaseLiveLoaderOptions, + PocketBaseLoaderOptions +} from "../types/pocketbase-loader-options.type"; import { combineFieldsForRequest } from "../utils/combine-fields-for-request"; +import { formatExpand } from "../utils/format-expand"; import { formatFields } from "../utils/format-fields"; /** @@ -28,7 +32,7 @@ export type CollectionFilter = { * Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination. */ export async function fetchCollection( - options: PocketBaseLoaderBaseOptions, + options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions, chunkLoaded: (entries: Array) => Promise, token: string | undefined, collectionFilter: CollectionFilter | undefined @@ -124,7 +128,9 @@ export async function fetchCollection( * Build search parameters for the PocketBase collection request. */ function buildSearchParams( - loaderOptions: PocketBaseLoaderBaseOptions, + loaderOptions: + | PocketBaseLoaderOptions + | ExperimentalPocketBaseLiveLoaderOptions, combinedFields: Array | undefined, collectionFilter: CollectionFilter ): URLSearchParams { @@ -173,5 +179,15 @@ function buildSearchParams( searchParams.set("fields", combinedFields.join(",")); } + if (loaderOptions.experimental && "expand" in loaderOptions.experimental) { + const expandString = formatExpand( + loaderOptions.experimental.expand, + loaderOptions.collectionName + ); + if (expandString) { + searchParams.set("expand", expandString); + } + } + return searchParams; } diff --git a/src/loader/fetch-entry.ts b/src/loader/fetch-entry.ts index 1403273..0ee5a69 100644 --- a/src/loader/fetch-entry.ts +++ b/src/loader/fetch-entry.ts @@ -6,8 +6,12 @@ import { PocketBaseAuthenticationError } from "../types/errors"; import { pocketBaseErrorResponse } from "../types/pocketbase-api-response.type"; import type { PocketBaseEntry } from "../types/pocketbase-entry.type"; import { pocketBaseEntry } from "../types/pocketbase-entry.type"; -import type { ExperimentalPocketBaseLiveLoaderOptions } from "../types/pocketbase-loader-options.type"; +import type { + ExperimentalPocketBaseLiveLoaderOptions, + PocketBaseLoaderOptions +} from "../types/pocketbase-loader-options.type"; import { combineFieldsForRequest } from "../utils/combine-fields-for-request"; +import { formatExpand } from "../utils/format-expand"; import { formatFields } from "../utils/format-fields"; /** @@ -15,7 +19,7 @@ import { formatFields } from "../utils/format-fields"; */ export async function fetchEntry( id: string, - options: ExperimentalPocketBaseLiveLoaderOptions, + options: PocketBaseLoaderOptions | ExperimentalPocketBaseLiveLoaderOptions, token: string | undefined ): Promise { // Build the URL for the entry endpoint @@ -31,6 +35,17 @@ export async function fetchEntry( entryUrl.searchParams.set("fields", combinedFields.join(",")); } + // Add expand parameter if specified in experimental options + if (options.experimental && "expand" in options.experimental) { + const expandString = formatExpand( + options.experimental.expand, + options.collectionName + ); + if (expandString) { + entryUrl.searchParams.set("expand", expandString); + } + } + // Create the headers for the request to append the token (if available) const entryHeaders = new Headers(); if (token) { diff --git a/src/schema/generate-schema.ts b/src/schema/generate-schema.ts index 82de308..cf4730b 100644 --- a/src/schema/generate-schema.ts +++ b/src/schema/generate-schema.ts @@ -84,7 +84,11 @@ export async function generateSchema( // Combine the basic schema with the parsed fields const schema = z.object({ ...BASIC_SCHEMA, - ...fields + ...fields, + // Add expand field for live types only mode to support expanded relations + ...(options.experimental?.liveTypesOnly && { + expand: z.optional(z.unknown()) + }) }); // Get all file fields diff --git a/src/types/errors.ts b/src/types/errors.ts index 3eaf4cf..2dbd9ee 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -17,3 +17,21 @@ export class PocketBaseAuthenticationError extends LiveCollectionError { ); } } + +/** + * Error thrown when there is a configuration issue with the loader. + */ +export class PocketBaseConfigurationError extends LiveCollectionError { + constructor(collection: string, message: string) { + super(collection, message); + this.name = "PocketBaseConfigurationError"; + } + + static is(error: unknown): error is PocketBaseConfigurationError { + // This is similar to the original implementation in Astro itself. + return ( + // oxlint-disable-next-line no-unsafe-type-assertion + !!error && (error as Error)?.name === "PocketBaseConfigurationError" + ); + } +} diff --git a/src/types/pocketbase-loader-options.type.ts b/src/types/pocketbase-loader-options.type.ts index 4f05042..3704c70 100644 --- a/src/types/pocketbase-loader-options.type.ts +++ b/src/types/pocketbase-loader-options.type.ts @@ -156,4 +156,33 @@ export type PocketBaseLoaderOptions = PocketBaseLoaderBaseOptions & { * @experimental Live content collections are still experimental */ export type ExperimentalPocketBaseLiveLoaderOptions = - PocketBaseLoaderBaseOptions; + PocketBaseLoaderBaseOptions & { + /** + * Experimental options for the live loader. + * + * @experimental All of these options are experimental and may change in the future. + */ + experimental?: { + /** + * Specify relations to auto expand in the API response. + * This can be an array of relation field names to expand. + * Supports dot notation for nested relations up to 6 levels deep. + * + * Note: This option is not compatible with the `fields` option. + * + * Example: + * ```ts + * // Using array format: + * expand: ['author', 'category'] + * + * // Nested relations: + * expand: ['author.profile', 'category.parent'] + * ``` + * + * @see {@link https://pocketbase.io/docs/working-with-relations/#expanding-relations PocketBase documentation} for valid syntax + * + * @experimental This feature is experimental and may change in the future + */ + 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-expand.ts b/src/utils/format-expand.ts new file mode 100644 index 0000000..2cdd2df --- /dev/null +++ b/src/utils/format-expand.ts @@ -0,0 +1,41 @@ +import { PocketBaseConfigurationError } from "../types/errors"; + +/** + * Maximum nesting depth for expand relations as enforced by PocketBase + */ +const MAX_EXPAND_DEPTH = 6; + +/** + * Format and validate expand option for PocketBase API requests. + * Validates nesting depth and returns formatted expand string. + */ +export function formatExpand( + expand: Array | undefined, + collectionName: string +): string | undefined { + if (!expand || expand.length === 0) { + return undefined; + } + + // Validate each expand field for maximum nesting depth and invalid characters + for (const field of expand) { + // Check for comma in field name + if (field.includes(",")) { + throw new PocketBaseConfigurationError( + collectionName, + `Expand field "${field}" contains a comma. Use separate array entries instead of comma-separated values.` + ); + } + + const depth = (field.match(/\./g) || []).length + 1; + if (depth > MAX_EXPAND_DEPTH) { + throw new PocketBaseConfigurationError( + collectionName, + `Expand field "${field}" exceeds maximum nesting depth of ${MAX_EXPAND_DEPTH} levels.` + ); + } + } + + // Join all expand fields with comma as required by PocketBase + return expand.join(","); +} diff --git a/test/_mocks/delete-collection.ts b/test/_mocks/delete-collection.ts index dba050e..bdd6096 100644 --- a/test/_mocks/delete-collection.ts +++ b/test/_mocks/delete-collection.ts @@ -1,8 +1,8 @@ import { assert } from "vitest"; -import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; +import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type"; export async function deleteCollection( - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string ): Promise { const deleteRequest = await fetch( diff --git a/test/_mocks/delete-entry.ts b/test/_mocks/delete-entry.ts index 7f11bb1..6290776 100644 --- a/test/_mocks/delete-entry.ts +++ b/test/_mocks/delete-entry.ts @@ -1,10 +1,10 @@ import { assert } from "vitest"; -import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; +import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type"; import { sendBatchRequest } from "./batch-requests"; export async function deleteEntries( entryIds: Array, - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string ): Promise { const requests = entryIds.map((entryId) => ({ @@ -26,7 +26,7 @@ export async function deleteEntries( export async function deleteEntry( entryId: string, - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string ): Promise { const deleteRequest = await fetch( diff --git a/test/_mocks/insert-collection.ts b/test/_mocks/insert-collection.ts index 8642139..b192f59 100644 --- a/test/_mocks/insert-collection.ts +++ b/test/_mocks/insert-collection.ts @@ -1,12 +1,12 @@ import { assert } from "console"; -import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; +import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type"; import type { PocketBaseSchemaEntry } from "../../src/types/pocketbase-schema.type"; export async function insertCollection( fields: Array, - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string -): Promise { +): Promise { const insertRequest = await fetch(new URL(`api/collections`, options.url), { method: "POST", headers: { @@ -20,4 +20,9 @@ export async function insertCollection( }); assert(insertRequest.status === 200, "Collection is not available."); + + const insertResponse = await insertRequest.json(); + assert(insertResponse.id, "Collection ID is not available."); + + return insertResponse.id; } diff --git a/test/_mocks/insert-entry.ts b/test/_mocks/insert-entry.ts index 3a5829f..33dd568 100644 --- a/test/_mocks/insert-entry.ts +++ b/test/_mocks/insert-entry.ts @@ -1,11 +1,11 @@ import { assert } from "vitest"; import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type"; -import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type"; +import type { PocketBaseLoaderBaseOptions } from "../../src/types/pocketbase-loader-options.type"; import { sendBatchRequest } from "./batch-requests"; export async function insertEntries( data: Array>, - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string ): Promise> { const requests = data.map((entry) => ({ @@ -34,7 +34,7 @@ export async function insertEntries( export async function insertEntry( data: Record, - options: PocketBaseLoaderOptions, + options: PocketBaseLoaderBaseOptions, superuserToken: string ): Promise { const insertRequest = await fetch( diff --git a/test/loader/fetch-collection.e2e-spec.ts b/test/loader/fetch-collection.e2e-spec.ts index e0475c5..5540e43 100644 --- a/test/loader/fetch-collection.e2e-spec.ts +++ b/test/loader/fetch-collection.e2e-spec.ts @@ -3,7 +3,16 @@ import { LiveEntryNotFoundError } from "astro/content/runtime"; import { randomUUID } from "crypto"; -import { beforeEach, describe, expect, inject, test, vi } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + inject, + test, + vi +} from "vitest"; +import type { ExperimentalPocketBaseLiveLoaderOptions } from "../../src"; import { fetchCollection } from "../../src/loader/fetch-collection"; import { PocketBaseAuthenticationError } from "../../src/types/errors"; import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type"; @@ -494,4 +503,127 @@ describe("fetchCollection", () => { await deleteCollection(testOptions, superuserToken); }); }); + + describe("expand parameter", () => { + let testOptions: ExperimentalPocketBaseLiveLoaderOptions; + let relationOptions: ExperimentalPocketBaseLiveLoaderOptions; + let relationCollectionId: string; + + beforeEach(async () => { + testOptions = { + ...options, + collectionName: randomUUID().replaceAll("-", ""), + experimental: { + expand: ["singleRelation"] + } + }; + relationOptions = { + ...options, + collectionName: randomUUID().replaceAll("-", ""), + experimental: {} + }; + + relationCollectionId = await insertCollection( + [], + relationOptions, + superuserToken + ); + }); + + afterEach(async () => { + await deleteCollection(testOptions, superuserToken); + await deleteCollection(relationOptions, superuserToken); + }); + + test("should expand single relation", async () => { + await insertCollection( + [ + { + type: "relation", + name: "singleRelation", + collectionId: relationCollectionId, + maxSelect: 1 + } + ], + testOptions, + superuserToken + ); + + const relationEntry = await insertEntry( + {}, + relationOptions, + superuserToken + ); + const entry = await insertEntry( + { + singleRelation: relationEntry.id + }, + testOptions, + superuserToken + ); + + const result: Array> = []; + await fetchCollection( + testOptions, + async (e) => { + result.push(...e); + }, + superuserToken, + undefined + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(entry.id); + expect(result[0].expand.singleRelation.id).toBe(relationEntry.id); + }); + + test("should expand multi relation", async () => { + await insertCollection( + [ + { + type: "relation", + name: "multiRelation", + collectionId: relationCollectionId, + maxSelect: 2 + } + ], + testOptions, + superuserToken + ); + + const relationEntries = await insertEntries( + [{}, {}], + relationOptions, + superuserToken + ); + const relationIds = relationEntries.map((entry) => entry.id); + const entry = await insertEntry( + { + multiRelation: relationIds + }, + testOptions, + superuserToken + ); + + const result: Array> = []; + await fetchCollection( + { ...testOptions, experimental: { expand: ["multiRelation"] } }, + async (e) => { + result.push(...e); + }, + superuserToken, + undefined + ); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(entry.id); + + const multiRelation = ( + result[0].expand as { multiRelation: Array<{ id: string }> } + ).multiRelation; + expect(multiRelation).toBeInstanceOf(Array); + expect(multiRelation).toHaveLength(2); + expect(multiRelation.map((entry) => entry.id)).toEqual(relationIds); + }); + }); }); diff --git a/test/loader/fetch-entry.e2e-spec.ts b/test/loader/fetch-entry.e2e-spec.ts index ebf20db..957ec2b 100644 --- a/test/loader/fetch-entry.e2e-spec.ts +++ b/test/loader/fetch-entry.e2e-spec.ts @@ -1,12 +1,13 @@ import { LiveEntryNotFoundError } from "astro/content/runtime"; import { randomUUID } from "crypto"; -import { describe, expect, inject, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, inject, test } from "vitest"; +import type { ExperimentalPocketBaseLiveLoaderOptions } from "../../src"; import { fetchEntry } from "../../src/loader/fetch-entry"; import { PocketBaseAuthenticationError } from "../../src/types/errors"; import { createLoaderOptions } from "../_mocks/create-loader-options"; import { deleteCollection } from "../_mocks/delete-collection"; import { insertCollection } from "../_mocks/insert-collection"; -import { insertEntry } from "../_mocks/insert-entry"; +import { insertEntries, insertEntry } from "../_mocks/insert-entry"; describe("fetchEntry", () => { const options = createLoaderOptions({ collectionName: "_superusers" }); @@ -195,4 +196,115 @@ describe("fetchEntry", () => { await deleteCollection(testOptions, superuserToken); }); }); + + describe("expand parameter", () => { + let testOptions: ExperimentalPocketBaseLiveLoaderOptions; + let relationOptions: ExperimentalPocketBaseLiveLoaderOptions; + let relationCollectionId: string; + + beforeEach(async () => { + testOptions = { + ...options, + collectionName: randomUUID().replaceAll("-", ""), + experimental: { + expand: ["singleRelation"] + } + }; + relationOptions = { + ...options, + collectionName: randomUUID().replaceAll("-", ""), + experimental: {} + }; + + relationCollectionId = await insertCollection( + [], + relationOptions, + superuserToken + ); + }); + + afterEach(async () => { + await deleteCollection(testOptions, superuserToken); + await deleteCollection(relationOptions, superuserToken); + }); + + test("should expand single relation", async () => { + await insertCollection( + [ + { + type: "relation", + name: "singleRelation", + collectionId: relationCollectionId, + maxSelect: 1 + } + ], + testOptions, + superuserToken + ); + + const relationEntry = await insertEntry( + {}, + relationOptions, + superuserToken + ); + const entry = await insertEntry( + { + singleRelation: relationEntry.id + }, + testOptions, + superuserToken + ); + + const result = await fetchEntry(entry.id, testOptions, superuserToken); + + expect(result).toBeDefined(); + expect(result.expand).toBeDefined(); + expect((result.expand as any).singleRelation.id).toBe(relationEntry.id); + }); + + test("should expand multi relation", async () => { + await insertCollection( + [ + { + type: "relation", + name: "multiRelation", + collectionId: relationCollectionId, + maxSelect: 2 + } + ], + testOptions, + superuserToken + ); + + const relationEntries = await insertEntries( + [{}, {}], + relationOptions, + superuserToken + ); + const relationIds = relationEntries.map((entry) => entry.id); + const entry = await insertEntry( + { + multiRelation: relationIds + }, + testOptions, + superuserToken + ); + + const result = await fetchEntry( + entry.id, + { ...testOptions, experimental: { expand: ["multiRelation"] } }, + superuserToken + ); + + expect(result).toBeDefined(); + expect(result.expand).toBeDefined(); + + const multiRelation = ( + result.expand as { multiRelation: Array<{ id: string }> } + ).multiRelation; + expect(multiRelation).toBeInstanceOf(Array); + expect(multiRelation).toHaveLength(2); + expect(multiRelation.map((entry) => entry.id)).toEqual(relationIds); + }); + }); }); diff --git a/test/schema/__snapshots__/get-remote-schema.e2e-spec.ts.snap b/test/schema/__snapshots__/get-remote-schema.e2e-spec.ts.snap index 311f87a..cbe3a3f 100644 --- a/test/schema/__snapshots__/get-remote-schema.e2e-spec.ts.snap +++ b/test/schema/__snapshots__/get-remote-schema.e2e-spec.ts.snap @@ -67,6 +67,7 @@ exports[`getRemoteSchema > should return schema if fetch request is successful 1 "type": "autodate", }, ], + "id": "_pb_users_auth_", "name": "users", "type": "auth", } diff --git a/test/schema/parse-schema.spec.ts b/test/schema/parse-schema.spec.ts index 5b3bb1c..7c27086 100644 --- a/test/schema/parse-schema.spec.ts +++ b/test/schema/parse-schema.spec.ts @@ -7,6 +7,7 @@ describe("parseSchema", () => { describe("number", () => { test("should parse number fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "numberCollection", type: "base", fields: [{ name: "age", type: "number", required: true, hidden: false }] @@ -28,6 +29,7 @@ describe("parseSchema", () => { test("should parse optional number fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "numberCollection", type: "base", fields: [ @@ -50,6 +52,7 @@ describe("parseSchema", () => { test("should parse optional number fields correctly with improved types", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "numberCollection", type: "base", fields: [ @@ -74,6 +77,7 @@ describe("parseSchema", () => { describe("boolean", () => { test("should parse boolean fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "booleanCollection", type: "base", fields: [ @@ -97,6 +101,7 @@ describe("parseSchema", () => { test("should parse optional boolean fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "booleanCollection", type: "base", fields: [ @@ -119,6 +124,7 @@ describe("parseSchema", () => { test("should parse optional boolean fields correctly with improved types", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "booleanCollection", type: "base", fields: [ @@ -143,6 +149,7 @@ describe("parseSchema", () => { describe("date", () => { test("should parse date fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -168,6 +175,7 @@ describe("parseSchema", () => { test("should parse optional date fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -192,6 +200,7 @@ describe("parseSchema", () => { describe("autodate", () => { test("should parse autodate fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -222,6 +231,7 @@ describe("parseSchema", () => { test("should parse optional autodate fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -249,6 +259,7 @@ describe("parseSchema", () => { test("should parse autodate fields with onCreate correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -279,6 +290,7 @@ describe("parseSchema", () => { describe("geoPoint", () => { test("should parse geoPoint fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "geoPointCollection", type: "base", fields: [ @@ -310,6 +322,7 @@ describe("parseSchema", () => { test("should parse optional geoPoint fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "geoPointCollection", type: "base", fields: [ @@ -339,6 +352,7 @@ describe("parseSchema", () => { describe("select", () => { test("should parse select fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "selectCollection", type: "base", fields: [ @@ -368,6 +382,7 @@ describe("parseSchema", () => { test("should throw an error if no values are defined", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "selectCollection", type: "base", fields: [ @@ -390,6 +405,7 @@ describe("parseSchema", () => { test("should parse select fields with multiple values correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "selectCollection", type: "base", fields: [ @@ -422,6 +438,7 @@ describe("parseSchema", () => { test("should parse optional select fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "selectCollection", type: "base", fields: [ @@ -453,6 +470,7 @@ describe("parseSchema", () => { describe("relation", () => { test("should parse relation fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "relationCollection", type: "base", fields: [ @@ -481,6 +499,7 @@ describe("parseSchema", () => { test("should parse relation fields with multiple values correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "relationCollection", type: "base", fields: [ @@ -510,6 +529,7 @@ describe("parseSchema", () => { test("should parse optional relation fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "relationCollection", type: "base", fields: [ @@ -540,6 +560,7 @@ describe("parseSchema", () => { describe("file", () => { test("should parse file fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "fileCollection", type: "base", fields: [ @@ -568,6 +589,7 @@ describe("parseSchema", () => { test("should parse file fields with multiple values correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "fileCollection", type: "base", fields: [ @@ -604,6 +626,7 @@ describe("parseSchema", () => { test("should parse optional file fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "fileCollection", type: "base", fields: [ @@ -634,6 +657,7 @@ describe("parseSchema", () => { describe("json", () => { test("should parse json fields with custom schema correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "jsonCollection", type: "base", fields: [ @@ -675,6 +699,7 @@ describe("parseSchema", () => { test("should parse json fields without custom schema correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "jsonCollection", type: "base", fields: [ @@ -708,6 +733,7 @@ describe("parseSchema", () => { test("should parse optional json fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "jsonCollection", type: "base", fields: [ @@ -740,6 +766,7 @@ describe("parseSchema", () => { describe("text", () => { test("should parse text fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "stringCollection", type: "base", fields: [{ name: "name", type: "text", required: true, hidden: false }] @@ -761,6 +788,7 @@ describe("parseSchema", () => { test("should parse optional text fields correctly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "stringCollection", type: "base", fields: [{ name: "name", type: "text", required: false, hidden: false }] @@ -783,6 +811,7 @@ describe("parseSchema", () => { describe("experimental live types", () => { test("should treat date fields as strings when experimentalLiveTypesOnly is true", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -806,6 +835,7 @@ describe("parseSchema", () => { test("should treat autodate fields as strings when experimentalLiveTypesOnly is true", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -829,6 +859,7 @@ describe("parseSchema", () => { test("should parse date fields normally when experimentalLiveTypesOnly is false", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -854,6 +885,7 @@ describe("parseSchema", () => { test("should parse date fields normally when experimentalLiveTypesOnly is undefined", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ @@ -878,6 +910,7 @@ describe("parseSchema", () => { test("should handle mixed field types with experimentalLiveTypesOnly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "mixedCollection", type: "base", fields: [ @@ -922,6 +955,7 @@ describe("parseSchema", () => { test("should handle optional date fields with experimentalLiveTypesOnly", () => { const collection: PocketBaseCollection = { + id: "collectionId", name: "dateCollection", type: "base", fields: [ diff --git a/test/schema/read-local-schema.spec.ts b/test/schema/read-local-schema.spec.ts index aa10aa0..676ece9 100644 --- a/test/schema/read-local-schema.spec.ts +++ b/test/schema/read-local-schema.spec.ts @@ -1,20 +1,24 @@ +import type { z } from "astro/zod"; import fs from "fs/promises"; import path from "path"; import { describe, expect, test, vi } from "vitest"; import { readLocalSchema } from "../../src/schema/read-local-schema"; +import type { pocketBaseDatabase } from "../../src/types/pocketbase-schema.type"; vi.mock("fs/promises"); describe("readLocalSchema", () => { const localSchemaPath = "test/pb_schema.json"; const collectionName = "users"; - const mockSchema = [ + const mockSchema: z.infer = [ { + id: "user_collection_id", name: "users", type: "base", fields: [] }, { + id: "message_collection_id", name: "messages", type: "base", fields: [] diff --git a/test/utils/format-expand.spec.ts b/test/utils/format-expand.spec.ts new file mode 100644 index 0000000..4f5f2c7 --- /dev/null +++ b/test/utils/format-expand.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { PocketBaseConfigurationError } from "../../src/types/errors"; +import { formatExpand } from "../../src/utils/format-expand"; + +describe("formatExpand", () => { + test("should return undefined when expand is undefined", () => { + const result = formatExpand(undefined, ""); + expect(result).toBeUndefined(); + }); + + test("should return undefined when expand is empty array", () => { + const result = formatExpand([], ""); + expect(result).toBeUndefined(); + }); + + test("should format single expand field", () => { + const result = formatExpand(["author"], ""); + expect(result).toBe("author"); + }); + + test("should format multiple expand fields with comma", () => { + const result = formatExpand(["author", "category"], ""); + expect(result).toBe("author,category"); + }); + + test("should handle nested expand fields", () => { + const result = formatExpand(["author.profile", "category.parent"], ""); + expect(result).toBe("author.profile,category.parent"); + }); + + test("should handle deeply nested expand fields up to 6 levels", () => { + const result = formatExpand( + ["level1.level2.level3.level4.level5.level6"], + "" + ); + expect(result).toBe("level1.level2.level3.level4.level5.level6"); + }); + + test("should throw error when expand exceeds 6 levels", () => { + expect(() => + formatExpand(["level1.level2.level3.level4.level5.level6.level7"], "") + ).toThrow(PocketBaseConfigurationError); + }); + + test("should throw error when field contains comma", () => { + expect(() => formatExpand(["author,category"], "")).toThrow( + PocketBaseConfigurationError + ); + }); +});