-
Notifications
You must be signed in to change notification settings - Fork 4
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
base: master
Are you sure you want to change the base?
Changes from 5 commits
6b3284e
4134e77
75d2917
258f04d
9d20391
3933c35
e6b541f
bc1cca0
0f342cb
3f51a05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -129,10 +133,52 @@ 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const expandedSchema = await generateSchema({ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| collectionName: expandedFieldDefinition.collectionId, | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| expandedFields[expandedFieldName] = z.union([ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| expandedSchema, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| z.array(expandedSchema) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Edit: During the review you already fixed this one with the |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const expandSchema = { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| expand: buildExpandSchema(expandedFields).optional() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
cf-david-easton marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const schema = z.object({ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ...BASIC_SCHEMA, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ...fields | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ...fields, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ...expandSchema | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // Get all file fields | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -146,7 +192,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); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤔
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is the purpose of this function?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const fields = s.split("."); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| if (fields.length >= 7) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| `Expand value ${s} exceeds 6 levels of depth that Pocketbase allows` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return fields; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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. | ||
|
cf-david-easton marked this conversation as resolved.
Outdated
|
||
| */ | ||
| 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>; | ||
|
cf-david-easton marked this conversation as resolved.
Outdated
|
||
|
|
||
| export type ExpandedFields = Record<string, ZodSchema | Array<ZodSchema>>; | ||
|
cf-david-easton marked this conversation as resolved.
Outdated
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"); | ||
|
|
||
|
|
@@ -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 | ||
| }); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this also work with vi.spyOn(parseEntry).mockImplementationOnce(/* whatever */)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.