Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
81 changes: 74 additions & 7 deletions src/schema/generate-schema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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 { readLocalSchema } from "./read-local-schema";
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,48 @@ export async function generateSchema(
}
}

// Combine the basic schema with the parsed fields
if (options.expand) {
Copy link
Owner

Choose a reason for hiding this comment

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

Doesn't this expand run for every nested layer? So it will always print the error message that the expand property cannot be found on the already expanded collection? 🤔
In other words: where is the cut-of point for x-level nesting?

Copy link
Author

Choose a reason for hiding this comment

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

I have address this with new additions today for supporitng 6 levels deep of expand nesting.
I essentially split all expand args as they nest until we are left with an empty array which I check the lenght of and pass undefined to this option so that the very next generateSchema call, expand is undefined for it.

for (const expandedFieldName of options.expand) {
const expandedFieldDefinition = collection.fields.find(
(field) => field.name === expandedFieldName
);

if (!expandedFieldDefinition) {
console.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.`
);
} else {
if (!expandedFieldDefinition.collectionId) {
console.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.`
);
} else {
const expandedchema = await generateSchema({
Copy link
Owner

Choose a reason for hiding this comment

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

Note for myself: Now that the generateSchema function can be called multiple times recursively it's definitely time to refactor the whole authentication. I already have a rough draft for this, were I can pass in a promise that resolves the token. This way we only need to do one authentication request.
I'll hopefully finish this up on the weekend.

collectionName: expandedFieldDefinition.collectionId,
superuserCredentials: options.superuserCredentials,
localSchema: options.localSchema,
jsonSchemas: options.jsonSchemas,
Copy link
Owner

Choose a reason for hiding this comment

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

It's probably fine, but using the same jsonSchemas would leave us unable to define a schema with the same name for the "parent" and the expanded "child" with different values.

Copy link
Author

Choose a reason for hiding this comment

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

I haven't dived too much into the implications of expand with the localSchema and jsonSchemas, how would we go about. resolving this? If its a case of name collisions, can we cleverly modify the collection names for the linked field or check if a pre-existing schema exists before making a new one? Sorry if this sounds like nonsense I need to look much more into the jsonSchema stuff

improveTypes: options.improveTypes,
url: options.url
});

expandedFields[expandedFieldName] = z.union([
expandedchema,
z.array(expandedchema)
]);
Copy link
Owner

Choose a reason for hiding this comment

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

What is this used for? So that when you have a relation with multiple records it's typed as an array instead of the value?
Since this information is encoded in the original database schema we can use it to only use a single value or array for the type.

Copy link
Author

Choose a reason for hiding this comment

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

Yes that was my intention with the union to show it could be a single value or an array of the schema type.
Sorry could you elaborate on your 2nd point? Is there a better way I can infer this?

}
}
}
}

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

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

// Get all file fields
Expand All @@ -146,7 +188,32 @@ 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: Record<string, ZodSchema | Array<ZodSchema>>
): 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);
}
6 changes: 2 additions & 4 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 type { PocketBaseCollection } from "../types/pocketbase-collection.type";
import type { PocketBaseSchemaEntry } from "../types/pocketbase-schema.type";

export function parseSchema(
collection: PocketBaseCollection,
Expand Down
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, PocketBaseBaseEntry & Record<string, unknown>>;
}

/**
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;
}
78 changes: 76 additions & 2 deletions test/loader/load-entries.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
describe,
expect,
test,
vi
vi,
type Mock
} from "vitest";
import { loadEntries } from "../../src/loader/load-entries";
import { parseEntry } from "../../src/loader/parse-entry";
import type { PocketBaseEntry } from "../../src/types/pocketbase-entry.type";
import { getSuperuserToken } from "../../src/utils/get-superuser-token";
import { checkE2eConnection } from "../_mocks/check-e2e-connection";
import { createLoaderContext } from "../_mocks/create-loader-context";
import { createLoaderOptions } from "../_mocks/create-loader-options";
import { deleteCollection } from "../_mocks/delete-collection";
import { insertCollection } from "../_mocks/insert-collection";
import { insertEntries } from "../_mocks/insert-entry";
import { insertEntries, insertEntry } from "../_mocks/insert-entry";

vi.mock("../../src/loader/parse-entry");

Expand Down Expand Up @@ -119,6 +121,78 @@ describe("loadEntries", () => {
await deleteCollection(testOptions, superuserToken);
});

test("should expand related fields in pages", async () => {
const RELATION_FIELD_NAME = "related";
const BLUE_ENTRY_NAME_FIELD_VALUE = "blue entry";

const redCollectionOptions = {
...options,
collectionName: `red_${randomUUID().replace(/-/g, "")}`
};

const blueCollectionOptions = {
...options,
collectionName: `blue_${randomUUID().replace(/-/g, "")}`
};

const testOptions = {
...options,
collectionName: redCollectionOptions.collectionName,
expand: [RELATION_FIELD_NAME]
};

const blueCollection = await insertCollection(
[
{
name: "name",
type: "text"
}
],
blueCollectionOptions,
superuserToken
);

await insertCollection(
[
{
name: RELATION_FIELD_NAME,
type: "relation",
collectionId: blueCollection.id
}
],
redCollectionOptions,
superuserToken
);

const parsedEntries: Array<PocketBaseEntry> = [];

const blueEntry = await insertEntry(
{ name: BLUE_ENTRY_NAME_FIELD_VALUE },
blueCollectionOptions,
superuserToken
);

await insertEntry(
{ [RELATION_FIELD_NAME]: blueEntry.id },
redCollectionOptions,
superuserToken
);

(parseEntry as Mock).mockImplementation((entry: PocketBaseEntry) => {
parsedEntries.push(entry);
return entry; // or whatever parseEntry should return
});
Copy link
Owner

Choose a reason for hiding this comment

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

Does this also work with spyOn?

vi.spyOn(parseEntry).mockImplementationOnce(/* whatever */)

Copy link
Author

Choose a reason for hiding this comment

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

Almost, had to do some fiddling and landed on extracting the arg out without a mock implmentation. Made it a global one for the file so the other checks for the call to use it.


await loadEntries(testOptions, context, superuserToken, undefined);

expect(parsedEntries[0]?.expand?.related?.name).toBe(
BLUE_ENTRY_NAME_FIELD_VALUE
);

await deleteCollection(redCollectionOptions, superuserToken);
await deleteCollection(blueCollectionOptions, superuserToken);
});

describe("incremental updates", () => {
test("should fetch all entries when updatedField is missing", async () => {
const lastModified = new Date(Date.now() - DAY).toISOString();
Expand Down
Loading