Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for NDC Spec v0.1.0-rc.15 and nested object/array selection #8

Merged
merged 9 commits into from
Feb 16, 2024
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
13 changes: 7 additions & 6 deletions ndc-lambda-sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ndc-lambda-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved
"@json-schema-tools/meta-schema": "^1.7.0",
"@tsconfig/node18": "^18.2.2",
"commander": "^11.1.0",
Expand Down
4 changes: 2 additions & 2 deletions ndc-lambda-sdk/src/cmdline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 17 additions & 12 deletions ndc-lambda-sdk/src/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
Expand All @@ -29,19 +29,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector<RawCon
const functionsFilePath = path.resolve(options.functionsFilePath);

const connector: sdk.Connector<RawConfiguration, Configuration, State> = {
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<RawConfiguration> {
updateConfiguration: async function (rawConfiguration: RawConfiguration): Promise<RawConfiguration> {
return {};
},

validate_raw_configuration: async function (rawConfiguration: RawConfiguration): Promise<Configuration> {
validateRawConfiguration: async function (rawConfiguration: RawConfiguration): Promise<Configuration> {
const schemaResults = deriveSchema(functionsFilePath);
printCompilerDiagnostics(schemaResults.compilerDiagnostics);
printFunctionIssues(schemaResults.functionIssues);
Expand All @@ -51,7 +51,7 @@ export function createConnector(options: ConnectorOptions): sdk.Connector<RawCon
}
},

try_init_state: async function (configuration: Configuration, metrics: unknown): Promise<State> {
tryInitState: async function (configuration: Configuration, metrics: unknown): Promise<State> {
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
Expand All @@ -61,18 +61,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector<RawCon
return { runtimeFunctions: require(functionsFilePath) }
},

get_capabilities: function (configuration: Configuration): sdk.CapabilitiesResponse {
getCapabilities: function (configuration: Configuration): sdk.CapabilitiesResponse {
return {
versions: "^0.1.0",
version: "0.1.0",
capabilities: {
query: {
variables: {}
},
mutation: {},
}
};
},

get_schema: async function (configuration: Configuration): Promise<sdk.SchemaResponse> {
getSchema: async function (configuration: Configuration): Promise<sdk.SchemaResponse> {
return getNdcSchema(configuration.functionsSchema);
},

Expand All @@ -84,15 +85,19 @@ export function createConnector(options: ConnectorOptions): sdk.Connector<RawCon
return await executeMutation(request, configuration.functionsSchema, state.runtimeFunctions);
},

explain: function (configuration: Configuration, state: State, request: sdk.QueryRequest): Promise<sdk.ExplainResponse> {
queryExplain: function (configuration: Configuration, state: State, request: sdk.QueryRequest): Promise<sdk.ExplainResponse> {
throw new Error("Function not implemented.");
},

health_check: async function (configuration: Configuration, state: State): Promise<undefined> {
mutationExplain: function (configuration: Configuration, state: State, request: sdk.MutationRequest): Promise<sdk.ExplainResponse> {
throw new Error("Function not implemented.");
},

healthCheck: async function (configuration: Configuration, state: State): Promise<undefined> {
return undefined;
},

fetch_metrics: async function (configuration: Configuration, state: State): Promise<undefined> {
fetchMetrics: async function (configuration: Configuration, state: State): Promise<undefined> {
return undefined;
},
}
Expand Down
122 changes: 88 additions & 34 deletions ndc-lambda-sdk/src/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,23 @@ export async function executeQuery(queryRequest: sdk.QueryRequest, functionsSche
const parallelLimit = pLimit(functionDefinition.parallelDegree ?? DEFAULT_PARALLEL_DEGREE);
const functionInvocations: Promise<sdk.RowSet>[] = 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<sdk.MutationResponse> {
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")
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved

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]
};
}

Expand All @@ -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);
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved

return {
affected_rows: 1,
returning: [{
__value: prunedResult
}]
type: "procedure",
result: reshapedResult
}
}

Expand Down Expand Up @@ -211,20 +202,76 @@ function buildCausalStackTrace(error: Error): string {
return stackTrace;
}

export function reshapeResultToNdcResponseValue(value: unknown, type: schema.TypeReference, valuePath: string[], fields: Record<string, sdk.Field> | "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" }
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved

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,
}
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved
}
// 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: [],
}
}
daniel-chambers marked this conversation as resolved.
Show resolved Hide resolved

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) {
Expand All @@ -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<string, sdk.Field> =
fields === "AllColumns"
? Object.fromEntries(objectType.properties.map(propDef => [propDef.propertyName, { type: "column", column: propDef.propertyName }]))
: fields;
const selectedFields: Record<string, sdk.Field> = (() => {
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<string, unknown>)[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<string, unknown>)[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"]);
}
})

Expand Down
4 changes: 2 additions & 2 deletions ndc-lambda-sdk/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Loading