diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c62776..356c826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ This changelog documents the changes between release versions. ## main Changes to be included in the next upcoming release +- Support for NDC Spec v0.1.0-rc.15 via the NDC TypeScript SDK v3.0.0. This is a breaking change and must be used with the latest Hasura engine. + - Support for nested object/array selection + - New function calling convention that relies on nested object queries + - New mutation request/response format + ## v0.14.0 - Support for "relaxed types" ([#10](https://github.com/hasura/ndc-nodejs-lambda/pull/10)) diff --git a/ndc-lambda-sdk/package-lock.json b/ndc-lambda-sdk/package-lock.json index 5e4f6c7..dc71184 100644 --- a/ndc-lambda-sdk/package-lock.json +++ b/ndc-lambda-sdk/package-lock.json @@ -9,7 +9,7 @@ "version": "0.14.0", "license": "Apache-2.0", "dependencies": { - "@hasura/ndc-sdk-typescript": "^1.2.8", + "@hasura/ndc-sdk-typescript": "^3.0.0", "@json-schema-tools/meta-schema": "^1.7.0", "@tsconfig/node18": "^18.2.2", "commander": "^11.1.0", @@ -74,12 +74,11 @@ } }, "node_modules/@hasura/ndc-sdk-typescript": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@hasura/ndc-sdk-typescript/-/ndc-sdk-typescript-1.2.8.tgz", - "integrity": "sha512-nmQXRbo8dAcdtdmE0pO5J7Ofka0M3QiwtAtAD4JCo0n/nJWUn1tFZy9BIRguYUQW7EOnYk83DxcVm1/sySDn4g==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@hasura/ndc-sdk-typescript/-/ndc-sdk-typescript-3.0.0.tgz", + "integrity": "sha512-Y4CuobtWdWSh5CCetyPzflyo1vISmZJenZ6DELRTlHHt+wwr2lCCKXb2rGRg7rbDglzG3KE2LnoER16LuGnoiw==", "dependencies": { "@json-schema-tools/meta-schema": "^1.7.0", - "@types/node": "^20.6.0", "commander": "^11.0.0", "fastify": "^4.23.2", "pino-pretty": "^10.2.3" @@ -370,6 +369,7 @@ "version": "20.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2306,7 +2306,8 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "peer": true }, "node_modules/unicode-emoji-modifier-base": { "version": "1.0.0", diff --git a/ndc-lambda-sdk/package.json b/ndc-lambda-sdk/package.json index fdd0247..5f27d28 100644 --- a/ndc-lambda-sdk/package.json +++ b/ndc-lambda-sdk/package.json @@ -30,7 +30,7 @@ "url": "git+https://github.com/hasura/ndc-nodejs-lambda.git" }, "dependencies": { - "@hasura/ndc-sdk-typescript": "^1.2.8", + "@hasura/ndc-sdk-typescript": "^3.0.0", "@json-schema-tools/meta-schema": "^1.7.0", "@tsconfig/node18": "^18.2.2", "commander": "^11.1.0", diff --git a/ndc-lambda-sdk/src/cmdline.ts b/ndc-lambda-sdk/src/cmdline.ts index 9302f0d..6586d18 100644 --- a/ndc-lambda-sdk/src/cmdline.ts +++ b/ndc-lambda-sdk/src/cmdline.ts @@ -17,13 +17,13 @@ export function makeCommand(commandActions: CommandActions): Command { .name("ndc-lambda-sdk") .version(version); - const serveCommand = sdk.get_serve_command(); + const serveCommand = sdk.getServeCommand(); serveCommand.action((serverOptions: sdk.ServerOptions, command: Command) => { const hostOpts: HostOptions = hostCommand.opts(); return commandActions.serveAction(hostOpts, serverOptions); }) - const configurationServeCommand = sdk.get_serve_configuration_command(); + const configurationServeCommand = sdk.getServeConfigurationCommand(); configurationServeCommand.commands.find(c => c.name() === "serve")?.action((serverOptions: sdk.ConfigurationServerOptions, command: Command) => { const hostOpts: HostOptions = hostCommand.opts(); return commandActions.configurationServeAction(hostOpts, serverOptions); diff --git a/ndc-lambda-sdk/src/connector.ts b/ndc-lambda-sdk/src/connector.ts index 720c111..0750aad 100644 --- a/ndc-lambda-sdk/src/connector.ts +++ b/ndc-lambda-sdk/src/connector.ts @@ -15,7 +15,7 @@ export type State = { } export const RAW_CONFIGURATION_SCHEMA: JSONSchemaObject = { - description: 'NodeJS Functions SDK Connector Configuration', + description: 'NodeJS Lambda SDK Connector Configuration', type: 'object', required: [], properties: {} @@ -29,19 +29,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector = { - get_raw_configuration_schema: function (): JSONSchemaObject { + getRawConfigurationSchema: function (): JSONSchemaObject { return RAW_CONFIGURATION_SCHEMA; }, - make_empty_configuration: function (): RawConfiguration { + makeEmptyConfiguration: function (): RawConfiguration { return {}; }, - update_configuration: async function (rawConfiguration: RawConfiguration): Promise { + updateConfiguration: async function (rawConfiguration: RawConfiguration): Promise { return {}; }, - validate_raw_configuration: async function (rawConfiguration: RawConfiguration): Promise { + validateRawConfiguration: async function (rawConfiguration: RawConfiguration): Promise { const schemaResults = deriveSchema(functionsFilePath); printCompilerDiagnostics(schemaResults.compilerDiagnostics); printFunctionIssues(schemaResults.functionIssues); @@ -51,7 +51,7 @@ export function createConnector(options: ConnectorOptions): sdk.Connector { + tryInitState: async function (configuration: Configuration, metrics: unknown): Promise { if (Object.keys(configuration.functionsSchema.functions).length === 0) { // If there are no declared functions, don't bother trying to load the code. // There's very likely to be compiler errors during schema inference that will @@ -61,18 +61,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector { + getSchema: async function (configuration: Configuration): Promise { return getNdcSchema(configuration.functionsSchema); }, @@ -84,15 +85,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector { + queryExplain: function (configuration: Configuration, state: State, request: sdk.QueryRequest): Promise { throw new Error("Function not implemented."); }, - health_check: async function (configuration: Configuration, state: State): Promise { + mutationExplain: function (configuration: Configuration, state: State, request: sdk.MutationRequest): Promise { + throw new Error("Function not implemented."); + }, + + healthCheck: async function (configuration: Configuration, state: State): Promise { return undefined; }, - fetch_metrics: async function (configuration: Configuration, state: State): Promise { + fetchMetrics: async function (configuration: Configuration, state: State): Promise { return undefined; }, } diff --git a/ndc-lambda-sdk/src/execution.ts b/ndc-lambda-sdk/src/execution.ts index 49bc9f5..e18038e 100644 --- a/ndc-lambda-sdk/src/execution.ts +++ b/ndc-lambda-sdk/src/execution.ts @@ -34,30 +34,23 @@ export async function executeQuery(queryRequest: sdk.QueryRequest, functionsSche const parallelLimit = pLimit(functionDefinition.parallelDegree ?? DEFAULT_PARALLEL_DEGREE); const functionInvocations: Promise[] = functionInvocationPreparedArgs.map(invocationPreparedArgs => parallelLimit(async () => { const result = await invokeFunction(runtimeFunction, invocationPreparedArgs, functionName); - const prunedResult = reshapeResultToNdcResponseValue(result, functionDefinition.resultType, [], queryRequest.query.fields ?? {}, functionsSchema.objectTypes); - return { - aggregates: {}, - rows: [ - { - __value: prunedResult - } - ] - }; + return reshapeResultUsingFunctionCallingConvention(result, functionDefinition.resultType, queryRequest.query, functionsSchema.objectTypes); })); return await Promise.all(functionInvocations); } export async function executeMutation(mutationRequest: sdk.MutationRequest, functionsSchema: schema.FunctionsSchema, runtimeFunctions: RuntimeFunctions): Promise { - const operationResults: sdk.MutationOperationResults[] = []; + if (mutationRequest.operations.length > 1) + throw new sdk.NotSupported("Transactional mutations (multiple operations) are not supported"); + if (mutationRequest.operations.length <= 0) + throw new sdk.BadRequest("One mutation operation must be provided") - for (const mutationOperation of mutationRequest.operations) { - const result = await executeMutationOperation(mutationOperation, functionsSchema, runtimeFunctions); - operationResults.push(result); - } + const mutationOperation = mutationRequest.operations[0]!; + const result = await executeMutationOperation(mutationOperation, functionsSchema, runtimeFunctions); return { - operation_results: operationResults + operation_results: [result] }; } @@ -77,13 +70,11 @@ async function executeMutationOperation(mutationOperation: sdk.MutationOperation const preparedArgs = prepareArguments(mutationOperation.arguments, functionDefinition, functionsSchema.objectTypes); const result = await invokeFunction(runtimeFunction, preparedArgs, functionName); - const prunedResult = reshapeResultToNdcResponseValue(result, functionDefinition.resultType, [], mutationOperation.fields ?? {}, functionsSchema.objectTypes); + const reshapedResult = reshapeResultUsingFieldSelection(result, functionDefinition.resultType, [], mutationOperation.fields ?? { type: "scalar" }, functionsSchema.objectTypes); return { - affected_rows: 1, - returning: [{ - __value: prunedResult - }] + type: "procedure", + result: reshapedResult } } @@ -211,20 +202,76 @@ function buildCausalStackTrace(error: Error): string { return stackTrace; } -export function reshapeResultToNdcResponseValue(value: unknown, type: schema.TypeReference, valuePath: string[], fields: Record | "AllColumns", objectTypes: schema.ObjectTypeDefinitions): unknown { +// Represents either selecting a scalar (ie. the whole value, opaquely), an object (selecting properties), or an array (select whole array) +export type FieldSelection = sdk.NestedField | { type: "scalar" } + +function reshapeResultUsingFunctionCallingConvention(functionResultValue: unknown, functionResultType: schema.TypeReference, query: sdk.Query, objectTypes: schema.ObjectTypeDefinitions): sdk.RowSet { + if (query.aggregates) throw new sdk.NotSupported("Query aggregates are not supported"); + if (query.order_by) throw new sdk.NotSupported("Query order_by is not supported"); + if (query.predicate) throw new sdk.NotSupported("Query predicate is not supported"); + if (!query.fields) { + return { + aggregates: null, + rows: null, + } + } + // There's one virtual row in the function calling convention, so if the query (pointlessly) usees + // pagination to skip it, just do what it says + if (query.limit !== undefined && query.limit !== null && query.limit <= 0 + || query.offset !== undefined && query.offset !== null && query.offset >= 1) { + return { + aggregates: null, + rows: [], + } + } + + const rowValue = mapObjectValues(query.fields, (field: sdk.Field, fieldName: string) => { + switch (field.type) { + case "column": + if (field.column === "__value") { + return reshapeResultUsingFieldSelection(functionResultValue, functionResultType, [fieldName], field.fields ?? { type: "scalar" }, objectTypes); + } else { + throw new sdk.BadRequest(`Unknown column '${field.column}' used in root query field`) + } + + case "relationship": + throw new sdk.NotSupported(`Field '${fieldName}' is a relationship field, which is unsupported.'`) + + default: + return unreachable(field["type"]); + } + }); + + return { + aggregates: null, + rows: [rowValue] + } +} + +export function reshapeResultUsingFieldSelection(value: unknown, type: schema.TypeReference, valuePath: string[], fieldSelection: FieldSelection, objectTypes: schema.ObjectTypeDefinitions): unknown { switch (type.type) { case "array": - if (isArray(value)) { - return value.map((elementValue, index) => reshapeResultToNdcResponseValue(elementValue, type.elementType, [...valuePath, `[${index}]`], fields, objectTypes)) - } - break; + if (!isArray(value)) + throw new sdk.InternalServerError(`Expected an array, but received '${value === null ? "null" : null ?? typeof value}'`); + + const elementFieldSelection = (() => { + switch (fieldSelection.type) { + case "scalar": return fieldSelection; + case "array": return fieldSelection.fields; + case "object": throw new sdk.BadRequest(`Trying to perform an object selection on an array type at '${valuePath.join(".")}'`) + default: return unreachable(fieldSelection["type"]); + } + })(); + + return value.map((elementValue, index) => reshapeResultUsingFieldSelection(elementValue, type.elementType, [...valuePath, `[${index}]`], elementFieldSelection, objectTypes)) + case "nullable": // Selected fields must always return a value, so they cannot be undefined. So all // undefineds are coerced to nulls so that the field is included with a null value. return value === null || value === undefined ? null - : reshapeResultToNdcResponseValue(value, type.underlyingType, valuePath, fields, objectTypes); + : reshapeResultUsingFieldSelection(value, type.underlyingType, valuePath, fieldSelection, objectTypes); case "named": switch (type.kind) { @@ -240,23 +287,30 @@ export function reshapeResultToNdcResponseValue(value: unknown, type: schema.Typ if (value === null || Array.isArray(value) || typeof value !== "object") throw new sdk.InternalServerError(`Expected an object, but received '${value === null ? "null" : null ?? Array.isArray(value) ? "array" : null ?? typeof value}'`); - const selectedFields: Record = - fields === "AllColumns" - ? Object.fromEntries(objectType.properties.map(propDef => [propDef.propertyName, { type: "column", column: propDef.propertyName }])) - : fields; + const selectedFields: Record = (() => { + switch (fieldSelection.type) { + case "scalar": return Object.fromEntries(objectType.properties.map(propDef => [propDef.propertyName, { type: "column", column: propDef.propertyName }])); + case "array": throw new sdk.BadRequest(`Trying to perform an array selection on an object type at '${valuePath.join(".")}'`); + case "object": return fieldSelection.fields; + default: return unreachable(fieldSelection["type"]); + } + })(); return mapObjectValues(selectedFields, (field, fieldName) => { switch(field.type) { case "column": const objPropDef = objectType.properties.find(prop => prop.propertyName === field.column); if (objPropDef === undefined) - throw new sdk.InternalServerError(`Unable to find property definition '${field.column}' on object type '${type.name}'`); + throw new sdk.BadRequest(`Unable to find property definition '${field.column}' on object type '${type.name}' at '${valuePath.join(".")}'`); + + const columnFieldSelection = field.fields ?? { type: "scalar" }; + return reshapeResultUsingFieldSelection((value as Record)[field.column], objPropDef.type, [...valuePath, fieldName], columnFieldSelection, objectTypes) - // We pass "AllColumns" as the fields because we don't yet support nested field selections, so we just include all columns by default for now - return reshapeResultToNdcResponseValue((value as Record)[field.column], objPropDef.type, [...valuePath, field.column], "AllColumns", objectTypes) + case "relationship": + throw new sdk.NotSupported(`Field '${fieldName}' is a relationship field, which is unsupported.'`) default: - throw new sdk.NotSupported(`Field '${fieldName}' uses an unsupported field type: '${field.type}'`) + return unreachable(field["type"]); } }) diff --git a/ndc-lambda-sdk/src/host.ts b/ndc-lambda-sdk/src/host.ts index db431d0..3ffbe72 100644 --- a/ndc-lambda-sdk/src/host.ts +++ b/ndc-lambda-sdk/src/host.ts @@ -3,8 +3,8 @@ import { createConnector } from "./connector"; import { makeCommand } from "./cmdline"; const program = makeCommand({ - serveAction: (hostOpts, serveOpts) => sdk.start_server(createConnector({functionsFilePath: hostOpts.functions}), serveOpts), - configurationServeAction: (hostOpts, serveOpts) => sdk.start_configuration_server(createConnector({functionsFilePath: hostOpts.functions}), serveOpts), + serveAction: (hostOpts, serveOpts) => sdk.startServer(createConnector({functionsFilePath: hostOpts.functions}), serveOpts), + configurationServeAction: (hostOpts, serveOpts) => sdk.startConfigurationServer(createConnector({functionsFilePath: hostOpts.functions}), serveOpts), }); program.parseAsync().catch(err => { diff --git a/ndc-lambda-sdk/test/execution/execute-mutation.test.ts b/ndc-lambda-sdk/test/execution/execute-mutation.test.ts new file mode 100644 index 0000000..c1a04e0 --- /dev/null +++ b/ndc-lambda-sdk/test/execution/execute-mutation.test.ts @@ -0,0 +1,341 @@ +import { describe, it } from "mocha"; +import { assert, expect } from "chai"; +import * as sdk from "@hasura/ndc-sdk-typescript" +import { executeMutation } from "../../src/execution"; +import { FunctionNdcKind, FunctionsSchema } from "../../src/schema"; + +describe("execute mutation", function() { + it("executes the function", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": (param: string) => { + functionCallCount++; + return `Called with '${param}'`; + } + }; + const functionSchema: FunctionsSchema = { + functions: { + "theFunction": { + ndcKind: FunctionNdcKind.Procedure, + description: null, + parallelDegree: null, + arguments: [ + { + argumentName: "param", + description: null, + type: { + type: "named", + kind: "scalar", + name: "String" + } + }, + ], + resultType: { + type: "named", + kind: "scalar", + name: "String" + } + } + }, + objectTypes: {}, + scalarTypes: { + "String": { type: "built-in" }, + } + }; + const mutationRequest: sdk.MutationRequest = { + operations: [ + { + type: "procedure", + name: "theFunction", + fields: null, + arguments: { + "param": "test" + } + } + ], + collection_relationships: {} + }; + + const result = await executeMutation(mutationRequest, functionSchema, runtimeFunctions); + assert.deepStrictEqual(result, { + operation_results: [ + { + type: "procedure", + result: "Called with 'test'" + } + ] + }); + assert.equal(functionCallCount, 1); + }); + + it("can select into the result using nested fields", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": (param: string) => { + functionCallCount++; + return { + calledWithStr: `Called with '${param}'`, + param, + functionCallCount, + }; + } + }; + const functionSchema: FunctionsSchema = { + functions: { + "theFunction": { + ndcKind: FunctionNdcKind.Procedure, + description: null, + parallelDegree: null, + arguments: [ + { + argumentName: "param", + description: null, + type: { + type: "named", + kind: "scalar", + name: "String" + } + }, + ], + resultType: { + type: "named", + kind: "object", + name: "FunctionResult" + } + } + }, + objectTypes: { + "FunctionResult": { + description: null, + properties: [ + { + propertyName: "calledWithStr", + description: null, + type: { + type: "named", + kind: "scalar", + name: "String" + } + }, + { + propertyName: "param", + description: null, + type: { + type: "named", + kind: "scalar", + name: "String" + } + }, + { + propertyName: "functionCallCount", + description: null, + type: { + type: "named", + kind: "scalar", + name: "Float" + } + } + ], + isRelaxedType: false, + } + }, + scalarTypes: { + "Float": { type: "built-in" }, + "String": { type: "built-in" }, + } + }; + const mutationRequest: sdk.MutationRequest = { + operations: [ + { + type: "procedure", + name: "theFunction", + fields: { + type: "object", + fields: { + "str": { + type: "column", + column: "calledWithStr", + }, + "callCount": { + type: "column", + column: "functionCallCount" + } + } + }, + arguments: { + "param": "test" + } + } + ], + collection_relationships: {} + }; + + const result = await executeMutation(mutationRequest, functionSchema, runtimeFunctions); + assert.deepStrictEqual(result, { + operation_results: [ + { + type: "procedure", + result: { + str: "Called with 'test'", + callCount: 1 + } + } + ] + }); + assert.equal(functionCallCount, 1); + }); + + it("blocks execution of multiple operations", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": (param: string) => { + functionCallCount++; + return `First function called with '${param}'`; + } + }; + const functionSchema: FunctionsSchema = { + functions: { + "theFunction": { + ndcKind: FunctionNdcKind.Procedure, + description: null, + parallelDegree: null, + arguments: [ + { + argumentName: "param", + description: null, + type: { + type: "named", + kind: "scalar", + name: "String" + } + }, + ], + resultType: { + type: "named", + kind: "scalar", + name: "String" + } + } + }, + objectTypes: {}, + scalarTypes: { + "String": { type: "built-in" }, + } + }; + const mutationRequest: sdk.MutationRequest = { + operations: [ + { + type: "procedure", + name: "theFunction", + fields: null, + arguments: { + "param": "test" + } + }, + { + type: "procedure", + name: "theFunction", + fields: null, + arguments: { + "param": "test2" + } + } + ], + collection_relationships: {} + }; + + await expect(executeMutation(mutationRequest, functionSchema, runtimeFunctions)) + .to.be.rejectedWith(sdk.NotSupported, "Transactional mutations (multiple operations) are not supported"); + assert.equal(functionCallCount, 0); + }); + + describe("function error handling", function() { + const functionSchema: FunctionsSchema = { + functions: { + "theFunction": { + ndcKind: FunctionNdcKind.Procedure, + description: null, + parallelDegree: null, + arguments: [], + resultType: { + type: "named", + kind: "scalar", + name: "String" + } + } + }, + objectTypes: {}, + scalarTypes: { + "String": { type: "built-in" }, + } + }; + const mutationRequest: sdk.MutationRequest = { + operations: [{ + type: "procedure", + name: "theFunction", + arguments: {}, + fields: null, + }], + collection_relationships: {} + }; + + it("Error -> sdk.InternalServerError", async function() { + const runtimeFunctions = { + "theFunction": () => { + throw new Error("BOOM!"); + } + }; + + await expect(executeMutation(mutationRequest, functionSchema, runtimeFunctions)) + .to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'") + .which.eventually.has.property("details") + .which.include.keys("stack") + .and.has.property("message", "BOOM!"); + }); + + it("string -> sdk.InternalServerError", async function() { + const runtimeFunctions = { + "theFunction": () => { + throw "A bad way to throw errors"; + } + }; + + await expect(executeMutation(mutationRequest, functionSchema, runtimeFunctions)) + .to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'") + .which.eventually.has.property("details") + .and.has.property("message", "A bad way to throw errors"); + }); + + it("unknown -> sdk.InternalServerError", async function() { + const runtimeFunctions = { + "theFunction": () => { + throw 666; // What are you even doing? 👊 + } + }; + + await expect(executeMutation(mutationRequest, functionSchema, runtimeFunctions)) + .to.be.rejectedWith(sdk.InternalServerError, "Error encountered when invoking function 'theFunction'"); + }); + + describe("sdk exceptions are passed through", function() { + const exceptions = [ + sdk.BadRequest, sdk.Forbidden, sdk.Conflict, sdk.UnprocessableContent, sdk.InternalServerError, sdk.NotSupported, sdk.BadGateway + ]; + + for (const exceptionCtor of exceptions) { + it(`sdk.${exceptionCtor.name}`, async function() { + const runtimeFunctions = { + "theFunction": () => { + throw new exceptionCtor("Nope!", { deets: "stuff" }); + } + }; + + await expect(executeMutation(mutationRequest, functionSchema, runtimeFunctions)) + .to.be.rejectedWith(exceptionCtor, "Nope!") + .and.eventually.property("details").deep.equals({"deets": "stuff"}); + }); + } + }); + }) + +}); diff --git a/ndc-lambda-sdk/test/execution/execute-query.test.ts b/ndc-lambda-sdk/test/execution/execute-query.test.ts index b889013..077fb6a 100644 --- a/ndc-lambda-sdk/test/execution/execute-query.test.ts +++ b/ndc-lambda-sdk/test/execution/execute-query.test.ts @@ -46,7 +46,12 @@ describe("execute query", function() { const queryRequest: sdk.QueryRequest = { collection: "theFunction", query: { - fields: {}, + fields: { + __value: { + type: "column", + column: "__value", + } + }, }, arguments: { "param": { @@ -60,7 +65,7 @@ describe("execute query", function() { const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); assert.deepStrictEqual(result, [ { - aggregates: {}, + aggregates: null, rows: [ { __value: "Called with 'test'" } ] @@ -118,7 +123,12 @@ describe("execute query", function() { const queryRequest: sdk.QueryRequest = { collection: "theFunction", query: { - fields: {}, + fields: { + __value: { + type: "column", + column: "__value", + } + }, }, arguments: { "param": { @@ -144,13 +154,13 @@ describe("execute query", function() { const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); assert.deepStrictEqual(result, [ { - aggregates: {}, + aggregates: null, rows: [ { __value: "Called with 'test' and 'first'" } ] }, { - aggregates: {}, + aggregates: null, rows: [ { __value: "Called with 'test' and 'second'" } ] @@ -209,7 +219,12 @@ describe("execute query", function() { const queryRequest: sdk.QueryRequest = { collection: "theFunction", query: { - fields: {}, + fields: { + __value: { + type: "column", + column: "__value", + } + }, }, arguments: { "invocationName": { @@ -245,25 +260,25 @@ describe("execute query", function() { const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); assert.deepStrictEqual(result, [ { - aggregates: {}, + aggregates: null, rows: [ { __value: "first" } ] }, { - aggregates: {}, + aggregates: null, rows: [ { __value: "second" } ] }, { - aggregates: {}, + aggregates: null, rows: [ { __value: "third" } ] }, { - aggregates: {}, + aggregates: null, rows: [ { __value: "fourth" } ] @@ -295,14 +310,14 @@ describe("execute query", function() { const queryRequest: sdk.QueryRequest = { collection: "theFunction", query: { - fields: {}, - }, - arguments: { - "param": { - type: "literal", - value: "test" - } + fields: { + __value: { + type: "column", + column: "__value", + } + }, }, + arguments: {}, collection_relationships: {} }; @@ -364,4 +379,119 @@ describe("execute query", function() { } }); }) + + describe("function calling convention", function() { + const functionSchema: FunctionsSchema = { + functions: { + "theFunction": { + ndcKind: FunctionNdcKind.Function, + description: null, + parallelDegree: null, + arguments: [], + resultType: { + type: "named", + kind: "scalar", + name: "String" + } + } + }, + objectTypes: {}, + scalarTypes: { + "String": { type: "built-in" }, + } + }; + + it("null fields produces null rows", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": () => { + functionCallCount++; + return `Called ${functionCallCount} times total`; + } + }; + const queryRequest: sdk.QueryRequest = { + collection: "theFunction", + query: { + fields: null + }, + arguments: {}, + collection_relationships: {} + }; + + const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); + assert.deepStrictEqual(result, [ + { + aggregates: null, + rows: null + } + ]); + assert.equal(functionCallCount, 1); + }); + + it("empty fields produces one row with an empty result object", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": () => { + functionCallCount++; + return `Called ${functionCallCount} times total`; + } + }; + const queryRequest: sdk.QueryRequest = { + collection: "theFunction", + query: { + fields: {} + }, + arguments: {}, + collection_relationships: {} + }; + + const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); + assert.deepStrictEqual(result, [ + { + aggregates: null, + rows: [{}] + } + ]); + assert.equal(functionCallCount, 1); + }); + + it("can duplicate virtual __value column", async function() { + let functionCallCount = 0; + const runtimeFunctions = { + "theFunction": () => { + functionCallCount++; + return `Called ${functionCallCount} times total`; + } + }; + const queryRequest: sdk.QueryRequest = { + collection: "theFunction", + query: { + fields: { + value1: { + type: "column", + column: "__value" + }, + value2: { + type: "column", + column: "__value" + } + } + }, + arguments: {}, + collection_relationships: {} + }; + + const result = await executeQuery(queryRequest, functionSchema, runtimeFunctions); + assert.deepStrictEqual(result, [ + { + aggregates: null, + rows: [{ + value1: "Called 1 times total", + value2: "Called 1 times total" + }] + } + ]); + assert.equal(functionCallCount, 1); + }); + }); }); diff --git a/ndc-lambda-sdk/test/execution/reshape-result.test.ts b/ndc-lambda-sdk/test/execution/reshape-result.test.ts index bfd4e20..f8929ed 100644 --- a/ndc-lambda-sdk/test/execution/reshape-result.test.ts +++ b/ndc-lambda-sdk/test/execution/reshape-result.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "mocha"; import { assert } from "chai"; import * as sdk from "@hasura/ndc-sdk-typescript" -import { reshapeResultToNdcResponseValue } from "../../src/execution"; +import { FieldSelection, reshapeResultUsingFieldSelection } from "../../src/execution"; import { ArrayTypeReference, BuiltInScalarTypeName, JSONValue, NamedTypeReference, NullOrUndefinability, NullableTypeReference, ObjectTypeDefinitions } from "../../src/schema"; describe("reshape result", function() { @@ -49,7 +49,7 @@ describe("reshape result", function() { for (const testCase of testCases) { it(testCase.testName, function () { const scalarType: NamedTypeReference = { type: "named", kind: "scalar", name: testCase.type }; - const result = reshapeResultToNdcResponseValue(testCase.value, scalarType, [], "AllColumns", {}); + const result = reshapeResultUsingFieldSelection(testCase.value, scalarType, [], { type: "scalar" }, {}); assert.deepStrictEqual(result, testCase.reshapedValue); }) } @@ -80,13 +80,13 @@ describe("reshape result", function() { for (const testCase of testCases) { it(testCase.testName, function () { const nullableType: NullableTypeReference = { type: "nullable", nullOrUndefinability: NullOrUndefinability.AcceptsEither, underlyingType: { type: "named", kind: "scalar", name: testCase.scalarType } }; - const result = reshapeResultToNdcResponseValue(testCase.value, nullableType, [], "AllColumns", {}); + const result = reshapeResultUsingFieldSelection(testCase.value, nullableType, [], { type: "scalar" }, {}); assert.strictEqual(result, testCase.reshapedValue); }) } }); - describe("projects object types using fields", function() { + describe("projects object types using field selection", function() { const objectTypes: ObjectTypeDefinitions = { "TestObjectType": { description: null, @@ -112,39 +112,39 @@ describe("reshape result", function() { } const testCases = [ { - testName: "AllColumns", + testName: "As a scalar", value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, - fields: "AllColumns" as const, + fieldSelection: { type: "scalar" }, reshapedValue: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB", nested: null } }, }, { testName: "propA, propB, nested", value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, - fields: >{ propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" }, nested: { type: "column", column: "nested" } }, + fieldSelection: { type: "object", fields: { propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" }, nested: { type: "column", column: "nested" } } }, reshapedValue: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB", nested: null } }, }, { testName: "renamedPropA:propA, renamedPropB:propB", value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, - fields: >{ renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } }, + fieldSelection: { type: "object", fields: { renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } } }, reshapedValue: { renamedPropA: "valueA", renamedPropB: "valueB" }, }, { testName: "propB", value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, - fields: >{ propB: { type: "column", column: "propB" } }, + fieldSelection: { type: "object", fields: { propB: { type: "column", column: "propB" } } }, reshapedValue: { propB: "valueB" }, }, { testName: "propB, duplicatedPropB", value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, - fields: >{ propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } }, + fieldSelection: { type: "object", fields: { propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } } }, reshapedValue: { propB: "valueB", duplicatedPropB: "valueB" }, }, { testName: "missingProp:nested", value: { propA: "valueA", propB: "valueB" }, - fields: >{ missingProp: { type: "column", column: "nested" } }, + fieldSelection: { type: "object", fields: { missingProp: { type: "column", column: "nested" } } }, reshapedValue: { missingProp: null }, }, ] @@ -152,7 +152,7 @@ describe("reshape result", function() { for (const testCase of testCases) { it(testCase.testName, function () { const objectType: NamedTypeReference = { type: "named", kind: "object", name: "TestObjectType" }; - const result = reshapeResultToNdcResponseValue(testCase.value, objectType, [], testCase.fields, objectTypes); + const result = reshapeResultUsingFieldSelection(testCase.value, objectType, [], testCase.fieldSelection, objectTypes); assert.deepStrictEqual(result, testCase.reshapedValue); }) } @@ -160,7 +160,7 @@ describe("reshape result", function() { it("serializes scalar array type", function() { const arrayType: ArrayTypeReference = { type: "array", elementType: { type: "named", kind: "scalar", name: BuiltInScalarTypeName.Float } }; - const result = reshapeResultToNdcResponseValue([1,2,3], arrayType, [], "AllColumns", {}); + const result = reshapeResultUsingFieldSelection([1,2,3], arrayType, [], { type: "scalar" }, {}); assert.deepStrictEqual(result, [1,2,3]); }); @@ -185,12 +185,12 @@ describe("reshape result", function() { } const testCases = [ { - testName: "AllColumns", + testName: "As a scalar", value: [ { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: "AllColumns" as const, + fieldSelection: { type: "scalar" }, reshapedValue: [ { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, @@ -202,7 +202,7 @@ describe("reshape result", function() { { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: >{ propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" } }, + fieldSelection: { type: "array", fields: { type: "object", fields: { propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" } } } }, reshapedValue: [ { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, @@ -214,7 +214,7 @@ describe("reshape result", function() { { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: >{ renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } }, + fieldSelection: { type: "array", fields: { type: "object", fields: { renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } } } }, reshapedValue: [ { renamedPropA: "valueA1", renamedPropB: "valueB1" }, { renamedPropA: "valueA2", renamedPropB: "valueB2" }, @@ -226,19 +226,19 @@ describe("reshape result", function() { { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: >{ propB: { type: "column", column: "propB" } }, + fieldSelection: { type: "array", fields: { type: "object", fields: { propB: { type: "column", column: "propB" } } } }, reshapedValue: [ { propB: "valueB1" }, { propB: "valueB2" }, ], }, { - testName: "propB, duplicatedPropB", + testName: "propB, duplicatedPropB:propB", value: [ { propA: "valueA1", propB: "valueB1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: >{ propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } }, + fieldSelection: { type: "array", fields: { type: "object", fields: { propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } } } }, reshapedValue: [ { propB: "valueB1", duplicatedPropB: "valueB1" }, { propB: "valueB2", duplicatedPropB: "valueB2" }, @@ -250,7 +250,7 @@ describe("reshape result", function() { { propA: "valueA1" }, { propA: "valueA2", propB: "valueB2" }, ], - fields: >{ missingProp: { type: "column", column: "propB" } }, + fieldSelection: { type: "array", fields: { type: "object", fields: { missingProp: { type: "column", column: "propB" } } } }, reshapedValue: [ { missingProp: null }, { missingProp: "valueB2" } @@ -261,7 +261,273 @@ describe("reshape result", function() { for (const testCase of testCases) { it(testCase.testName, function () { const arrayType: ArrayTypeReference = { type: "array", elementType: { type: "named", kind: "object", name: "TestObjectType" } }; - const result = reshapeResultToNdcResponseValue(testCase.value, arrayType, [], testCase.fields, objectTypes); + const result = reshapeResultUsingFieldSelection(testCase.value, arrayType, [], testCase.fieldSelection, objectTypes); + assert.deepStrictEqual(result, testCase.reshapedValue); + }) + } + }); + + describe("projects nested objects using nested fields", function() { + const objectTypes: ObjectTypeDefinitions = { + "TestObjectType": { + description: null, + properties: [ + { + propertyName: "propA", + description: null, + type: { type: "named", kind: "scalar", name: BuiltInScalarTypeName.String } + }, + { + propertyName: "propB", + description: null, + type: { type: "named", kind: "scalar", name: BuiltInScalarTypeName.String } + }, + { + propertyName: "nested", + description: null, + type: { type: "nullable", nullOrUndefinability: NullOrUndefinability.AcceptsEither, underlyingType: { type: "named", kind: "object", name: "TestObjectType" } } + } + ], + isRelaxedType: false, + } + } + const testCases = [ + { + testName: "AllColumns", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { type: "scalar" }, + reshapedValue: { + propA: "valueA", + propB: "valueB", + nested: { propA: "nestedValueA", propB: "nestedValueB", nested: null, }, + }, + }, + { + testName: "nested.propA, nested.propB", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { + type: "object", + fields: { + nested: { + type: "column", + column: "nested", + fields: { type: "object", fields: { propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" } } } + } + } + }, + reshapedValue: { nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + }, + { + testName: "nested.(renamedPropA:propA), nested.(renamedPropB:propB)", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { + type: "object", + fields: { + nested: { + type: "column", + column: "nested", + fields: { type: "object", fields: { renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } } } + } + } + }, + reshapedValue: { nested: { renamedPropA: "nestedValueA", renamedPropB: "nestedValueB" } }, + }, + { + testName: "nested.propB", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { + type: "object", + fields: { + nested: { + type: "column", + column: "nested", + fields: { type: "object", fields: { propB: { type: "column", column: "propB" } } } + } + } + }, + reshapedValue: { nested: { propB: "nestedValueB" } }, + }, + { + testName: "nested.propB, nested.(duplicatedPropB:propB}", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { + type: "object", + fields: { + nested: { + type: "column", + column: "nested", + fields: { type: "object", fields: { propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } } } + } + } + }, + reshapedValue: { nested: { propB: "nestedValueB", duplicatedPropB: "nestedValueB" } }, + }, + { + testName: "nested.(missingProp:nested)", + value: { propA: "valueA", propB: "valueB", nested: { propA: "nestedValueA", propB: "nestedValueB" } }, + fieldSelection: { + type: "object", + fields: { + nested: { + type: "column", + column: "nested", + fields: { type: "object", fields: { missingProp: { type: "column", column: "nested" } } } + } + } + }, + reshapedValue: { nested: { missingProp: null } }, + }, + ] + + for (const testCase of testCases) { + it(testCase.testName, function () { + const objectType: NamedTypeReference = { type: "named", kind: "object", name: "TestObjectType" }; + const result = reshapeResultUsingFieldSelection(testCase.value, objectType, [], testCase.fieldSelection, objectTypes); + assert.deepStrictEqual(result, testCase.reshapedValue); + }) + } + }); + + describe("projects nested arrays using nested fields", function() { + const objectTypes: ObjectTypeDefinitions = { + "TestObjectType": { + description: null, + properties: [ + { + propertyName: "propA", + description: null, + type: { type: "named", kind: "scalar", name: BuiltInScalarTypeName.String } + }, + { + propertyName: "propB", + description: null, + type: { type: "named", kind: "scalar", name: BuiltInScalarTypeName.String } + }, + { + propertyName: "nestedArray", + description: null, + type: { type: "nullable", nullOrUndefinability: NullOrUndefinability.AcceptsEither, underlyingType: { type: "array", elementType: { type: "named", kind: "object", name: "TestObjectType" } } } + } + ], + isRelaxedType: false, + } + } + const testCases = [ + { + testName: "AllColumns", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { type: "scalar" }, + reshapedValue: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B", nestedArray: null }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B", nestedArray: null } ] + }, + }, + { + testName: "nestedArray.propA, nestedArray.propB", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { + type: "object", + fields: { + nestedArray: { + type: "column", + column: "nestedArray", + fields: { type: "array", fields: { type: "object", fields: { propA: { type: "column", column: "propA" }, propB: { type: "column", column: "propB" } } } } + } + } + }, + reshapedValue: { nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] }, + }, + { + testName: "nestedArray.(renamedPropA:propA), nestedArray.(renamedPropB:propB)", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { + type: "object", + fields: { + nestedArray: { + type: "column", + column: "nestedArray", + fields: { type: "array", fields: { type: "object", fields: { renamedPropA: { type: "column", column: "propA" }, renamedPropB: { type: "column", column: "propB" } } } } + } + } + }, + reshapedValue: { nestedArray: [ { renamedPropA: "nestedArrayValue1A", renamedPropB: "nestedArrayValue1B" }, { renamedPropA: "nestedArrayValue2A", renamedPropB: "nestedArrayValue2B" } ] }, + }, + { + testName: "nestedArray.propB", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { + type: "object", + fields: { + nestedArray: { + type: "column", + column: "nestedArray", + fields: { type: "array", fields: { type: "object", fields: { propB: { type: "column", column: "propB" } } } } + } + } + }, + reshapedValue: { nestedArray: [ { propB: "nestedArrayValue1B" }, { propB: "nestedArrayValue2B" } ] }, + }, + { + testName: "nestedArray.propB, nestedArray.(duplicatedPropB:propB}", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { + type: "object", + fields: { + nestedArray: { + type: "column", + column: "nestedArray", + fields: { type: "array", fields: { type: "object", fields: { propB: { type: "column", column: "propB" }, duplicatedPropB: { type: "column", column: "propB" } } } } + } + } + }, + reshapedValue: { nestedArray: [ { propB: "nestedArrayValue1B", duplicatedPropB: "nestedArrayValue1B" }, { propB: "nestedArrayValue2B", duplicatedPropB: "nestedArrayValue2B" } ] }, + }, + { + testName: "nestedArray.(missingProp:nested)", + value: { + propA: "valueA", + propB: "valueB", + nestedArray: [ { propA: "nestedArrayValue1A", propB: "nestedArrayValue1B" }, { propA: "nestedArrayValue2A", propB: "nestedArrayValue2B" } ] + }, + fieldSelection: { + type: "object", + fields: { + nestedArray: { + type: "column", + column: "nestedArray", + fields: { type: "array", fields: { type: "object", fields: { missingProp: { type: "column", column: "nestedArray" } } } } + } + } + }, + reshapedValue: { nestedArray: [ { missingProp: null }, { missingProp: null } ] }, + }, + ] + + for (const testCase of testCases) { + it(testCase.testName, function () { + const objectType: NamedTypeReference = { type: "named", kind: "object", name: "TestObjectType" }; + const result = reshapeResultUsingFieldSelection(testCase.value, objectType, [], testCase.fieldSelection, objectTypes); assert.deepStrictEqual(result, testCase.reshapedValue); }) }