diff --git a/packages/wabe-documentation/docs/documentation/schema/classes.md b/packages/wabe-documentation/docs/documentation/schema/classes.md index 7f541d51..fb892112 100644 --- a/packages/wabe-documentation/docs/documentation/schema/classes.md +++ b/packages/wabe-documentation/docs/documentation/schema/classes.md @@ -17,6 +17,7 @@ Wabe supports the following native field types (with the ability to extend throu - **Array**: Arrays of values (can specify element type) - **Pointer**: Single reference to another class object - **Relation**: Multiple references to objects of another class +- **Virtual**: Computed fields derived from other fields at read time - **Hash**: Secure password hashing The `classes` array allow you to create new classe in your project with some fields. In the example below, a company class had a required name and a logo. @@ -197,6 +198,57 @@ const run = async () => { await run(); ``` +#### Virtual + +Virtual fields are computed at read time from other fields. They are not stored in the database and cannot be used in create or update operations. Use them to derive values like full names, computed booleans, or formatted strings without duplicating data. + +Each virtual field requires: + +- **returnType**: The GraphQL type of the computed value (`String`, `Int`, `Float`, `Boolean`, `Date`, `Email`, or `Phone`) +- **dependsOn**: An array of field names that the callback needs to compute the value +- **callback**: A function that receives the object (including `id` and all `dependsOn` fields) and returns the computed value + +When you select a virtual field in a query, only the computed value is returned—the dependency fields are not exposed unless you explicitly select them. + +```ts +import { Wabe } from "wabe"; + +const run = async () => { + const wabe = new Wabe({ + // ... others config fields + schema: { + classes: [ + { + name: "Person", + fields: { + firstName: { type: "String" }, + lastName: { type: "String" }, + age: { type: "Int" }, + fullName: { + type: "Virtual", + returnType: "String", + dependsOn: ["firstName", "lastName"], + callback: (object) => + `${object.firstName} ${object.lastName}`.trim(), + }, + isAdult: { + type: "Virtual", + returnType: "Boolean", + dependsOn: ["age"], + callback: (object) => (object.age ?? 0) >= 18, + }, + }, + }, + ], + }, + }); + + await wabe.start(); +}; + +await run(); +``` + ## Indexes Indexes improve query performance by creating optimized data structures for specific fields. When defining indexes, you specify: diff --git a/packages/wabe/src/database/DatabaseController.ts b/packages/wabe/src/database/DatabaseController.ts index b94adbd6..d628a959 100644 --- a/packages/wabe/src/database/DatabaseController.ts +++ b/packages/wabe/src/database/DatabaseController.ts @@ -23,6 +23,20 @@ import type { export type Select = Record type SelectWithObject = Record +type RuntimeVirtualField = { + type: 'Virtual' + dependsOn: string[] + callback: (object: Record) => unknown +} + +const isVirtualField = (field: unknown): field is RuntimeVirtualField => { + if (!field || typeof field !== 'object') return false + + if (!('type' in field) || field.type !== 'Virtual') return false + + return true +} + export class DatabaseController { public adapter: DatabaseAdapter @@ -47,6 +61,146 @@ export class DatabaseController { return realClass?.fields[fieldName] as { type: string; class?: string } | undefined } + _getVirtualFieldsForClass(className: keyof T['types'], context: WabeContext) { + const currentClass = this._getClass(className, context) + + if (!currentClass) return {} + + const virtualFields: Record = {} + + for (const [fieldName, fieldDefinition] of Object.entries(currentClass.fields)) { + if (!isVirtualField(fieldDefinition)) continue + + virtualFields[fieldName] = fieldDefinition + } + + return virtualFields + } + + _buildReadSelects({ + className, + context, + selectWithoutPointers, + }: { + className: keyof T['types'] + context: WabeContext + selectWithoutPointers: Select + }) { + const virtualFieldsByName = this._getVirtualFieldsForClass(className, context) + const requestedVirtualFields: string[] = [] + + const userSelect: Select = {} + + for (const [fieldName, selected] of Object.entries(selectWithoutPointers)) { + if (!selected) continue + + const virtualField = virtualFieldsByName[fieldName] + + if (virtualField) { + requestedVirtualFields.push(fieldName) + continue + } + + userSelect[fieldName] = true + } + + const adapterSelect: Select = { ...userSelect } + + for (const virtualFieldName of requestedVirtualFields) { + const virtualField = virtualFieldsByName[virtualFieldName] + + if (!virtualField) continue + + for (const dependencyField of virtualField.dependsOn) { + const dependencyName = String(dependencyField) + + // Virtual dependencies are only useful for computation and must never reach adapters. + if (virtualFieldsByName[dependencyName]) continue + + adapterSelect[dependencyName] = true + } + } + + return { + userSelect, + adapterSelect, + } + } + + _projectObjectForUserSelect({ + object, + select, + }: { + object: Record | null | undefined + select?: SelectWithObject + }): any { + if (!object) return object + if (!select) return object + + const projectedObject: Record = {} + + for (const [fieldName, selected] of Object.entries(select)) { + if (!selected) continue + if (!(fieldName in object)) continue + + projectedObject[fieldName] = object[fieldName] + } + + return projectedObject + } + + _stripVirtualFieldsFromPayload({ + className, + context, + payload, + }: { + className: keyof T['types'] + context: WabeContext + payload: unknown + }): any { + if (!payload || typeof payload !== 'object') return {} + + const virtualFields = this._getVirtualFieldsForClass(className, context) + + if (Object.keys(virtualFields).length === 0) return payload + + const filteredPayload: Record = {} + + for (const [fieldName, value] of Object.entries(payload)) { + if (virtualFields[fieldName]) continue + + filteredPayload[fieldName] = value + } + + return filteredPayload + } + + _stripVirtualFieldsFromSchema(schema: SchemaInterface): SchemaInterface { + const classes = schema.classes?.map((classDefinition) => { + const filteredFieldEntries = Object.entries(classDefinition.fields).filter( + ([_fieldName, fieldDefinition]) => !isVirtualField(fieldDefinition), + ) + const filteredFields = Object.fromEntries(filteredFieldEntries) + + const allowedFieldNames = new Set(Object.keys(filteredFields)) + + const filteredIndexes = classDefinition.indexes?.filter((index) => + allowedFieldNames.has(index.field), + ) + + return { + ...classDefinition, + fields: filteredFields, + indexes: filteredIndexes, + } + }) + + return { + ...schema, + classes, + } + } + _getSelectMinusPointersAndRelations({ className, context, @@ -390,17 +544,22 @@ export class DatabaseController { ) if (isRelation && object[pointerField]) { - const selectWithoutTotalCount = Object.entries(currentSelect || {}).reduce( - (acc2, [key, value]) => { - if (key === 'totalCount') return acc2 - - return { - ...acc2, - [key]: value, - } - }, - {} as Record, - ) + const computedSelectWithoutTotalCount = currentSelect + ? Object.entries(currentSelect).reduce((acc2, [key, value]) => { + if (key === 'totalCount') return acc2 + + return { + ...acc2, + [key]: value, + } + }, {}) + : undefined + + const selectWithoutTotalCount = + computedSelectWithoutTotalCount && + Object.keys(computedSelectWithoutTotalCount).length > 0 + ? computedSelectWithoutTotalCount + : { id: true } const relationObjects = await this.getObjects({ className: currentClassName, @@ -435,11 +594,11 @@ export class DatabaseController { } createClassIfNotExist(className: string, schema: SchemaInterface): Promise { - return this.adapter.createClassIfNotExist(className, schema) + return this.adapter.createClassIfNotExist(className, this._stripVirtualFieldsFromSchema(schema)) } initializeDatabase(schema: SchemaInterface): Promise { - return this.adapter.initializeDatabase(schema) + return this.adapter.initializeDatabase(this._stripVirtualFieldsFromSchema(schema)) } async count({ @@ -489,12 +648,24 @@ export class DatabaseController { context, select: select as SelectWithObject, }) + const { userSelect, adapterSelect } = this._buildReadSelects({ + className, + context, + selectWithoutPointers, + }) const hook = !_skipHooks ? initializeHook({ className, context, - select: selectWithoutPointers, + select: { + ...userSelect, + ...Object.fromEntries( + Object.keys(this._getVirtualFieldsForClass(className, context)) + .filter((fieldName) => !!selectWithoutPointers[fieldName]) + .map((fieldName) => [fieldName, true]), + ), + }, objectLoader: this._loadObjectForHooks(className, context), objectsLoader: this._loadObjectsForHooks(className, context), }) @@ -511,7 +682,7 @@ export class DatabaseController { acc[fieldName] = true return acc - }, selectWithoutPointers) + }, adapterSelect) const objectToReturn = await this.adapter.getObject({ className, @@ -541,8 +712,13 @@ export class DatabaseController { // @ts-expect-error object: finalObject, }) + const objectAfterHooks = afterReadResult?.object || finalObject + const objectProjectedForUser = this._projectObjectForUserSelect({ + object: objectAfterHooks, + select: select as SelectWithObject, + }) - return afterReadResult?.object || finalObject + return objectProjectedForUser } async getObjects< @@ -564,6 +740,11 @@ export class DatabaseController { context, select: select as SelectWithObject, }) + const { userSelect, adapterSelect } = this._buildReadSelects({ + className, + context, + selectWithoutPointers, + }) const whereWithPointer = await this._getWhereObjectWithPointerOrRelation( className, @@ -577,12 +758,19 @@ export class DatabaseController { acc[fieldName] = true return acc - }, selectWithoutPointers) + }, adapterSelect) const hook = !_skipHooks ? initializeHook({ className, - select: selectWithoutPointers, + select: { + ...userSelect, + ...Object.fromEntries( + Object.keys(this._getVirtualFieldsForClass(className, context)) + .filter((fieldName) => !!selectWithoutPointers[fieldName]) + .map((fieldName) => [fieldName, true]), + ), + }, context, objectLoader: this._loadObjectForHooks(className, context), objectsLoader: this._loadObjectsForHooks(className, context), @@ -627,10 +815,16 @@ export class DatabaseController { // @ts-expect-error objects: objectsWithPointers, }) + const objectsAfterHooks = afterReadResults?.objects || objectsWithPointers + const projectedObjects = objectsAfterHooks.map((object) => + this._projectObjectForUserSelect({ + object, + select: select as SelectWithObject, + }), + ) - return (afterReadResults?.objects || objectsWithPointers) as unknown as Promise< - OutputType[] - > + // Projection keeps only user-requested top-level fields, including virtual fields. + return projectedObjects } async createObject< @@ -653,11 +847,17 @@ export class DatabaseController { data, select: select as Select, adapterCallback: async (newData) => { + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: newData || data, + }) + const res = await this.adapter.createObject({ className, context, select, - data: newData || data, + data: payload, }) return { id: res.id } @@ -668,10 +868,12 @@ export class DatabaseController { if (select && Object.keys(select).length === 0) return null + const selectWithoutPrivateFields = select ? selectFieldsWithoutPrivateFields(select) : undefined + return this.getObject({ className, context: contextWithRoot(context), - select: selectFieldsWithoutPrivateFields(select), + select: selectWithoutPrivateFields, id: res.id, }) } @@ -723,7 +925,13 @@ export class DatabaseController { className, select, context, - data: arrayOfComputedData, + data: arrayOfComputedData.map((payload) => + this._stripVirtualFieldsFromPayload({ + className, + context, + payload, + }), + ), first, offset, order, @@ -768,13 +976,18 @@ export class DatabaseController { }: UpdateObjectOptions): Promise> { if (_skipHooks) { const whereWithACLCondition = this._buildWhereWithACL({}, context, 'write') + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: data, + }) return this.adapter.updateObject({ className, select, id, context, - data, + data: payload, where: whereWithACLCondition, }) as Promise> } @@ -789,13 +1002,18 @@ export class DatabaseController { select: select as Select, adapterCallback: async (newData) => { const whereWithACLCondition = this._buildWhereWithACL({}, context, 'write') + const payload = this._stripVirtualFieldsFromPayload({ + className, + context, + payload: newData || data, + }) await this.adapter.updateObject({ className, select, id, context, - data: newData || data, + data: payload, where: whereWithACLCondition, }) @@ -858,7 +1076,11 @@ export class DatabaseController { className, context, select, - data: resultsAfterBeforeUpdate?.newData[0] || data, + data: this._stripVirtualFieldsFromPayload({ + className, + context, + payload: resultsAfterBeforeUpdate?.newData[0] || data || {}, + }), where: whereWithACLCondition, first, offset, diff --git a/packages/wabe/src/graphql/GraphQLSchema.test.ts b/packages/wabe/src/graphql/GraphQLSchema.test.ts index d7493dc3..670fb273 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.test.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.test.ts @@ -262,7 +262,15 @@ describe('GraphqlSchema', () => { const createResult = await rootClient.request(gql` ${fragments} mutation createTestClass { - createTestClass(input: { fields: { field: "testField", field1: "value1", field2: "value2" } }) { + createTestClass( + input: { + fields: { + field: "testField" + field1: "value1" + field2: "value2" + } + } + ) { testClass { ...ExtendedFields } @@ -394,7 +402,16 @@ describe('GraphqlSchema', () => { const createResult = await rootClient.request(gql` ${fragments} mutation createTestClass { - createTestClass(input: { fields: { field1: "created1", field2: "created2", field3: "created3", field4: "created4" } }) { + createTestClass( + input: { + fields: { + field1: "created1" + field2: "created2" + field3: "created3" + field4: "created4" + } + } + ) { testClass { ...CompleteFields } @@ -1363,63 +1380,53 @@ describe('GraphqlSchema', () => { {}, ) - const res = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "t" } }] }) { - totalCount - } + const res = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "t" } }] }) { + totalCount } - `, - ) + } + `) expect(res.testClasses.totalCount).toEqual(1) - const res2 = await client.request( - gql` - query testClasses { - testClasses(where: { search: { contains: "invalid" } }) { - totalCount - } + const res2 = await client.request(gql` + query testClasses { + testClasses(where: { search: { contains: "invalid" } }) { + totalCount } - `, - ) + } + `) expect(res2.testClasses.totalCount).toEqual(0) - const res3 = await client.request( - gql` - query testClasses { - testClasses(where: { search: { contains: "test" } }) { - totalCount - } + const res3 = await client.request(gql` + query testClasses { + testClasses(where: { search: { contains: "test" } }) { + totalCount } - `, - ) + } + `) expect(res3.testClasses.totalCount).toEqual(1) - const res4 = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 1111 } }, { search: { contains: "test" } }] }) { - totalCount - } + const res4 = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 1111 } }, { search: { contains: "test" } }] }) { + totalCount } - `, - ) + } + `) expect(res4.testClasses.totalCount).toEqual(0) - const res5 = await client.request( - gql` - query testClasses { - testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "" } }] }) { - totalCount - } + const res5 = await client.request(gql` + query testClasses { + testClasses(where: { AND: [{ age: { equalTo: 30 } }, { search: { contains: "" } }] }) { + totalCount } - `, - ) + } + `) expect(res5.testClasses.totalCount).toEqual(1) @@ -1472,15 +1479,13 @@ describe('GraphqlSchema', () => { {}, ) - const res = await client.request( - gql` - query testClasses { - testClasses { - totalCount - } + const res = await client.request(gql` + query testClasses { + testClasses { + totalCount } - `, - ) + } + `) expect(res.testClasses.totalCount).toEqual(1) @@ -1893,6 +1898,266 @@ describe('GraphqlSchema', () => { }) }) + it('should resolve virtual fields in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPerson', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + age: { type: 'Int' }, + fullName: { + type: 'Virtual', + returnType: 'String', + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => + `${object.firstName || ''} ${object.lastName || ''}`.trim(), + }, + isAdult: { + type: 'Virtual', + returnType: 'Boolean', + // @ts-expect-error + dependsOn: ['age'], + callback: (object: any) => (object.age || 0) >= 18, + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPerson: { + virtualPerson: { + id: string + } + } + }>(gql` + mutation createVirtualPerson { + createVirtualPerson(input: { fields: { firstName: "Ada", lastName: "Lovelace", age: 37 } }) { + virtualPerson { + id + } + } + } + `) + + const read = await client.request<{ + virtualPerson: { + id: string + firstName: string + lastName: string + age: number + fullName: string + isAdult: boolean + } + }>( + gql` + query virtualPerson($id: ID) { + virtualPerson(id: $id) { + id + firstName + lastName + age + fullName + isAdult + } + } + `, + { id: created.createVirtualPerson.virtualPerson.id }, + ) + + expect(read.virtualPerson.id).toBe(created.createVirtualPerson.virtualPerson.id) + expect(read.virtualPerson.firstName).toBe('Ada') + expect(read.virtualPerson.lastName).toBe('Lovelace') + expect(read.virtualPerson.age).toBe(37) + expect(read.virtualPerson.fullName).toBe('Ada Lovelace') + expect(read.virtualPerson.isAdult).toBe(true) + + const list = await client.request<{ + virtualPersons: { + edges: Array<{ + node: { + fullName: string + isAdult: boolean + } + }> + } + }>(gql` + query virtualPersons { + virtualPersons { + edges { + node { + fullName + isAdult + } + } + } + } + `) + + expect(list.virtualPersons.edges[0]?.node.fullName).toBe('Ada Lovelace') + expect(list.virtualPersons.edges[0]?.node.isAdult).toBe(true) + + await wabe.close() + }) + + it('should resolve virtual field returning array in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPersonWithArray', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + nameParts: { + type: 'Virtual', + returnType: 'Array', + typeValue: 'String', + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => + [object.firstName || '', object.lastName || ''].filter(Boolean), + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPersonWithArray: { + virtualPersonWithArray: { id: string } + } + }>(gql` + mutation createVirtualPersonWithArray { + createVirtualPersonWithArray(input: { fields: { firstName: "Ada", lastName: "Lovelace" } }) { + virtualPersonWithArray { + id + } + } + } + `) + + const read = await client.request<{ + virtualPersonWithArray: { + id: string + firstName: string + lastName: string + nameParts: string[] + } + }>( + gql` + query virtualPersonWithArray($id: ID) { + virtualPersonWithArray(id: $id) { + id + firstName + lastName + nameParts + } + } + `, + { id: created.createVirtualPersonWithArray.virtualPersonWithArray.id }, + ) + + expect(read.virtualPersonWithArray.nameParts).toEqual(['Ada', 'Lovelace']) + + await wabe.close() + }) + + it('should resolve virtual field returning array of objects in GraphQL queries', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'VirtualPersonWithObjectArray', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + nameInfos: { + type: 'Virtual', + returnType: 'Array', + typeValue: 'Object', + object: { + name: 'NameInfo', + fields: { + label: { type: 'String' }, + value: { type: 'String' }, + }, + }, + // @ts-expect-error + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => [ + { label: 'First', value: object.firstName || '' }, + { label: 'Last', value: object.lastName || '' }, + ], + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + }) + + const created = await client.request<{ + createVirtualPersonWithObjectArray: { + virtualPersonWithObjectArray: { id: string } + } + }>(gql` + mutation createVirtualPersonWithObjectArray { + createVirtualPersonWithObjectArray( + input: { fields: { firstName: "Grace", lastName: "Hopper" } } + ) { + virtualPersonWithObjectArray { + id + } + } + } + `) + + const read = await client.request<{ + virtualPersonWithObjectArray: { + id: string + nameInfos: Array<{ label: string; value: string }> + } + }>( + gql` + query virtualPersonWithObjectArray($id: ID) { + virtualPersonWithObjectArray(id: $id) { + id + nameInfos { + label + value + } + } + } + `, + { id: created.createVirtualPersonWithObjectArray.virtualPersonWithObjectArray.id }, + ) + + expect(read.virtualPersonWithObjectArray.nameInfos).toEqual([ + { label: 'First', value: 'Grace' }, + { label: 'Last', value: 'Hopper' }, + ]) + + await wabe.close() + }) + it('should have ConnectionObject on field of relation in ObjectType', () => { expect( getTypeFromGraphQLSchema({ @@ -4026,7 +4291,9 @@ describe('GraphqlSchema', () => { })) as any expect(field2BeforeUpdate2[0]?.field2).toEqual([ - { id: resAfterAdd.createTestClass2.testClass2.field2.edges[0].node.id }, + { + id: resAfterAdd.createTestClass2.testClass2.field2.edges[0].node.id, + }, ]) const resAfterUpdate = await client.request(gql` diff --git a/packages/wabe/src/graphql/parser.ts b/packages/wabe/src/graphql/parser.ts index 3da82804..1f677ec2 100644 --- a/packages/wabe/src/graphql/parser.ts +++ b/packages/wabe/src/graphql/parser.ts @@ -179,6 +179,70 @@ export const GraphqlParser: GraphqlParserConstructor = return acc } + if (currentField?.type === 'Virtual') { + if (isWhereType) return acc + + if (currentField.returnType === 'Object' && 'object' in currentField) { + acc[key] = { + type: callBackForObjectType({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${keyWithFirstLetterUppercase}`, + }), + } + return acc + } + + if (currentField.returnType === 'Array' && 'typeValue' in currentField) { + if (currentField.typeValue === 'Object' && 'object' in currentField) { + const objectList = new GraphQLList( + callBackForObjectType({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${currentField.object.name}`, + }), + ) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(objectList) + : objectList, + } + } else if ( + currentField.typeValue && + templateScalarType[currentField.typeValue as WabePrimaryTypes] + ) { + const graphqlType = getGraphqlType({ + type: 'Array', + typeValue: currentField.typeValue as WabePrimaryTypes, + requiredValue: (currentField as any).requiredValue, + }) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + } + return acc + } + + const graphqlType = getGraphqlType({ + type: currentField.returnType, + }) + + acc[key] = { + type: + currentField?.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + + return acc + } + const graphqlType = getGraphqlType({ ...currentField, // We never come here, complicated to good type this @@ -409,6 +473,7 @@ export const GraphqlParser: GraphqlParserConstructor = const rawFields = keysOfObject.reduce( (acc, key) => { + const keyWithFirstLetterUppercase = `${key.charAt(0).toUpperCase()}${key.slice(1)}` const currentField = schemaFields[key] const isRelation = currentField?.type === 'Relation' @@ -448,6 +513,72 @@ export const GraphqlParser: GraphqlParserConstructor = return acc } + if (currentField?.type === 'Virtual') { + if (graphqlObjectType !== 'Object') return acc + + if (currentField.returnType === 'Object' && 'object' in currentField) { + acc[key] = { + type: callback({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${keyWithFirstLetterUppercase}`, + }), + } + return acc + } + + if (currentField.returnType === 'Array' && 'typeValue' in currentField) { + if (currentField.typeValue === 'Object' && 'object' in currentField) { + const objectList = new GraphQLList( + callback({ + required: currentField.object?.required, + description: currentField.description, + objectToParse: currentField.object, + nameOfTheObject: `${nameOfTheObject}${currentField.object.name}`, + }), + ) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(objectList) + : objectList, + } + } else if ( + currentField.typeValue && + templateScalarType[currentField.typeValue as WabePrimaryTypes] + ) { + const graphqlType = getGraphqlType({ + type: 'Array', + typeValue: currentField.typeValue as WabePrimaryTypes, + requiredValue: (currentField as any).requiredValue, + isWhereType, + }) + acc[key] = { + type: + currentField.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + } + return acc + } + + const graphqlType = getGraphqlType({ + type: currentField.returnType, + isWhereType, + }) + + acc[key] = { + type: + currentField?.required && !forceRequiredToFalse + ? new GraphQLNonNull(graphqlType) + : graphqlType, + } + + return acc + } + if (currentField?.type === 'File') { if (graphqlObjectType === 'Object') acc[key] = { diff --git a/packages/wabe/src/hooks/index.ts b/packages/wabe/src/hooks/index.ts index e96feb1d..a108fdad 100644 --- a/packages/wabe/src/hooks/index.ts +++ b/packages/wabe/src/hooks/index.ts @@ -42,6 +42,7 @@ import { defaultCallAuthenticationProviderOnBeforeCreateUser, defaultCallAuthenticationProviderOnBeforeUpdateUser, } from './authentication' +import { defaultVirtualFieldsAfterRead } from './virtualFields' export enum OperationType { AfterCreate = 'afterCreate', @@ -314,6 +315,11 @@ export const getDefaultHooks = (): Hook[] => [ priority: 1, callback: defaultAfterReadFile, }, + { + operationType: OperationType.AfterRead, + priority: 2, + callback: defaultVirtualFieldsAfterRead, + }, { operationType: OperationType.AfterDelete, priority: 1, diff --git a/packages/wabe/src/hooks/virtualFields.test.ts b/packages/wabe/src/hooks/virtualFields.test.ts new file mode 100644 index 00000000..5fceb011 --- /dev/null +++ b/packages/wabe/src/hooks/virtualFields.test.ts @@ -0,0 +1,228 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import type { Wabe } from '../server' +import { type DevWabeTypes } from '../utils/helper' +import { closeTests, setupTests } from '../utils/testHelper' + +describe('Virtual fields integration', () => { + let wabe: Wabe + + beforeAll(async () => { + const setup = await setupTests([ + { + name: 'VirtualPerson', + fields: { + firstName: { type: 'String' }, + lastName: { type: 'String' }, + age: { type: 'Int' }, + fullName: { + type: 'Virtual', + returnType: 'String', + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => `${object.firstName} ${object.lastName}`.trim(), + }, + isAdult: { + type: 'Virtual', + returnType: 'Boolean', + dependsOn: ['age'], + callback: (object: any) => (object.age ?? 0) >= 18, + }, + nameInfo: { + type: 'Virtual', + returnType: 'Object', + object: { + name: 'NameInfo', + fields: { + full: { type: 'String' }, + initials: { type: 'String' }, + }, + }, + dependsOn: ['firstName', 'lastName'], + callback: (object: any) => ({ + full: `${object.firstName} ${object.lastName}`.trim(), + initials: + [object.firstName?.[0], object.lastName?.[0]].filter(Boolean).join('.') || '', + }), + }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ]) + + wabe = setup.wabe + }) + + afterAll(async () => { + await closeTests(wabe) + }) + + beforeEach(async () => { + await wabe.controllers.database.clearDatabase() + }) + + it('computes virtual fields from dependsOn without leaking dependencies', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Ada', + lastName: 'Lovelace', + age: 37, + }, + select: { id: true }, + }) + + const result = await wabe.controllers.database.getObject({ + // @ts-expect-error Test class only exists in test schema + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + fullName: true, + }, + }) + + const resultAny: any = result + expect(resultAny?.fullName).toBe('Ada Lovelace') + expect(resultAny?.firstName).toBeUndefined() + expect(resultAny?.lastName).toBeUndefined() + }) + + it('loads dependencies in adapter select and never requests virtual keys directly on database', async () => { + const adapterGetObjectSpy = spyOn(wabe.controllers.database.adapter, 'getObject') + + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Grace', + lastName: 'Hopper', + age: 30, + }, + select: { id: true }, + }) + + adapterGetObjectSpy.mockClear() + + await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + fullName: true, + isAdult: true, + }, + }) + + expect(adapterGetObjectSpy.mock.calls.length).toBeGreaterThanOrEqual(1) + const adapterCallWithVirtualDependencies = adapterGetObjectSpy.mock.calls.find((call: any) => { + const options = call[0] + return ( + options?.className === 'VirtualPerson' && + options?.id === (created?.id || '') && + options?.select?.firstName === true && + options?.select?.lastName === true && + options?.select?.age === true && + options?.select?.fullName === undefined && + options?.select?.isAdult === undefined + ) + }) + + expect(adapterCallWithVirtualDependencies).toBeDefined() + }) + + it('ignores virtual fields in create and update payloads', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Initial', + lastName: 'User', + age: 20, + fullName: 'MUST_BE_IGNORED', + }, + select: { id: true }, + }) + + await wabe.controllers.database.updateObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + data: { + // @ts-expect-error + firstName: 'Updated', + fullName: 'MUST_STILL_BE_IGNORED', + }, + select: {}, + }) + + const read = await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + firstName: true, + lastName: true, + fullName: true, + }, + }) + + // @ts-expect-error + expect(read?.firstName).toBe('Updated') + // @ts-expect-error + expect(read?.lastName).toBe('User') + // @ts-expect-error + expect(read?.fullName).toBe('Updated User') + }) + + it('returns virtual field as object with 2 fields and expects correct structure', async () => { + const created = await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + data: { + // @ts-expect-error + firstName: 'Alan', + lastName: 'Turing', + age: 41, + }, + select: { id: true }, + }) + + const result = await wabe.controllers.database.getObject({ + // @ts-expect-error + className: 'VirtualPerson', + context: { isRoot: true, wabe }, + id: created?.id || '', + select: { + // @ts-expect-error + nameInfo: true, + }, + }) + + const resultAny: any = result + const expectedNameInfo = { + full: 'Alan Turing', + initials: 'A.T', + } + expect(resultAny?.nameInfo).toEqual(expectedNameInfo) + expect(resultAny?.nameInfo.full).toBe('Alan Turing') + expect(resultAny?.nameInfo.initials).toBe('A.T') + }) +}) diff --git a/packages/wabe/src/hooks/virtualFields.ts b/packages/wabe/src/hooks/virtualFields.ts new file mode 100644 index 00000000..fd0d81fc --- /dev/null +++ b/packages/wabe/src/hooks/virtualFields.ts @@ -0,0 +1,38 @@ +import type { Hook } from '.' +import type { DevWabeTypes } from '../utils/helper' + +type VirtualFieldDefinition = { + type: 'Virtual' + dependsOn: string[] + callback: (object: Record) => unknown +} + +const isVirtualFieldDefinition = (value: unknown): value is VirtualFieldDefinition => { + if (!value || typeof value !== 'object') return false + + return 'type' in value && value.type === 'Virtual' +} + +export const defaultVirtualFieldsAfterRead: Hook['callback'] = (hookObject) => { + const object = hookObject.object + + if (!object) return + + const classDefinition = hookObject.context.wabe.config.schema?.classes?.find( + (c) => c.name === hookObject.className, + ) + + if (!classDefinition) return + + const selectedFields = Object.keys(hookObject.select || {}) + + if (selectedFields.length === 0) return + + for (const fieldName of selectedFields) { + const fieldDefinition = classDefinition.fields[fieldName] + + if (!isVirtualFieldDefinition(fieldDefinition)) continue + + object[fieldName] = fieldDefinition.callback(object) + } +} diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 5b202b7d..0ade3159 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -27,6 +27,17 @@ export type WabeRelationTypes = 'Pointer' | 'Relation' export type WabeFieldTypes = WabeCustomTypes | WabePrimaryTypes | WabeRelationTypes +export type VirtualReturnType = + | 'String' + | 'Int' + | 'Float' + | 'Boolean' + | 'Date' + | 'Email' + | 'Phone' + | 'Object' + | 'Array' + export type WabeObject = { name: string fields: SchemaFields @@ -92,7 +103,37 @@ type TypeFieldCustomEnums = { defaultValue?: any } -export type TypeField = ( +type TypeFieldVirtualScalar = { + type: 'Virtual' + returnType: 'String' | 'Int' | 'Float' | 'Boolean' | 'Date' | 'Email' | 'Phone' + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => string | number | boolean | Date | null +} + +type TypeFieldVirtualObject = { + type: 'Virtual' + returnType: 'Object' + object: WabeObject + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => Record | null +} + +type TypeFieldVirtualArray = { + type: 'Virtual' + returnType: 'Array' + defaultValue?: never + dependsOn: Array + callback: (object: T['types'][K] & { id: string }) => unknown[] | null +} & ({ typeValue: WabePrimaryTypes } | { typeValue: 'Object'; object: WabeObject }) + +type TypeFieldVirtual = + | TypeFieldVirtualScalar + | TypeFieldVirtualObject + | TypeFieldVirtualArray + +export type TypeField = ( | TypeFieldBase | TypeFieldBase | TypeFieldBase @@ -108,10 +149,14 @@ export type TypeField = ( | TypeFieldFile | TypeFieldCustomScalars | TypeFieldCustomEnums + | TypeFieldVirtual ) & FieldBase -export type SchemaFields = Record> +export type SchemaFields< + T extends WabeTypes, + K extends keyof T['types'] = keyof T['types'], +> = Record> export type ResolverType = { required?: boolean @@ -183,7 +228,7 @@ export type ClassIndexes = Array<{ export interface ClassInterface { name: string - fields: SchemaFields + fields: SchemaFields description?: string permissions?: ClassPermissions searchableFields?: SearchableFields