From cb3569fedd34d5a6d4a95d47867dbe94b539d12f Mon Sep 17 00:00:00 2001 From: Carson Full Date: Wed, 20 Mar 2024 08:49:32 -0500 Subject: [PATCH] Refactor codec to TS generation for fine grained customization (#889) --- .../dbschema/queries/deepArrayInput.edgeql | 1 + integration-tests/lts/queries.test.ts | 28 ++ packages/driver/src/baseClient.ts | 23 ++ packages/driver/src/codecs/consts.ts | 6 - packages/driver/src/codecs/object.ts | 8 +- .../driver/src/reflection/analyzeQuery.ts | 290 +++++++++++------- packages/driver/src/reflection/util.ts | 21 ++ packages/generate/src/queries.ts | 5 +- 8 files changed, 254 insertions(+), 128 deletions(-) create mode 100644 integration-tests/lts/dbschema/queries/deepArrayInput.edgeql diff --git a/integration-tests/lts/dbschema/queries/deepArrayInput.edgeql b/integration-tests/lts/dbschema/queries/deepArrayInput.edgeql new file mode 100644 index 000000000..321d19d36 --- /dev/null +++ b/integration-tests/lts/dbschema/queries/deepArrayInput.edgeql @@ -0,0 +1 @@ +select >>$deep; diff --git a/integration-tests/lts/queries.test.ts b/integration-tests/lts/queries.test.ts index 5efb21f3a..cc271b49e 100644 --- a/integration-tests/lts/queries.test.ts +++ b/integration-tests/lts/queries.test.ts @@ -6,6 +6,8 @@ import { getMoviesStarring, type GetMoviesStarringArgs, type GetMoviesStarringReturns, + deepArrayInput, + type DeepArrayInputArgs, } from "./dbschema/queries"; import { setupTests, teardownTests } from "./setupTeardown"; @@ -68,4 +70,30 @@ describe("queries", () => { tc.assert>(true); }); + + test("deep array input", async () => { + const result = await deepArrayInput(client, { + deep: [ + ['name', 'Stark'], + ['color', 'red'], + ] as const, + }); + + type result = typeof result; + tc.assert< + tc.IsExact< + result, + Array<[string, string]> + > + >(true); + + tc.assert< + tc.IsExact< + DeepArrayInputArgs, + { + deep: ReadonlyArray; + } + > + >(true); + }); }); diff --git a/packages/driver/src/baseClient.ts b/packages/driver/src/baseClient.ts index 7ff4fcf1f..3ef0d2394 100644 --- a/packages/driver/src/baseClient.ts +++ b/packages/driver/src/baseClient.ts @@ -38,6 +38,7 @@ import Event from "./primitives/event"; import { LifoQueue } from "./primitives/queues"; import { BaseRawConnection } from "./baseConn"; import { ConnectWithTimeout, retryingConnect } from "./retry"; +import { util } from "./reflection/util"; import { Transaction } from "./transaction"; import { sleep } from "./utils"; @@ -660,4 +661,26 @@ export class Client implements Executor { await holder.release(); } } + + async parse(query: string) { + const holder = await this.pool.acquireHolder(this.options); + try { + const cxn = await holder._getConnection(); + const result = await cxn._parse( + query, + OutputFormat.BINARY, + Cardinality.MANY, + this.options.session + ); + const cardinality = util.parseCardinality(result[0]); + + return { + in: result[1], + out: result[2], + cardinality, + }; + } finally { + await holder.release(); + } + } } diff --git a/packages/driver/src/codecs/consts.ts b/packages/driver/src/codecs/consts.ts index 45fa7d9cd..577f4435f 100644 --- a/packages/driver/src/codecs/consts.ts +++ b/packages/driver/src/codecs/consts.ts @@ -57,9 +57,3 @@ export const KNOWN_TYPENAMES = (() => { } return res; })(); - -export const NO_RESULT = 0x6e; -export const AT_MOST_ONE = 0x6f; -export const ONE = 0x41; -export const MANY = 0x6d; -export const AT_LEAST_ONE = 0x4d; diff --git a/packages/driver/src/codecs/object.ts b/packages/driver/src/codecs/object.ts index 82e24a686..3ae671fbc 100644 --- a/packages/driver/src/codecs/object.ts +++ b/packages/driver/src/codecs/object.ts @@ -16,9 +16,9 @@ * limitations under the License. */ +import { Cardinality } from "../ifaces"; import { ICodec, Codec, uuid, CodecKind } from "./ifaces"; import { ReadBuffer, WriteBuffer } from "../primitives/buffer"; -import { ONE, AT_LEAST_ONE } from "./consts"; import { InvalidArgumentError, MissingArgumentError, @@ -34,7 +34,7 @@ export interface ObjectFieldInfo { name: string; implicit: boolean; linkprop: boolean; - cardinality: number; + cardinality: Cardinality; } export class ObjectCodec extends Codec implements ICodec { @@ -104,7 +104,7 @@ export class ObjectCodec extends Codec implements ICodec { const arg = args[i]; if (arg == null) { const card = this.cardinalities[i]; - if (card === ONE || card === AT_LEAST_ONE) { + if (card === Cardinality.ONE || card === Cardinality.AT_LEAST_ONE) { throw new MissingArgumentError( `argument ${this.fields[i].name} is required, but received ${arg}` ); @@ -154,7 +154,7 @@ export class ObjectCodec extends Codec implements ICodec { elemData.writeInt32(0); // reserved bytes if (val == null) { const card = this.cardinalities[i]; - if (card === ONE || card === AT_LEAST_ONE) { + if (card === Cardinality.ONE || card === Cardinality.AT_LEAST_ONE) { throw new MissingArgumentError( `argument ${this.fields[i].name} is required, but received ${val}` ); diff --git a/packages/driver/src/reflection/analyzeQuery.ts b/packages/driver/src/reflection/analyzeQuery.ts index 1c40d155f..73a8f012d 100644 --- a/packages/driver/src/reflection/analyzeQuery.ts +++ b/packages/driver/src/reflection/analyzeQuery.ts @@ -1,5 +1,4 @@ import { ArrayCodec } from "../codecs/array"; -import { AT_LEAST_ONE, AT_MOST_ONE, MANY, ONE } from "../codecs/consts"; import { EnumCodec } from "../codecs/enum"; import type { ICodec } from "../codecs/ifaces"; import { ScalarCodec } from "../codecs/ifaces"; @@ -9,9 +8,9 @@ import { MultiRangeCodec, RangeCodec } from "../codecs/range"; import { NullCodec } from "../codecs/codecs"; import { SetCodec } from "../codecs/set"; import { TupleCodec } from "../codecs/tuple"; -import { Cardinality, OutputFormat } from "../ifaces"; -import { Options, Session } from "../options"; -import type { Client, BaseClientPool } from "../baseClient"; +import type { Client } from "../baseClient"; +import { Cardinality } from "./enums"; +import { util } from "./util"; type QueryType = { args: string; @@ -25,147 +24,208 @@ export async function analyzeQuery( client: Client, query: string ): Promise { - const [cardinality, inCodec, outCodec] = await parseQuery(client, query); + const { cardinality, in: inCodec, out: outCodec } = await client.parse(query); - const imports = new Set(); - const args = walkCodec(inCodec, { - indent: "", + const args = generateTSTypeFromCodec(inCodec, Cardinality.One, { optionalNulls: true, readonly: true, - imports, }); - - const result = applyCardinalityToTsType( - walkCodec(outCodec, { - indent: "", - optionalNulls: false, - readonly: false, - imports, - }), - cardinality - ); + const result = generateTSTypeFromCodec(outCodec, cardinality); return { - result, - args, + result: result.type, + args: args.type, cardinality, query, - imports, + imports: new Set([...args.imports, ...result.imports]), }; } -export async function parseQuery(client: Client, query: string) { - const pool: BaseClientPool = (client as any).pool; - - const holder = await pool.acquireHolder(Options.defaults()); - try { - const cxn = await holder._getConnection(); - return await cxn._parse( - query, - OutputFormat.BINARY, - Cardinality.MANY, - Session.defaults() - ); - } finally { - await holder.release(); - } -} +type AbstractClass = (abstract new (...arguments_: any[]) => T) & { + prototype: T; +}; -export function applyCardinalityToTsType( - type: string, - cardinality: Cardinality -): string { - switch (cardinality) { - case Cardinality.MANY: - return `${type}[]`; - case Cardinality.ONE: - return type; - case Cardinality.AT_MOST_ONE: - return `${type} | null`; - case Cardinality.AT_LEAST_ONE: - return `[(${type}), ...(${type})[]]`; - } - throw Error(`unexpected cardinality: ${cardinality}`); -} +type CodecLike = ICodec | ScalarCodec; -// type AtLeastOne = [T, ...T[]]; +export type CodecGenerator = ( + codec: Codec, + context: CodecGeneratorContext +) => string; -export { walkCodec as walkCodecToTsType }; -function walkCodec( +type CodecGeneratorMap = ReadonlyMap, CodecGenerator>; + +export type CodecGeneratorContext = { + indent: string; + optionalNulls: boolean; + readonly: boolean; + imports: Set; + walk: (codec: CodecLike, context?: CodecGeneratorContext) => string; + generators: CodecGeneratorMap; + applyCardinality: (type: string, cardinality: Cardinality) => string; +}; + +export type CodecGenerationOptions = Partial< + Pick< + CodecGeneratorContext, + "optionalNulls" | "readonly" | "generators" | "applyCardinality" + > +>; + +export const generateTSTypeFromCodec = ( codec: ICodec, - ctx: { - indent: string; - optionalNulls: boolean; - readonly: boolean; - imports: Set; - } -): string { - if (codec instanceof NullCodec) { - return "null"; - } - if (codec instanceof ScalarCodec) { - if (codec instanceof EnumCodec) { - return `(${codec.values.map((val) => JSON.stringify(val)).join(" | ")})`; - } + cardinality: Cardinality = Cardinality.One, + options: CodecGenerationOptions = {} +) => { + const optionsWithDefaults = { + indent: "", + optionalNulls: false, + readonly: false, + ...options, + }; + const context: CodecGeneratorContext = { + ...optionsWithDefaults, + generators: defaultCodecGenerators, + applyCardinality: defaultApplyCardinalityToTsType(optionsWithDefaults), + ...options, + imports: new Set(), + walk: (codec, innerContext) => { + innerContext ??= context; + for (const [type, generator] of innerContext.generators) { + if (codec instanceof type) { + return generator(codec, innerContext); + } + } + throw new Error(`Unexpected codec kind: ${codec.getKind()}`); + }, + }; + const type = context.applyCardinality( + context.walk(codec, context), + cardinality + ); + return { + type, + imports: context.imports, + }; +}; + +/** A helper function to define a codec generator tuple. */ +const genDef = ( + codecType: AbstractClass, + generator: CodecGenerator +) => + [codecType as AbstractClass, generator as CodecGenerator] as const; +export { genDef as defineCodecGeneratorTuple }; + +export const defaultCodecGenerators: CodecGeneratorMap = new Map([ + genDef(NullCodec, () => "null"), + genDef(EnumCodec, (codec) => { + return `(${codec.values.map((val) => JSON.stringify(val)).join(" | ")})`; + }), + genDef(ScalarCodec, (codec, ctx) => { if (codec.importedType) { ctx.imports.add(codec.tsType); } return codec.tsType; - } - if (codec instanceof ObjectCodec || codec instanceof NamedTupleCodec) { - const fields = - codec instanceof ObjectCodec - ? codec.getFields() - : codec.getNames().map((name) => ({ name, cardinality: ONE })); + }), + genDef(ObjectCodec, (codec, ctx) => { const subCodecs = codec.getSubcodecs(); - const objectShape = `{\n${fields - .map((field, i) => { - let subCodec = subCodecs[i]; - if (subCodec instanceof SetCodec) { - if ( - !(field.cardinality === MANY || field.cardinality === AT_LEAST_ONE) - ) { - throw Error("subcodec is SetCodec, but upper cardinality is one"); - } - subCodec = subCodec.getSubcodecs()[0]; - } - return `${ctx.indent} ${JSON.stringify(field.name)}${ - ctx.optionalNulls && field.cardinality === AT_MOST_ONE ? "?" : "" - }: ${applyCardinalityToTsType( - walkCodec(subCodec, { ...ctx, indent: ctx.indent + " " }), - field.cardinality - )};`; - }) - .join("\n")}\n${ctx.indent}}`; - return ctx.readonly ? `Readonly<${objectShape}>` : objectShape; - } - if (codec instanceof ArrayCodec) { - return `${ctx.readonly ? "readonly " : ""}${walkCodec( - codec.getSubcodecs()[0], - ctx - )}[]`; - } - if (codec instanceof TupleCodec) { - return `${ctx.readonly ? "readonly " : ""}[${codec + const fields = codec.getFields().map((field, i) => ({ + name: field.name, + codec: subCodecs[i], + cardinality: util.parseCardinality(field.cardinality), + })); + return generateTsObject(fields, ctx); + }), + genDef(NamedTupleCodec, (codec, ctx) => { + const subCodecs = codec.getSubcodecs(); + const fields = codec.getNames().map((name, i) => ({ + name, + codec: subCodecs[i], + cardinality: Cardinality.One, + })); + return generateTsObject(fields, ctx); + }), + genDef(TupleCodec, (codec, ctx) => { + const subCodecs = codec .getSubcodecs() - .map((subCodec) => walkCodec(subCodec, ctx)) - .join(", ")}]`; - } - if (codec instanceof RangeCodec) { + .map((subCodec) => ctx.walk(subCodec)); + const tuple = `[${subCodecs.join(", ")}]`; + return ctx.readonly ? `(readonly ${tuple})` : tuple; + }), + genDef(ArrayCodec, (codec, ctx) => + ctx.applyCardinality(ctx.walk(codec.getSubcodecs()[0]), Cardinality.Many) + ), + genDef(RangeCodec, (codec, ctx) => { const subCodec = codec.getSubcodecs()[0]; if (!(subCodec instanceof ScalarCodec)) { throw Error("expected range subtype to be scalar type"); } ctx.imports.add("Range"); - return `Range<${walkCodec(subCodec, ctx)}>`; - } - if (codec instanceof MultiRangeCodec) { + return `Range<${ctx.walk(subCodec)}>`; + }), + genDef(MultiRangeCodec, (codec, ctx) => { const subCodec = codec.getSubcodecs()[0]; if (!(subCodec instanceof ScalarCodec)) { throw Error("expected multirange subtype to be scalar type"); } ctx.imports.add("MultiRange"); - return `MultiRange<${walkCodec(subCodec, ctx)}>`; + return `MultiRange<${ctx.walk(subCodec)}>`; + }), +]); + +export const generateTsObject = ( + fields: Array[0]>, + ctx: CodecGeneratorContext +) => { + const properties = fields.map((field) => generateTsObjectField(field, ctx)); + return `{\n${properties.join("\n")}\n${ctx.indent}}`; +}; + +export const generateTsObjectField = ( + field: { name: string; cardinality: Cardinality; codec: ICodec }, + ctx: CodecGeneratorContext +) => { + const codec = unwrapSetCodec(field.codec, field.cardinality); + + const name = JSON.stringify(field.name); + const value = ctx.applyCardinality( + ctx.walk(codec, { ...ctx, indent: ctx.indent + " " }), + field.cardinality + ); + const optional = + ctx.optionalNulls && field.cardinality === Cardinality.AtMostOne; + const questionMark = optional ? "?" : ""; + const isReadonly = ctx.readonly ? "readonly " : ""; + return `${ctx.indent} ${isReadonly}${name}${questionMark}: ${value};`; +}; + +function unwrapSetCodec(codec: ICodec, cardinality: Cardinality) { + if (!(codec instanceof SetCodec)) { + return codec; + } + if ( + cardinality === Cardinality.Many || + cardinality === Cardinality.AtLeastOne + ) { + return codec.getSubcodecs()[0]; } - throw Error(`Unexpected codec kind: ${codec.getKind()}`); + throw new Error("Sub-codec is SetCodec, but upper cardinality is one"); } + +export const defaultApplyCardinalityToTsType = + (ctx: Pick) => + (type: string, cardinality: Cardinality): string => { + switch (cardinality) { + case Cardinality.Many: + return `${ctx.readonly ? "Readonly" : ""}Array<${type}>`; + case Cardinality.One: + return type; + case Cardinality.AtMostOne: + return `${type} | null`; + case Cardinality.AtLeastOne: { + const tuple = `[(${type}), ...(${type})[]]`; + return ctx.readonly ? `(readonly ${tuple})` : tuple; + } + } + throw new Error(`Unexpected cardinality: ${cardinality}`); + }; diff --git a/packages/driver/src/reflection/util.ts b/packages/driver/src/reflection/util.ts index a1eaf64ab..5be548f30 100644 --- a/packages/driver/src/reflection/util.ts +++ b/packages/driver/src/reflection/util.ts @@ -1,3 +1,6 @@ +import { Cardinality as RawCardinality } from "../ifaces"; +import { Cardinality } from "./enums"; + export namespace util { export function assertNever(arg: never, error?: Error): never { throw error ?? new Error(`${arg} is supposed to be of "never" type`); @@ -88,4 +91,22 @@ export namespace util { } return obj; } + + export const parseCardinality = ( + cardinality: RawCardinality + ): Cardinality => { + switch (cardinality) { + case RawCardinality.MANY: + return Cardinality.Many; + case RawCardinality.ONE: + return Cardinality.One; + case RawCardinality.AT_MOST_ONE: + return Cardinality.AtMostOne; + case RawCardinality.AT_LEAST_ONE: + return Cardinality.AtLeastOne; + case RawCardinality.NO_RESULT: + return Cardinality.Empty; + } + throw new Error(`Unexpected cardinality: ${cardinality}`); + }; } diff --git a/packages/generate/src/queries.ts b/packages/generate/src/queries.ts index 928104e9b..a5a946982 100644 --- a/packages/generate/src/queries.ts +++ b/packages/generate/src/queries.ts @@ -1,5 +1,4 @@ import { $, adapter, type Client } from "edgedb"; -import { Cardinality } from "edgedb/dist/ifaces"; import { type CommandOptions } from "./commandutil"; import { headerComment } from "./genutil"; import { type Target, camelify } from "./genutil"; @@ -193,9 +192,9 @@ export function generateFiles(params: { ); const method = - params.types.cardinality === Cardinality.ONE + params.types.cardinality === $.Cardinality.One ? "queryRequiredSingle" - : params.types.cardinality === Cardinality.AT_MOST_ONE + : params.types.cardinality === $.Cardinality.AtMostOne ? "querySingle" : "query"; const functionName = camelify(baseFileName);