Skip to content
Merged
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
11 changes: 10 additions & 1 deletion packages/wabe-documentation/docs/documentation/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,16 @@ The hook object also contains the following methods:
- `getUser(): User`: this method returns the user who initiated the request.
- `isFieldUpdatedd(fieldName: string): boolean`: this method returns true if the field has been updated during the request.
- `getNewData(): Record<string, any>`: this method returns the new data that has been added during the request.
- `fetch(): Promise<OutputType<T, K, any>>`: this methods allow you to force the fetch of the current object from the database. By default, the `object` property is already up to date, but if you need to force a fetch, you can use this method.
- `fetchPointerOrRelation(field, options?): Promise<...>`: this method resolves a Pointer or Relation field on the current object from the database. The `field` argument is type-checked: only fields whose value is a Pointer (object with `id`) or a Relation (array of objects with `id`) are accepted. After the call, `hookObject.object[field]` is mutated to contain the fully resolved value, so subsequent reads (e.g. `hookObject.object.role.id`, `hookObject.object.role.name`) are available. An optional `select` lets you restrict which fields of the target class are loaded (e.g. `{ select: { name: true } }`); when omitted, all fields are returned. Calling it on a field that isn't a Pointer or Relation throws at runtime.

```ts
// On a User hook, resolve the `role` Pointer
await hookObject.fetchPointerOrRelation("role");
console.log(hookObject.object.role.id, hookObject.object.role.name);

// Restrict the loaded fields
await hookObject.fetchPointerOrRelation("role", { select: { name: true } });
```

```ts
import { Wabe } from "wabe";
Expand Down
190 changes: 167 additions & 23 deletions packages/wabe/src/hooks/HookObject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,40 +43,184 @@ describe('HookObject', () => {
await closeTests(wabe)
})

it('should fetch all the fields of an object', async () => {
const res = await wabe.controllers.database.createObject({
className: 'User',
data: { age: 30, name: 'John Doe' },
context: {
wabe,
isRoot: true,
},
it('should fetch a pointer field and mutate the object with the resolved value', async () => {
const database = wabe.controllers.database as any

const document = await database.createObject({
className: 'TestDocument',
data: { name: 'My Document' },
context: { wabe, isRoot: true },
select: { id: true },
})

const hookObject = new HookObject<DevWabeTypes, 'User'>({
className: 'User',
// @ts-expect-error
newData: { age: 30, name: 'John Doe' },
context: {
wabe,
} as any,
operationType: OperationType.BeforeCreate,
const container = await database.createObject({
className: 'TestPointerContainer',
data: { document: document?.id },
context: { wabe, isRoot: true },
select: { id: true, document: true },
})

const hookObject = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: {
id: res?.id || 'id',
},
id: container?.id,
document: container?.document,
} as any,
select: {},
})

const fetchResult = await hookObject.fetch()
const result = await hookObject.fetchPointerOrRelation('document')

expect(fetchResult).toEqual(
expect(result).toEqual(
expect.objectContaining({
id: res?.id || 'id',
age: 30,
name: 'John Doe',
id: document?.id,
name: 'My Document',
}),
)

expect((hookObject.object as any)?.document).toEqual(
expect.objectContaining({
id: document?.id,
name: 'My Document',
}),
)
})

it('should fetch a relation field and mutate the object with the resolved values', async () => {
const database = wabe.controllers.database as any

const doc1 = await database.createObject({
className: 'TestDocument',
data: { name: 'Doc 1' },
context: { wabe, isRoot: true },
select: { id: true },
})

const doc2 = await database.createObject({
className: 'TestDocument',
data: { name: 'Doc 2' },
context: { wabe, isRoot: true },
select: { id: true },
})

const container = await database.createObject({
className: 'TestPointerContainer',
data: { documents: [doc1?.id, doc2?.id] },
context: { wabe, isRoot: true },
select: { id: true, documents: true },
})

const hookObject = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: {
id: container?.id,
documents: container?.documents,
} as any,
select: {},
})

const result = await hookObject.fetchPointerOrRelation('documents')

expect(Array.isArray(result)).toBeTrue()
expect(result).toHaveLength(2)
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: doc1?.id, name: 'Doc 1' }),
expect.objectContaining({ id: doc2?.id, name: 'Doc 2' }),
]),
)

expect((hookObject.object as any)?.documents).toEqual(result)
})

it('should respect the select option when fetching a pointer field', async () => {
const database = wabe.controllers.database as any

const document = await database.createObject({
className: 'TestDocument',
data: { name: 'Selective' },
context: { wabe, isRoot: true },
select: { id: true },
})

const container = await database.createObject({
className: 'TestPointerContainer',
data: { document: document?.id },
context: { wabe, isRoot: true },
select: { id: true, document: true },
})

const hookObject = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: {
id: container?.id,
document: container?.document,
} as any,
select: {},
})

const result = await hookObject.fetchPointerOrRelation('document', {
select: { name: true },
})

expect(result).toEqual({ name: 'Selective' } as any)

const hookObjectWithId = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: {
id: container?.id,
document: container?.document,
} as any,
select: {},
})

const resultWithId = await hookObjectWithId.fetchPointerOrRelation('document', {
select: { id: true, name: true },
})

expect(resultWithId).toEqual({ id: document?.id, name: 'Selective' } as any)
})

it('should throw when trying to fetch a non Pointer/Relation field at runtime', async () => {
const hookObject = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: { id: 'container-id' } as any,
select: {},
})

await expect(hookObject.fetchPointerOrRelation('name' as any)).rejects.toThrow(
'Field "name" is not a Pointer or Relation',
)
})

it('should throw when trying to fetch an unsafe field key', async () => {
const hookObject = new HookObject<DevWabeTypes, any>({
className: 'TestPointerContainer',
newData: {} as any,
context: { wabe } as any,
operationType: OperationType.AfterCreate,
object: { id: 'container-id' } as any,
select: {},
})

await expect(hookObject.fetchPointerOrRelation('__proto__' as any)).rejects.toThrow(
'Cannot fetch unsafe field key "__proto__"',
)
})

it('should return correctly value depends on the update state of the field', () => {
Expand Down
99 changes: 91 additions & 8 deletions packages/wabe/src/hooks/HookObject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { OperationType } from '.'
import type { RoleEnum, ACLObject } from '../../generated/wabe'
import type { MutationData, OutputType, Select } from '../database'
import { normalizePointerValue, normalizeRelationValue } from '../database/pointerRelationPayload'
import {
extractPointerId,
extractRelationIds,
normalizePointerValue,
normalizeRelationValue,
} from '../database/pointerRelationPayload'
import type { WabeTypes } from '../server'
import type { WabeContext } from '../server/interface'
import { isUnsafeObjectKey } from '../utils/objectKeys'
Expand All @@ -14,6 +19,29 @@ type AddACLOpptions = {
write: boolean
} | null

/**
* Restricts to fields whose value is a Pointer (object with `id`)
* or a Relation (array of objects with `id`).
*/
type PointerOrRelationFields<T extends WabeTypes, K extends keyof T['types']> = {
[P in keyof T['types'][K]]: NonNullable<T['types'][K][P]> extends
| { id: string }
| Array<{ id: string }>
? P
: never
}[keyof T['types'][K]]

type TargetShape<T extends WabeTypes, K extends keyof T['types'], P extends keyof T['types'][K]> =
NonNullable<T['types'][K][P]> extends Array<infer Item> ? Item : NonNullable<T['types'][K][P]>

export type FetchPointerOrRelationOptions<
T extends WabeTypes,
K extends keyof T['types'],
P extends keyof T['types'][K],
> = {
select?: Partial<Record<keyof TargetShape<T, K, P>, boolean>>
}

export class HookObject<T extends WabeTypes, K extends keyof WabeTypes['types']> {
public className: K
private newData: MutationData<T, K, keyof T['types'][K]> | undefined
Expand Down Expand Up @@ -111,16 +139,71 @@ export class HookObject<T extends WabeTypes, K extends keyof WabeTypes['types']>
return this.newData || ({} as any)
}

fetch(): Promise<OutputType<T, K, keyof T['types'][K]>> {
async fetchPointerOrRelation<P extends PointerOrRelationFields<T, K>>(
field: P,
options?: FetchPointerOrRelationOptions<T, K, P>,
): Promise<T['types'][K][P] | null> {
const fieldName = String(field)

if (isUnsafeObjectKey(fieldName))
throw new Error(`Cannot fetch unsafe field key "${fieldName}"`)

const schema = this.context?.wabe?.config?.schema
const currentClass = schema?.classes?.find(
(schemaClass) => schemaClass.name.toLowerCase() === String(this.className).toLowerCase(),
)
const schemaField = currentClass?.fields?.[fieldName]

if (
!schemaField ||
!('type' in schemaField) ||
(schemaField.type !== 'Pointer' && schemaField.type !== 'Relation') ||
!('class' in schemaField) ||
!schemaField.class
)
throw new Error(`Field "${fieldName}" is not a Pointer or Relation`)

if (!this.object) return null

const databaseController = this.context.wabe.controllers.database
const targetClassName = schemaField.class as keyof T['types']
const rootContext = contextWithRoot(this.context)
const select = options?.select as Select | undefined
const rawValue = (this.object as Record<string, any>)[fieldName]

let resolved: unknown

if (schemaField.type === 'Pointer') {
const pointerId = extractPointerId(rawValue)

resolved = pointerId
? await databaseController.getObject({
className: targetClassName,
id: pointerId,
context: rootContext,
select: select as any,
})
: null
} else {
const relationIds = extractRelationIds(rawValue)

resolved =
relationIds.length === 0
? []
: await databaseController.getObjects({
className: targetClassName,
where: { id: { in: relationIds } } as any,
context: rootContext,
select: select as any,
})
}

if (!this.object?.id) return Promise.resolve(null)
const value = (resolved ?? null) as T['types'][K][P] | null

return databaseController.getObject({
className: this.className,
id: this.object.id,
context: contextWithRoot(this.context),
})
// @ts-expect-error
this.object[fieldName] = value

return value
}

async addACL(type: 'users' | 'roles', options: AddACLOpptions) {
Expand Down
Loading