Skip to content

feat(expand): Enables the use of Pocketbases 'Expand' property #41

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

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/loader/load-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export async function loadEntries(
searchParams.set("filter", filters.join("&&"));
}

// Add expand to search parameters
if (options.expand) {
searchParams.set("expand", options.expand.join(","));
}

// Fetch entries from the collection
const collectionRequest = await fetch(
`${collectionUrl}?${searchParams.toString()}`,
Expand Down
62 changes: 58 additions & 4 deletions src/schema/generate-schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { ZodSchema } from "astro/zod";
import { z } from "astro/zod";
import type { PocketBaseCollection } from "../types/pocketbase-collection.type";
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
import { getRemoteSchema } from "./get-remote-schema";
import { parseSchema } from "./parse-schema";
import { parseExpandedSchemaField, parseSchema } from "./parse-schema";
import { readLocalSchema } from "./read-local-schema";
import { transformFiles } from "./transform-files";

Expand Down Expand Up @@ -35,6 +35,7 @@ export async function generateSchema(
token: string | undefined
): Promise<ZodSchema> {
let collection: PocketBaseCollection | undefined;
const expandedFields: Record<string, z.ZodType> = {};

if (token) {
// Try to get the schema directly from the PocketBase instance
Expand Down Expand Up @@ -133,10 +134,51 @@ export async function generateSchema(
}
}

// Combine the basic schema with the parsed fields
if (options.expand && options.expand.length > 0) {
for (const expandedFieldName of options.expand) {
const [currentLevelFieldName, ...deeperExpandFields] =
getCurrentLevelExpandedFieldName(expandedFieldName);

const expandedFieldDefinition = collection.fields.find(
(field) => field.name === currentLevelFieldName
);

if (!expandedFieldDefinition) {
throw new Error(
`The provided field in the expand property "${expandedFieldName}" is not present in the schema of the collection "${options.collectionName}".\nThis will lead to use unable to provide a definition for this field.`
);
}

if (!expandedFieldDefinition.collectionId) {
throw new Error(
`The provided field in the expand property "${expandedFieldName}" does not have an associated collection linked to it, we need this in order to know the shape of the related schema.`
);
}
Comment on lines +146 to +156
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like with the fields above, I'd keep this as a console error and don't throw an error. We can still get the base data of the current collection, but the expanded object will just be empty.

Suggested change
if (!expandedFieldDefinition) {
throw new Error(
`The provided field in the expand property "${expandedFieldName}" is not present in the schema of the collection "${options.collectionName}".\nThis will lead to use unable to provide a definition for this field.`
);
}
if (!expandedFieldDefinition.collectionId) {
throw new Error(
`The provided field in the expand property "${expandedFieldName}" does not have an associated collection linked to it, we need this in order to know the shape of the related schema.`
);
}
if (!expandedFieldDefinition) {
console.error(
`The expanded field "${expandedFieldName}" is not present in the schema of the collection "${options.collectionName}".\nIt can not be used to fetch data from the collections relations.`
);
continue;
}
if (expandedFieldDefinition.type !== 'relation' || !expandedFieldDefinition.collectionId) {
console.error(
`The expanded field "${expandedFieldName}" does not have an associated collection linked to it via the collection "${options.collectionName}".\nIt can not be used to fetch data from the collections relations.`
);
continue;
}


const expandedSchema = await generateSchema(
{
collectionName: expandedFieldDefinition.collectionId,
superuserCredentials: options.superuserCredentials,
expand: deeperExpandFields.length ? deeperExpandFields : undefined,
localSchema: options.localSchema,
jsonSchemas: options.jsonSchemas,
improveTypes: options.improveTypes,
url: options.url
},
token
);

expandedFields[expandedFieldName] = parseExpandedSchemaField(
expandedFieldDefinition,
expandedSchema
);
}
}

const schema = z.object({
...BASIC_SCHEMA,
...fields
...fields,
expand: z.optional(z.object(expandedFields))
});

// Get all file fields
Expand All @@ -154,3 +196,15 @@ export async function generateSchema(
transformFiles(options.url, fileFields, entry)
);
}

function getCurrentLevelExpandedFieldName(s: string): Array<string> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function getCurrentLevelExpandedFieldName(s: string): Array<string> {
/**
* Splits the given expandedField name at the collection split string.
* The first element represents the expandedField name of the current collection,
* the following elements represent deeper nested expanded fields.
*/
function splitExpandedFieldByCollection(expandedField: string): Array<string> {

const fields = s.split(".");

if (fields.length >= 7) {
throw new Error(
`Expand value ${s} exceeds 6 levels of depth that Pocketbase allows`
);
}

return fields;
}
2 changes: 1 addition & 1 deletion src/schema/get-remote-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PocketBaseCollection } from "../types/pocketbase-collection.type";
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
import type { PocketBaseCollection } from "../types/pocketbase-schema.type";

/**
* Fetches the schema for the specified collection from the PocketBase instance.
Expand Down
29 changes: 21 additions & 8 deletions src/schema/parse-schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { z } from "astro/zod";
import type {
PocketBaseCollection,
PocketBaseSchemaEntry
} from "../types/pocketbase-schema.type";
import { z, ZodSchema } from "astro/zod";
import type { PocketBaseCollection } from "../types/pocketbase-collection.type";
import type { PocketBaseSchemaEntry } from "../types/pocketbase-schema.type";

export function parseSchema(
collection: PocketBaseCollection,
Expand Down Expand Up @@ -101,6 +99,21 @@ export function parseSchema(
return fields;
}

export function parseExpandedSchemaField(
originalField: PocketBaseSchemaEntry,
expandedSchema: ZodSchema
): z.ZodType {
const isRequired = originalField.required;
let fieldType = parseSingleOrMultipleValues(originalField, expandedSchema);

// If the field is not required, mark it as optional
if (!isRequired) {
fieldType = z.preprocess((val) => val || undefined, z.optional(fieldType));
}

return fieldType;
}

/**
* Parse the field type based on the number of values it can have
*
Expand All @@ -114,9 +127,9 @@ function parseSingleOrMultipleValues(
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;
} else {
return z.array(type);
}

return z.array(type);
}
2 changes: 1 addition & 1 deletion src/schema/read-local-schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "fs/promises";
import path from "path";
import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
import type { PocketBaseCollection } from "../types/pocketbase-collection.type";

/**
* Reads the local PocketBase schema file and returns the schema for the specified collection.
Expand Down
23 changes: 23 additions & 0 deletions src/types/pocketbase-collection.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { PocketBaseSchemaEntry } from "./pocketbase-schema.type";

/**
* Base interface for all PocketBase collections.
*/
export interface PocketBaseCollection {
/**
* ID of the collection.
*/
id: string;
/**
* Name of the collection
*/
name: string;
/**
* Type of the collection.
*/
type: "base" | "view" | "auth";
/**
* Schema of the collection.
*/
fields: Array<PocketBaseSchemaEntry>;
}
4 changes: 4 additions & 0 deletions src/types/pocketbase-entry.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ interface PocketBaseBaseEntry {
* Name of the collection the entry belongs to.
*/
collectionName: string;
/**
* Optional property that contains all relational fields that have been expanded to contain their linked entry(s)
*/
expand?: Record<string, PocketBaseEntry | Array<PocketBaseEntry | null>>;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/types/pocketbase-loader-options.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ export interface PocketBaseLoaderOptions {
* ```
*/
filter?: string;
/**
* array of relational field names to auto expand when loading data from PocketBase.
* Valid syntax can be found in the [PocketBase documentation](https://pocketbase.io/docs/api-records/#listsearch-records)
* Example:
* ```ts
* // config:
* expand: ['relatedField1', 'relatedField2']
*
* // request
* `?expand=relatedField1,relatedField2`
* ```
*/
expand?: Array<string>;
/**
* Credentials of a superuser to get full access to the PocketBase instance.
* This is required to get automatic type generation without a local schema, to access all resources even if they are not public and to fetch content of hidden fields.
Expand Down
18 changes: 3 additions & 15 deletions src/types/pocketbase-schema.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,10 @@ export interface PocketBaseSchemaEntry {
* This is only present on "autodate" fields.
*/
onUpdate?: boolean;
}

/**
* Schema for a PocketBase collection.
*/
export interface PocketBaseCollection {
/**
* Name of the collection.
*/
name: string;
/**
* Type of the collection.
*/
type: "base" | "view" | "auth";
/**
* Schema of the collection.
* The associated collection id that the relation field is referencing
* This is only present on "relation" fields.
*/
fields: Array<PocketBaseSchemaEntry>;
collectionId?: string;
}
8 changes: 7 additions & 1 deletion test/_mocks/insert-collection.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { assert } from "console";
import type { PocketBaseCollection } from "../../src/types/pocketbase-collection.type";
import type { PocketBaseLoaderOptions } from "../../src/types/pocketbase-loader-options.type";

export async function insertCollection(
fields: Array<Record<string, unknown>>,
options: PocketBaseLoaderOptions,
superuserToken: string
): Promise<void> {
): Promise<PocketBaseCollection> {
const insertRequest = await fetch(new URL(`api/collections`, options.url), {
method: "POST",
headers: {
Expand All @@ -19,4 +20,9 @@ export async function insertCollection(
});

assert(insertRequest.status === 200, "Collection is not available.");

const collection = await insertRequest.json();
assert(collection.id, "Collection ID is not available.");

return collection;
}
Loading