Skip to content

Commit

Permalink
Support for NDC Spec v0.1.0-rc.15 and nested object/array selection (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
daniel-chambers authored Feb 16, 2024
1 parent a21eb56 commit a8600ca
Show file tree
Hide file tree
Showing 10 changed files with 898 additions and 96 deletions.
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",
"@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")

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);

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" }

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) {
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

0 comments on commit a8600ca

Please sign in to comment.