Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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 @@ -66,6 +66,11 @@ export async function loadEntries(
searchParams.set("filter", filters.join("&&"));
}

// Add filters 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
101 changes: 93 additions & 8 deletions src/schema/generate-schema.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { ZodSchema } from "astro/zod";
import type { ZodObject, ZodSchema } from "astro/zod";
import { z } from "astro/zod";
import type {
ExpandedFields,
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 @@ -33,6 +36,7 @@ export async function generateSchema(
options: PocketBaseLoaderOptions
): Promise<ZodSchema> {
let collection: PocketBaseCollection | undefined;
const expandedFields: ExpandedFields = {};

// Try to get the schema directly from the PocketBase instance
collection = await getRemoteSchema(options);
Expand Down Expand Up @@ -129,10 +133,54 @@ 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 isRequired = expandedFieldDefinition.required;

const expandedSchema = await generateSchema({
collectionName: expandedFieldDefinition.collectionId,
Copy link
Owner

Choose a reason for hiding this comment

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

This will currently fail when trying to read collections from a local schema path. While the remote schema is loaded by it's own (doesn't matter if it's the name or the id), the local schema reader only checks if the collection name matches.

superuserCredentials: options.superuserCredentials,
expand: deeperExpandFields.length ? deeperExpandFields : undefined,
localSchema: options.localSchema,
jsonSchemas: options.jsonSchemas,
improveTypes: options.improveTypes,
url: options.url
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
collectionName: expandedFieldDefinition.collectionId,
superuserCredentials: options.superuserCredentials,
expand: deeperExpandFields.length ? deeperExpandFields : undefined,
localSchema: options.localSchema,
jsonSchemas: options.jsonSchemas,
improveTypes: options.improveTypes,
url: options.url
...options,
collectionName: expandedFieldDefinition.collectionId,
expand: deeperExpandFields.length ? deeperExpandFields : undefined

});

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

const expandSchema = {
expand: buildExpandSchema(expandedFields).optional()
};

const schema = z.object({
...BASIC_SCHEMA,
...fields
...fields,
...expandSchema
});

// Get all file fields
Expand All @@ -146,7 +194,44 @@ export async function generateSchema(
}

// Transform file names to file urls
return schema.transform((entry) =>
transformFiles(options.url, fileFields, entry)
);
return schema.transform((entry) => {
if (Array.isArray(entry)) {
return entry.map((e) => transformFiles(options.url, fileFields, e));
}

return transformFiles(options.url, fileFields, entry);
});
Copy link
Owner

Choose a reason for hiding this comment

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

Do we really need this? My assumption would be that the transformation for expanded collections is already done it the recursive call 🤔

Copy link
Author

Choose a reason for hiding this comment

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

Yes probably, not sure why I added this originally, reverted

}

/**
* Builds a Zod object schema from expandedFields, where each property is either a ZodSchema or an array of ZodSchema.
*/
export function buildExpandSchema(
expandedFields: ExpandedFields
): ZodObject<Record<string, ZodSchema | z.ZodArray<ZodSchema>>> {
const shape: Record<string, ZodSchema | z.ZodArray<ZodSchema>> = {};

for (const key in expandedFields) {
const value = expandedFields[key];
if (Array.isArray(value)) {
// If it's an array, wrap the first element as a ZodArray (assuming all elements are the same schema)
shape[key] = z.array(value[0]);
} else {
shape[key] = value;
}
}

return z.object(shape);
}
Copy link
Owner

Choose a reason for hiding this comment

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

That is the purpose of this function?

Copy link
Author

Choose a reason for hiding this comment

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

This was when I was still trying to figure out how to get the conditional shapes of array or singular elements. It was a core misunderstanding by me that I could read the maxSelect property of the field to understand if its a singular or multiple select, have removed and simplified the expanded property.


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;
}
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
32 changes: 32 additions & 0 deletions src/types/pocketbase-collection.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ZodSchema } from "astro/zod";
import type { PocketBaseSchemaEntry } from "./pocketbase-schema.type";

/**
* Base interface for all PocketBase entries.
*/
interface PocketBaseBaseCollection {
/**
* 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>;
}

/**
* Type for a PocketBase entry.
*/
export type PocketBaseCollection = PocketBaseBaseCollection &
Record<string, unknown>;

export type ExpandedFields = Record<string, ZodSchema | Array<ZodSchema>>;
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>;
}

/**
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