diff --git a/.changeset/moody-parrots-sing.md b/.changeset/moody-parrots-sing.md new file mode 100644 index 000000000..9afa16b5c --- /dev/null +++ b/.changeset/moody-parrots-sing.md @@ -0,0 +1,5 @@ +--- +"@osdk/client": patch +--- + +Adds version compatibility checks to Queries diff --git a/packages/client.api/src/queries/Queries.ts b/packages/client.api/src/queries/Queries.ts index 66e630b5a..2b193a923 100644 --- a/packages/client.api/src/queries/Queries.ts +++ b/packages/client.api/src/queries/Queries.ts @@ -45,7 +45,7 @@ export type QuerySignatureFromDef> = export type QueryParameterType< T extends Record>, -> = NOOP, OptionalQueryParams>>; +> = PartialByNotStrict, OptionalQueryParams>; export type QueryReturnType> = T extends ObjectQueryDataType ? OsdkBase diff --git a/packages/client/package.json b/packages/client/package.json index d02f602d4..4f6be4db7 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -45,6 +45,7 @@ "conjure-lite": "^0.4.4", "fast-deep-equal": "^3.1.3", "fetch-retry": "^6.0.0", + "find-up": "7.0.0", "isomorphic-ws": "^5.0.0", "tiny-invariant": "^1.3.1", "ws": "^8.18.0" @@ -68,9 +69,13 @@ "@osdk/shared.test": "workspace:~", "@types/geojson": "^7946.0.14", "@types/ws": "^8.5.10", + "execa": "^9.3.0", "jest-extended": "^4.0.2", "msw": "^2.3.0", "p-defer": "^4.0.1", + "p-event": "^6.0.1", + "p-locate": "^6.0.0", + "p-map": "^7.0.2", "p-state": "^2.0.1", "pino": "^9.1.0", "pino-pretty": "^11.2.1", diff --git a/packages/client/src/intellisense.test.helpers/callsQueryAcceptsObject.ts b/packages/client/src/intellisense.test.helpers/callsQueryAcceptsObject.ts new file mode 100644 index 000000000..81251e0be --- /dev/null +++ b/packages/client/src/intellisense.test.helpers/callsQueryAcceptsObject.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// WARNING! +// WARNING! +// This file is used for tests that check intellisense. Editing this file by hand will likely +// break tests that have hard coded line numbers and line offsets. + +import { queryAcceptsObject } from "@osdk/client.test.ontology"; +import type { Client } from "../Client.js"; + +const client: Client = {} as any; +client(queryAcceptsObject)({ + object: undefined as any, +}); diff --git a/packages/client/src/intellisense.test.ts b/packages/client/src/intellisense.test.ts new file mode 100644 index 000000000..12f1b5072 --- /dev/null +++ b/packages/client/src/intellisense.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { findUpSync } from "find-up"; +import * as path from "node:path"; +import type { Logger } from "pino"; +import invariant from "tiny-invariant"; +import * as ts from "typescript"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { TsServer } from "./tsserver.js"; +import { startTsServer } from "./tsserver.js"; + +// it needs to be hoisted because its referenced from our mocked WebSocket +// which must be hoisted to work +const rootLogger = await vi.hoisted(async (): Promise => { + const pino = (await import("pino")).pino; + const pinoPretty = await import("pino-pretty"); + const { EventEmitter } = await import("node:events"); + class PinoConsoleLogDestination extends EventEmitter { + write(a: string) { + // remove trailing newline since console.log adds one + if (a.at(-1) === "\n") a = a.slice(0, -1); + + // This lets the test framework aggregate the logs per test, whereas direct to stdout does not + console.log(a); + } + } + return pino( + { level: "info" }, + (pinoPretty.build)({ + sync: true, + timestampKey: undefined, + errorLikeObjectKeys: ["error", "err", "exception"], + errorProps: "stack,cause,properties", + ignore: "time,hostname,pid", + destination: new PinoConsoleLogDestination(), + }), + ); +}); + +describe("intellisense", () => { + let packagesDir: string; + let clientPackagePath: string; + + beforeAll(() => { + const clientsPackageJson = findUpSync("package.json", { + cwd: import.meta.url, + }); + invariant(clientsPackageJson != null); + packagesDir = path.join( + path.dirname(clientsPackageJson), + "..", + ); + + clientPackagePath = path.join(packagesDir, "client"); + }); + + let tsServer: TsServer; + let intellisenseFilePath: string; + + beforeEach(async (a) => { + intellisenseFilePath = path.join( + clientPackagePath, + "src", + "intellisense.test.helpers", + `${a.task.name}.ts`, + ); + + expect(ts.sys.fileExists(intellisenseFilePath)).toBeTruthy(); + + tsServer = await startTsServer(rootLogger); + await tsServer.sendOpenRequest({ file: intellisenseFilePath }); + }); + + afterEach(async () => { + tsServer.stop(); + tsServer = undefined as any; + }); + + it("callsQueryAcceptsObject", { timeout: 10_000 }, async () => { + const { resp } = await tsServer.sendQuickInfoRequest({ + file: intellisenseFilePath, + line: 27, + offset: 6, + }); + expect(resp.body?.documentation).toMatchInlineSnapshot( + `"(no ontology metadata)"`, + ); + }); +}); diff --git a/packages/client/src/tsserver.ts b/packages/client/src/tsserver.ts new file mode 100644 index 000000000..4e92cca62 --- /dev/null +++ b/packages/client/src/tsserver.ts @@ -0,0 +1,200 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Subprocess } from "execa"; +import { execaNode } from "execa"; +import { findUpMultiple } from "find-up"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import pLocate from "p-locate"; +import pMap from "p-map"; +import type { Logger } from "pino"; +import invariant from "tiny-invariant"; +import { server as s } from "typescript"; + +class TsServerImpl extends EventEmitter<{ + exit: []; +}> { + #tsServerPath: string; + #nextSeq = 1; + #subprocess: Subprocess<{ ipc: true; serialization: "json" }> | undefined; + #logger: Logger; + + constructor(tsServerPath: string, logger: Logger) { + super(); + this.#tsServerPath = tsServerPath; + this.#logger = logger; + } + + get subprocess() { + return this.#subprocess; + } + + async start() { + this.#subprocess = execaNode({ + ipc: true, + serialization: "json", + })`${this.#tsServerPath} --useNodeIpc`; + + if (this.#logger.isLevelEnabled("trace")) { + this.#subprocess.on("message", (req) => { + this.#logger.trace({ req }, "message received"); + }); + } + + this.#subprocess.on("exit", () => { + this.#logger.info("tsserver exited"); + this.emit("exit"); + }); + return this; + } + + stop() { + if (this.#subprocess?.connected) { + this.#subprocess?.disconnect(); + } + } + + async getOneMessage(filter?: (m: unknown) => m is X): Promise { + return await this.subprocess!.getOneMessage({ filter }) as X; + } + + #requestFactory = + ( + command: T["command"], + isResponse?: (m: unknown) => m is X, + ) => + async (args: T["arguments"]): Promise<{ req: T; resp: X }> => { + return await this.#makeRequest(command, args, isResponse); + }; + + sendOpenRequest = this.#requestFactory( + s.protocol.CommandTypes.Open, + ); + + sendQuickInfoRequest = this.#requestFactory< + s.protocol.QuickInfoRequest, + s.protocol.QuickInfoResponse + >( + s.protocol.CommandTypes.Quickinfo, + isQuickInfoResponse, + ); + + async #makeRequest< + T extends s.protocol.Request, + X extends s.protocol.Response = never, + >( + command: T["command"], + args: T["arguments"], + isResponse?: (m: unknown) => m is X, + ): Promise<{ req: T; resp: X }> { + const seq = this.#nextSeq++; + const req: T = { + type: "request", + command, + arguments: args, + seq, + } as T; + this.#logger.trace({ req }, "requesting"); + + await this.#subprocess?.sendMessage(req as any); + + if (isResponse) { + return { + req, + resp: await this.#subprocess?.getOneMessage({ + filter: isResponse, + }) as unknown as X, + }; + } + return { req, resp: undefined as unknown as X }; + } +} + +export type TsServer = Omit< + TsServerImpl, + Exclude< + keyof EventEmitter, + | "on" + | "addListener" + | "off" + | "once" + | "removeListener" + | "removeAllListeners" + > +>; + +export async function startTsServer(logger: Logger): Promise { + const tsServerPath = await getTsServerPath(); + invariant(tsServerPath != null); + + return new TsServerImpl(tsServerPath, logger).start(); +} + +async function getTsServerPath() { + const nodeModuleDirs = await findUpMultiple("node_modules", { + cwd: import.meta.url, + type: "directory", + }); + const possibleTsServerPaths = await pMap( + nodeModuleDirs, + (dir) => path.join(dir, "typescript", "lib", "tsserver.js"), + ); + + const tsServerPath = await pLocate( + ["no", ...possibleTsServerPaths], + async (dir) => { + try { + const c = await fs.stat( + dir, + ); + return c.isFile(); + } catch (e) { + return false; + } + }, + ); + return tsServerPath; +} + +export function isEvent(m: unknown): m is s.protocol.Event { + return !!(m && typeof m === "object" && "type" in m + && m.type === "event"); +} + +export function isResponse(m: unknown): m is s.protocol.Response { + return !!(m && typeof m === "object" && "type" in m + && m.type === "response"); +} + +export function isProjectLoadingStart( + m: unknown, +): m is s.protocol.ProjectLoadingStartEvent { + return isEvent(m) && m.event === "projectLoadingStart"; +} +export function isProjectLoadingEnd( + m: unknown, +): m is s.protocol.ProjectLoadingStartEvent { + return isEvent(m) && m.event === "projectLoadingFinish"; +} +export function isQuickInfoResponse( + m: unknown, + requestSeq?: number, +): m is s.protocol.QuickInfoResponse { + return isResponse(m) && m.command === s.protocol.CommandTypes.Quickinfo + && (requestSeq == null || m.request_seq === requestSeq); +} diff --git a/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/getTodoCount.ts b/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/getTodoCount.ts index 0660f6be9..9c097aef5 100644 --- a/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/getTodoCount.ts +++ b/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/getTodoCount.ts @@ -1,9 +1,26 @@ +import type { VersionBound } from '@osdk/api'; import { QueryDefinition } from '@osdk/api'; -export const getTodoCount = { +import type { $ExpectedClientVersion } from '../../OntologyMetadata.js'; + +export interface getTodoCount extends QueryDefinition<'getTodoCount', never>, VersionBound<$ExpectedClientVersion> { + apiName: 'getTodoCount'; + type: 'query'; + version: '0.1.2'; + parameters: {}; + output: { + nullable: false; + type: 'integer'; + }; +} + +export const getTodoCount: getTodoCount = { apiName: 'getTodoCount', type: 'query', version: '0.1.2', parameters: {}, - output: { nullable: false, type: 'integer' }, -} satisfies QueryDefinition<'getTodoCount', never>; + output: { + nullable: false, + type: 'integer', + }, +}; diff --git a/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/queryTakesAllParameterTypes.ts b/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/queryTakesAllParameterTypes.ts index b9f5e158f..037217c8a 100644 --- a/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/queryTakesAllParameterTypes.ts +++ b/packages/e2e.generated.catchall/src/generatedNoCheck/ontology/queries/queryTakesAllParameterTypes.ts @@ -1,48 +1,317 @@ +import type { VersionBound } from '@osdk/api'; import { QueryDefinition } from '@osdk/api'; + +import type { $ExpectedClientVersion } from '../../OntologyMetadata.js'; + import { Todo } from '../objects.js'; -export const queryTakesAllParameterTypes = { + +export interface queryTakesAllParameterTypes + extends QueryDefinition<'queryTakesAllParameterTypes', 'Todo'>, + VersionBound<$ExpectedClientVersion> { + apiName: 'queryTakesAllParameterTypes'; + description: 'description of the query that takes all parameter types'; + displayName: 'qTAPT'; + type: 'query'; + version: 'version'; + parameters: { + /** + * description: an array of strings + */ + array: { + description: 'an array of strings'; + multiplicity: true; + nullable: false; + type: 'string'; + }; + /** + * (no ontology metadata) + */ + attachment: { + nullable: false; + type: 'attachment'; + }; + /** + * (no ontology metadata) + */ + boolean: { + nullable: false; + type: 'boolean'; + }; + /** + * (no ontology metadata) + */ + date: { + nullable: false; + type: 'date'; + }; + /** + * description: a double parameter + */ + double: { + description: 'a double parameter'; + nullable: false; + type: 'double'; + }; + /** + * (no ontology metadata) + */ + float: { + nullable: false; + type: 'float'; + }; + /** + * (no ontology metadata) + */ + integer: { + nullable: false; + type: 'integer'; + }; + /** + * (no ontology metadata) + */ + long: { + nullable: false; + type: 'long'; + }; + /** + * (no ontology metadata) + */ + object: { + nullable: false; + object: 'Todo'; + type: 'object'; + __OsdkTargetType?: Todo; + }; + /** + * (no ontology metadata) + */ + objectSet: { + nullable: false; + objectSet: 'Todo'; + type: 'objectSet'; + __OsdkTargetType?: Todo; + }; + /** + * description: a set of strings + */ + set: { + description: 'a set of strings'; + nullable: false; + set: { + type: 'string'; + nullable: false; + }; + type: 'set'; + }; + /** + * (no ontology metadata) + */ + string: { + nullable: false; + type: 'string'; + }; + /** + * description: a struct with some fields + */ + struct: { + description: 'a struct with some fields'; + nullable: false; + struct: { + name: { + type: 'string'; + nullable: false; + }; + id: { + type: 'integer'; + nullable: false; + }; + }; + type: 'struct'; + }; + /** + * (no ontology metadata) + */ + threeDimensionalAggregation: { + nullable: false; + threeDimensionalAggregation: { + keyType: 'range'; + keySubtype: 'date'; + valueType: { + keyType: 'range'; + keySubtype: 'timestamp'; + valueType: 'date'; + }; + }; + type: 'threeDimensionalAggregation'; + }; + /** + * (no ontology metadata) + */ + timestamp: { + nullable: false; + type: 'timestamp'; + }; + /** + * (no ontology metadata) + */ + twoDimensionalAggregation: { + nullable: false; + twoDimensionalAggregation: { + keyType: 'string'; + valueType: 'double'; + }; + type: 'twoDimensionalAggregation'; + }; + /** + * description: a union of strings and integers + */ + unionNonNullable: { + description: 'a union of strings and integers'; + nullable: false; + type: 'union'; + union: [ + { + type: 'string'; + nullable: false; + }, + { + type: 'integer'; + nullable: false; + }, + ]; + }; + /** + * description: a union of strings and integers but its optional + */ + unionNullable: { + description: 'a union of strings and integers but its optional'; + nullable: true; + type: 'union'; + union: [ + { + type: 'string'; + nullable: false; + }, + { + type: 'integer'; + nullable: false; + }, + ]; + }; + }; + output: { + nullable: false; + type: 'string'; + }; +} + +export const queryTakesAllParameterTypes: queryTakesAllParameterTypes = { apiName: 'queryTakesAllParameterTypes', description: 'description of the query that takes all parameter types', displayName: 'qTAPT', type: 'query', version: 'version', parameters: { - double: { description: 'a double parameter', nullable: false, type: 'double' }, - float: { nullable: false, type: 'float' }, - integer: { nullable: false, type: 'integer' }, - long: { nullable: false, type: 'long' }, - attachment: { nullable: false, type: 'attachment' }, - boolean: { nullable: false, type: 'boolean' }, - date: { nullable: false, type: 'date' }, - string: { nullable: false, type: 'string' }, - timestamp: { nullable: false, type: 'timestamp' }, - object: { + array: { + description: 'an array of strings', + type: 'string', nullable: false, - object: 'Todo', + multiplicity: true, + }, + attachment: { + type: 'attachment', + nullable: false, + }, + boolean: { + type: 'boolean', + nullable: false, + }, + date: { + type: 'date', + nullable: false, + }, + double: { + description: 'a double parameter', + type: 'double', + nullable: false, + }, + float: { + type: 'float', + nullable: false, + }, + integer: { + type: 'integer', + nullable: false, + }, + long: { + type: 'long', + nullable: false, + }, + object: { type: 'object', - - __OsdkTargetType: Todo, + object: 'Todo', + nullable: false, }, objectSet: { - nullable: false, - objectSet: 'Todo', type: 'objectSet', - - __OsdkTargetType: Todo, + objectSet: 'Todo', + nullable: false, }, - array: { description: 'an array of strings', multiplicity: true, nullable: false, type: 'string' }, set: { description: 'a set of strings', - nullable: false, + type: 'set', set: { type: 'string', nullable: false, }, - type: 'set', + nullable: false, + }, + string: { + type: 'string', + nullable: false, + }, + struct: { + description: 'a struct with some fields', + type: 'struct', + struct: { + name: { + type: 'string', + nullable: false, + }, + id: { + type: 'integer', + nullable: false, + }, + }, + nullable: false, + }, + threeDimensionalAggregation: { + type: 'threeDimensionalAggregation', + threeDimensionalAggregation: { + keyType: 'range', + keySubtype: 'date', + valueType: { + keyType: 'range', + keySubtype: 'timestamp', + valueType: 'date', + }, + }, + nullable: false, + }, + timestamp: { + type: 'timestamp', + nullable: false, + }, + twoDimensionalAggregation: { + type: 'twoDimensionalAggregation', + twoDimensionalAggregation: { + keyType: 'string', + valueType: 'double', + }, + nullable: false, }, unionNonNullable: { description: 'a union of strings and integers', - nullable: false, type: 'union', union: [ { @@ -54,10 +323,10 @@ export const queryTakesAllParameterTypes = { nullable: false, }, ], + nullable: false, }, unionNullable: { description: 'a union of strings and integers but its optional', - nullable: true, type: 'union', union: [ { @@ -69,43 +338,11 @@ export const queryTakesAllParameterTypes = { nullable: false, }, ], + nullable: true, }, - struct: { - description: 'a struct with some fields', - nullable: false, - struct: { - name: { - type: 'string', - nullable: false, - }, - id: { - type: 'integer', - nullable: false, - }, - }, - type: 'struct', - }, - twoDimensionalAggregation: { - nullable: false, - twoDimensionalAggregation: { - keyType: 'string', - valueType: 'double', - }, - type: 'twoDimensionalAggregation', - }, - threeDimensionalAggregation: { - nullable: false, - threeDimensionalAggregation: { - keyType: 'range', - keySubtype: 'date', - valueType: { - keyType: 'range', - keySubtype: 'timestamp', - valueType: 'date', - }, - }, - type: 'threeDimensionalAggregation', - }, }, - output: { nullable: false, type: 'string' }, -} satisfies QueryDefinition<'queryTakesAllParameterTypes', 'Todo'>; + output: { + nullable: false, + type: 'string', + }, +}; diff --git a/packages/generator/src/shared/getObjectTypeApiNamesFromQuery.ts b/packages/generator/src/shared/getObjectTypeApiNamesFromQuery.ts new file mode 100644 index 000000000..42baaf6fe --- /dev/null +++ b/packages/generator/src/shared/getObjectTypeApiNamesFromQuery.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { QueryTypeV2 } from "@osdk/gateway/types"; +import { getObjectTypesFromQueryDataType } from "./getObjectTypesFromQueryDataType.js"; + +export function getObjectTypeApiNamesFromQuery(query: QueryTypeV2) { + const types = new Set(); + + for (const { dataType } of Object.values(query.parameters)) { + getObjectTypesFromQueryDataType(dataType, types); + } + getObjectTypesFromQueryDataType(query.output, types); + + return Array.from(types); +} diff --git a/packages/generator/src/shared/getObjectTypesFromQueryDataType.ts b/packages/generator/src/shared/getObjectTypesFromQueryDataType.ts new file mode 100644 index 000000000..a2a7fa035 --- /dev/null +++ b/packages/generator/src/shared/getObjectTypesFromQueryDataType.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { QueryDataType } from "@osdk/gateway/types"; + +export function getObjectTypesFromQueryDataType( + dataType: QueryDataType, + types: Set, +) { + switch (dataType.type) { + case "array": + case "set": + getObjectTypesFromQueryDataType(dataType.subType, types); + return; + + case "object": + types.add(dataType.objectTypeApiName); + return; + + case "objectSet": + types.add(dataType.objectTypeApiName!); + return; + + case "struct": + for (const prop of Object.values(dataType.fields)) { + getObjectTypesFromQueryDataType(prop.fieldType, types); + } + return; + + case "union": + for (const type of dataType.unionTypes) { + getObjectTypesFromQueryDataType(type, types); + } + return; + + case "attachment": + case "boolean": + case "date": + case "double": + case "float": + case "integer": + case "long": + case "null": + case "string": + case "threeDimensionalAggregation": + case "timestamp": + case "twoDimensionalAggregation": + case "unsupported": + /* complete no-op */ + return; + + default: + const _: never = dataType; + throw new Error( + `Cannot find object types from unsupported QueryDataType ${ + (dataType as any).type + }`, + ); + } +} diff --git a/packages/generator/src/util/test/TodoWireOntology.ts b/packages/generator/src/util/test/TodoWireOntology.ts index 5dac3906f..01c168481 100644 --- a/packages/generator/src/util/test/TodoWireOntology.ts +++ b/packages/generator/src/util/test/TodoWireOntology.ts @@ -156,6 +156,26 @@ export const TodoWireOntology = { rid: "rid.query.1", version: "0", }, + "returnsTodo": { + apiName: "returnsTodo", + output: { + type: "object", + objectApiName: "Todo", + objectTypeApiName: "Todo", + }, + parameters: { + someTodo: { + description: "Random desc so we test jsdoc", + dataType: { + type: "object", + objectApiName: "Todo", + objectTypeApiName: "Todo", + }, + }, + }, + rid: "rid.query.2", + version: "0", + }, }, interfaceTypes: {}, sharedPropertyTypes: {}, diff --git a/packages/generator/src/v1.1/generateMetadataFile.test.ts b/packages/generator/src/v1.1/generateMetadataFile.test.ts index c883c8dfa..21c1f1b0c 100644 --- a/packages/generator/src/v1.1/generateMetadataFile.test.ts +++ b/packages/generator/src/v1.1/generateMetadataFile.test.ts @@ -48,6 +48,7 @@ describe(generateMetadataFile, () => { import { Todo } from './ontology/objects/Todo'; import { getCount } from './ontology/queries/getCount'; import type { Queries } from './ontology/queries/Queries'; + import { returnsTodo } from './ontology/queries/returnsTodo'; export const Ontology: { metadata: { @@ -65,6 +66,7 @@ describe(generateMetadataFile, () => { }; queries: { getCount: typeof getCount; + returnsTodo: typeof returnsTodo; }; } = { metadata: { @@ -82,8 +84,9 @@ describe(generateMetadataFile, () => { }, queries: { getCount, + returnsTodo, }, - } satisfies OntologyDefinition<'Todo' | 'Person', 'markTodoCompleted' | 'deleteTodos', 'getCount'>; + } satisfies OntologyDefinition<'Todo' | 'Person', 'markTodoCompleted' | 'deleteTodos', 'getCount' | 'returnsTodo'>; export interface Ontology extends ClientOntology { objects: Objects; diff --git a/packages/generator/src/v1.1/generatePerQueryDataFiles.ts b/packages/generator/src/v1.1/generatePerQueryDataFiles.ts index b94185458..9a77b0db9 100644 --- a/packages/generator/src/v1.1/generatePerQueryDataFiles.ts +++ b/packages/generator/src/v1.1/generatePerQueryDataFiles.ts @@ -14,18 +14,13 @@ * limitations under the License. */ -import type { QueryDataType, QueryTypeV2 } from "@osdk/gateway/types"; +import type { QueryTypeV2 } from "@osdk/gateway/types"; import path from "node:path"; import type { MinimalFs } from "../MinimalFs.js"; -import { getObjectDefIdentifier } from "../shared/wireObjectTypeV2ToSdkObjectConst.js"; -import { wireQueryDataTypeToQueryDataTypeDefinition } from "../shared/wireQueryDataTypeToQueryDataTypeDefinition.js"; +import { getObjectTypeApiNamesFromQuery } from "../shared/getObjectTypeApiNamesFromQuery.js"; import { - wireQueryParameterV2ToQueryParameterDefinition, wireQueryTypeV2ToSdkQueryDefinition, - wireQueryTypeV2ToSdkQueryDefinitionNoParams, } from "../shared/wireQueryTypeV2ToSdkQueryDefinition.js"; -import { deleteUndefineds } from "../util/deleteUndefineds.js"; -import { stringify } from "../util/stringify.js"; import { formatTs } from "../util/test/formatTs.js"; import type { WireOntologyDefinition } from "../WireOntologyDefinition.js"; @@ -39,71 +34,7 @@ export async function generatePerQueryDataFiles( await fs.mkdir(outDir, { recursive: true }); await Promise.all( Object.values(ontology.queryTypes).map(async query => { - const objectTypes = getObjectTypesFromQuery(query); - const importObjects = objectTypes.length > 0 - ? `import {${ - [...objectTypes].join(",") - }} from "../objects${importExt}";` - : ""; - if (v2) { - await fs.writeFile( - path.join(outDir, `${query.apiName}.ts`), - await formatTs(` - import { QueryDefinition } from "@osdk/api"; - ${importObjects} - export const ${query.apiName} = { - ${ - stringify( - deleteUndefineds( - wireQueryTypeV2ToSdkQueryDefinitionNoParams(query), - ), - ) - }, - parameters: {${ - Object.entries(query.parameters).map(( - [name, parameter], - ) => { - return `${name} : {${ - stringify(deleteUndefineds( - wireQueryParameterV2ToQueryParameterDefinition(parameter), - )) - }, - ${ - parameter.dataType.type === "object" - || parameter.dataType.type === "objectSet" - ? getOsdkTargetTypeIfPresent( - parameter.dataType.objectTypeApiName!, - v2, - ) - : `` - }}`; - }) - }}, - output: {${ - stringify( - deleteUndefineds( - wireQueryDataTypeToQueryDataTypeDefinition(query.output), - ), - ) - }, - ${ - query.output.type === "object" || query.output.type === "objectSet" - ? getOsdkTargetTypeIfPresent(query.output.objectTypeApiName!, v2) - : `` - }} - } ${getQueryDefSatisfies(query.apiName, objectTypes)}`), - ); - } else { - await fs.writeFile( - path.join(outDir, `${query.apiName}.ts`), - await formatTs(` - import { QueryDefinition } from "@osdk/api"; - - export const ${query.apiName} = ${ - JSON.stringify(wireQueryTypeV2ToSdkQueryDefinition(query)) - } ${getQueryDefSatisfies(query.apiName, objectTypes)}`), - ); - } + await generateV1QueryFile(fs, outDir, query); }), ); @@ -121,71 +52,21 @@ export async function generatePerQueryDataFiles( ); } -function getObjectTypesFromQuery(query: QueryTypeV2) { - const types = new Set(); - - for (const { dataType } of Object.values(query.parameters)) { - getObjectTypesFromDataType(dataType, types); - } - getObjectTypesFromDataType(query.output, types); - - return Array.from(types); -} - -function getObjectTypesFromDataType( - dataType: QueryDataType, - types: Set, +async function generateV1QueryFile( + fs: MinimalFs, + outDir: string, + query: QueryTypeV2, ) { - switch (dataType.type) { - case "array": - case "set": - getObjectTypesFromDataType(dataType.subType, types); - return; - - case "object": - types.add(dataType.objectTypeApiName); - return; - - case "objectSet": - types.add(dataType.objectTypeApiName!); - return; - - case "struct": - for (const prop of Object.values(dataType.fields)) { - getObjectTypesFromDataType(prop.fieldType, types); - } - return; - - case "union": - for (const type of dataType.unionTypes) { - getObjectTypesFromDataType(type, types); - } - return; - - case "attachment": - case "boolean": - case "date": - case "double": - case "float": - case "integer": - case "long": - case "null": - case "string": - case "threeDimensionalAggregation": - case "timestamp": - case "twoDimensionalAggregation": - case "unsupported": - /* complete no-op */ - return; - - default: - const _: never = dataType; - throw new Error( - `Cannot find object types from unsupported QueryDataType ${ - (dataType as any).type - }`, - ); - } + const objectTypes = getObjectTypeApiNamesFromQuery(query); + await fs.writeFile( + path.join(outDir, `${query.apiName}.ts`), + await formatTs(` + import { QueryDefinition } from "@osdk/api"; + + export const ${query.apiName} = ${ + JSON.stringify(wireQueryTypeV2ToSdkQueryDefinition(query)) + } ${getQueryDefSatisfies(query.apiName, objectTypes)}`), + ); } function getQueryDefSatisfies(apiName: string, objectTypes: string[]): string { @@ -195,17 +76,3 @@ function getQueryDefSatisfies(apiName: string, objectTypes: string[]): string { : "never" }>;`; } - -function getOsdkTargetTypeIfPresent( - objectTypeApiName: string, - v2: boolean, -): string { - return ` - __OsdkTargetType: ${ - getObjectDefIdentifier( - objectTypeApiName, - v2, - ) - } - `; -} diff --git a/packages/generator/src/v1.1/generateQueries.test.ts b/packages/generator/src/v1.1/generateQueries.test.ts index e8a08c7cd..91a00c356 100644 --- a/packages/generator/src/v1.1/generateQueries.test.ts +++ b/packages/generator/src/v1.1/generateQueries.test.ts @@ -31,6 +31,7 @@ describe(generateQueries, () => { expect(helper.getFiles()[`${BASE_PATH}/Queries.ts`]) .toMatchInlineSnapshot(` "import type { QueryError, QueryResponse, Result } from '@osdk/legacy-client'; + import type { Todo } from '../objects/Todo'; export interface Queries { /** @@ -38,6 +39,12 @@ describe(generateQueries, () => { * @returns number */ getCount(params: { completed: boolean }): Promise, QueryError>>; + + /** + * @param {Todo|Todo["__primaryKey"]} params.someTodo - Random desc so we test jsdoc + * @returns Todo + */ + returnsTodo(params: { someTodo: Todo | Todo['__primaryKey'] }): Promise, QueryError>>; } " `); diff --git a/packages/generator/src/v2.0/generateClientSdkVersionTwoPointZero.ts b/packages/generator/src/v2.0/generateClientSdkVersionTwoPointZero.ts index 2fa08add7..ce62e7546 100644 --- a/packages/generator/src/v2.0/generateClientSdkVersionTwoPointZero.ts +++ b/packages/generator/src/v2.0/generateClientSdkVersionTwoPointZero.ts @@ -24,9 +24,9 @@ import { } from "../shared/wireObjectTypeV2ToSdkObjectConst.js"; import { formatTs } from "../util/test/formatTs.js"; import { verifyOutDir } from "../util/verifyOutDir.js"; -import { generatePerQueryDataFiles } from "../v1.1/generatePerQueryDataFiles.js"; import type { WireOntologyDefinition } from "../WireOntologyDefinition.js"; import { generateOntologyMetadataFile } from "./generateMetadata.js"; +import { generatePerQueryDataFilesV2 } from "./generatePerQueryDataFiles.js"; export async function generateClientSdkVersionTwoPointZero( ontology: WireOntologyDefinition, @@ -202,12 +202,11 @@ export async function generateClientSdkVersionTwoPointZero( const queriesDir = path.join(outDir, "ontology", "queries"); await fs.mkdir(queriesDir, { recursive: true }); - await generatePerQueryDataFiles( + await generatePerQueryDataFilesV2( sanitizedOntology, fs, queriesDir, importExt, - true, ); } diff --git a/packages/generator/src/v2.0/generatePerQueryDataFiles.test.ts b/packages/generator/src/v2.0/generatePerQueryDataFiles.test.ts new file mode 100644 index 000000000..8704e6e23 --- /dev/null +++ b/packages/generator/src/v2.0/generatePerQueryDataFiles.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { createMockMinimalFiles } from "../util/test/createMockMinimalFiles.js"; +import { TodoWireOntology } from "../util/test/TodoWireOntology.js"; +import { generatePerQueryDataFilesV2 as generatePerQueryDataFiles } from "./generatePerQueryDataFiles.js"; + +describe("generatePerQueryDataFiles", () => { + it("is stable v2", async () => { + const helper = createMockMinimalFiles(); + const BASE_PATH = "/foo/queries"; + + await generatePerQueryDataFiles( + TodoWireOntology, + helper.minimalFiles, + BASE_PATH, + ".js", + ); + + expect(helper.getFiles()).toMatchInlineSnapshot(` + { + "/foo/queries/getCount.ts": "import { QueryDefinition } from '@osdk/api'; + + export interface getCount extends QueryDefinition<'getCount', never> { + apiName: 'getCount'; + type: 'query'; + version: '0'; + parameters: { + /** + * (no ontology metadata) + */ + completed: { + nullable: false; + type: 'boolean'; + }; + }; + output: { + nullable: false; + type: 'integer'; + }; + } + + export const getCount: getCount = { + apiName: 'getCount', + type: 'query', + version: '0', + parameters: { + completed: { + type: 'boolean', + nullable: false, + }, + }, + output: { + nullable: false, + type: 'integer', + }, + }; + ", + "/foo/queries/index.ts": "export * from './getCount.js'; + export * from './returnsTodo.js'; + ", + "/foo/queries/returnsTodo.ts": "import { QueryDefinition } from '@osdk/api'; + import { Todo } from '../objects.js'; + + export interface returnsTodo extends QueryDefinition<'returnsTodo', 'Todo'> { + apiName: 'returnsTodo'; + type: 'query'; + version: '0'; + parameters: { + /** + * description: Random desc so we test jsdoc + */ + someTodo: { + description: 'Random desc so we test jsdoc'; + nullable: false; + object: 'Todo'; + type: 'object'; + __OsdkTargetType?: Todo; + }; + }; + output: { + nullable: false; + object: 'Todo'; + type: 'object'; + __OsdkTargetType?: Todo; + }; + } + + export const returnsTodo: returnsTodo = { + apiName: 'returnsTodo', + type: 'query', + version: '0', + parameters: { + someTodo: { + description: 'Random desc so we test jsdoc', + type: 'object', + object: 'Todo', + nullable: false, + }, + }, + output: { + nullable: false, + object: 'Todo', + type: 'object', + }, + }; + ", + } + `); + + await helper.minimalFiles.writeFile( + "/bar/test.ts", + ` + import {returnsTodo} from "/foo/queries/returnsTodo.ts"; + + returnsTodo({someTodo:/*marker*/}) + `, + ); + + const rootFileNames = Object.keys(helper.getFiles()); + console.log(rootFileNames); + + const files: ts.MapLike<{ version: number }> = {}; + + // initialize the list of files + rootFileNames.forEach(fileName => { + files[fileName] = { version: 0 }; + }); + + const servicesHost: ts.LanguageServiceHost = { + getScriptFileNames: () => Object.keys(helper.getFiles()), + getScriptVersion: fileName => + files[fileName] && files[fileName].version.toString(), + getScriptSnapshot: fileName => { + if (!helper.getFiles()[fileName]) { + return undefined; + } + + return ts.ScriptSnapshot.fromString( + helper.getFiles()[fileName], + ); + }, + getCurrentDirectory: () => "/bar", + getCompilationSettings: () => ({}), + getDefaultLibFileName: options => ts.getDefaultLibFilePath(options), + fileExists: (path: string) => { + console.log(path); + return helper.getFiles()[path] !== undefined; + }, + readFile: (path: string) => { + console.log("readFile: ", path); + return helper.getFiles()[path]; + }, + readDirectory: (path, extensions, exclude, include, depth) => { + console.log("readDirectory", path); + return ts.sys.readDirectory(path, extensions, exclude, include, depth); + }, + directoryExists: ts.sys.directoryExists, + getDirectories: ts.sys.getDirectories, + }; + + const langServices = ts.createLanguageService(servicesHost); + + const q = langServices.getDocCommentTemplateAtPosition("/bar/test.ts", 1); + console.log(q); + ts.createDocumentRegistry(); + }); +}); diff --git a/packages/generator/src/v2.0/generatePerQueryDataFiles.ts b/packages/generator/src/v2.0/generatePerQueryDataFiles.ts new file mode 100644 index 000000000..f6bd1b0e9 --- /dev/null +++ b/packages/generator/src/v2.0/generatePerQueryDataFiles.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2024 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { QueryParameterDefinition } from "@osdk/api"; +import type { QueryDataType, QueryTypeV2 } from "@osdk/gateway/types"; +import path from "node:path"; +import type { MinimalFs } from "../MinimalFs.js"; +import { getObjectTypeApiNamesFromQuery } from "../shared/getObjectTypeApiNamesFromQuery.js"; +import { getObjectDefIdentifier } from "../shared/wireObjectTypeV2ToSdkObjectConst.js"; +import { wireQueryDataTypeToQueryDataTypeDefinition } from "../shared/wireQueryDataTypeToQueryDataTypeDefinition.js"; +import { + wireQueryParameterV2ToQueryParameterDefinition as paramToDef, + wireQueryTypeV2ToSdkQueryDefinitionNoParams, +} from "../shared/wireQueryTypeV2ToSdkQueryDefinition.js"; +import { deleteUndefineds } from "../util/deleteUndefineds.js"; +import { stringify } from "../util/stringify.js"; +import { formatTs } from "../util/test/formatTs.js"; +import type { WireOntologyDefinition } from "../WireOntologyDefinition.js"; + +export async function generatePerQueryDataFilesV2( + ontology: WireOntologyDefinition, + fs: MinimalFs, + outDir: string, + importExt: string = "", +) { + await fs.mkdir(outDir, { recursive: true }); + await Promise.all( + Object.values(ontology.queryTypes).map(async query => { + await generateV2QueryFile( + fs, + outDir, + query, + importExt, + ); + }), + ); + + await fs.writeFile( + path.join(outDir, "index.ts"), + await formatTs(` + ${ + Object.values(ontology.queryTypes).map(query => + `export * from "./${query.apiName}${importExt}";` + ) + .join("\n") + } + ${Object.keys(ontology.queryTypes).length === 0 ? "export {};" : ""} + `), + ); +} + +async function generateV2QueryFile( + fs: MinimalFs, + outDir: string, + query: QueryTypeV2, + importExt: string, +) { + const objectTypes = getObjectTypeApiNamesFromQuery(query); + const importObjects = objectTypes.length > 0 + ? `import {${[...objectTypes].join(",")}} from "../objects${importExt}";` + : ""; + + const baseProps = deleteUndefineds( + wireQueryTypeV2ToSdkQueryDefinitionNoParams(query), + ); + + const outputBase = deleteUndefineds( + wireQueryDataTypeToQueryDataTypeDefinition(query.output), + ); + + const referencedObjectTypes = objectTypes.length > 0 + ? objectTypes.map(apiNameObj => `"${apiNameObj}"`).join("|") + : "never"; + + await fs.writeFile( + path.join(outDir, `${query.apiName}.ts`), + await formatTs(` + import { QueryDefinition } from "@osdk/api"; + import type { VersionBound } from "@osdk/api"; + + import type { $ExpectedClientVersion } from "../../OntologyMetadata${importExt}"; + + ${importObjects} + + export interface ${query.apiName} extends QueryDefinition<"${query.apiName}", ${referencedObjectTypes}>, VersionBound<$ExpectedClientVersion>{ + ${stringify(baseProps)}, + parameters: { + ${parameterDefsForType(query)} + }; + output: { + ${stringify(outputBase)}, + ${getLineFor__OsdkTargetType(query.output)} + }; + } + + export const ${query.apiName}: ${query.apiName} = { + ${stringify(baseProps)}, + parameters: { + ${parametersForConst(query)} + }, + output: { + ${stringify(outputBase)}, + } + }; + `), + ); +} + +function parametersForConst(query: QueryTypeV2) { + return stringify(query.parameters, { + "*": (parameter, formatter) => + formatter(deleteUndefineds(paramToDef(parameter))), + }); +} + +function parameterDefsForType(query: QueryTypeV2) { + return stringify(query.parameters, { + "*": (parameter, valueFormatter, apiName) => [ + `${queryParamJsDoc(paramToDef(parameter), { apiName })} ${apiName}`, + ` { + ${stringify(deleteUndefineds(paramToDef(parameter)))}, + ${getLineFor__OsdkTargetType(parameter.dataType)} + }`, + ], + }); +} + +function getLineFor__OsdkTargetType(qdt: QueryDataType) { + if (qdt.type === "object" || qdt.type === "objectSet") { + return `__OsdkTargetType?: ${ + getObjectDefIdentifier(qdt.objectTypeApiName!, true) + }`; + } + return ""; +} + +export function queryParamJsDoc( + param: QueryParameterDefinition, + { apiName }: { apiName: string }, +) { + let ret = `/**\n`; + + if (param.description) { + if (param.description) { + ret += ` * description: ${param.description}\n`; + } + } else { + ret += ` * (no ontology metadata)\n`; + } + + ret += ` */\n`; + return ret; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6b232866..3813fdff6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,9 @@ importers: fetch-retry: specifier: ^6.0.0 version: 6.0.0 + find-up: + specifier: 7.0.0 + version: 7.0.0 isomorphic-ws: specifier: ^5.0.0 version: 5.0.0(ws@8.18.0) @@ -682,6 +685,9 @@ importers: '@types/ws': specifier: ^8.5.10 version: 8.5.10 + execa: + specifier: ^9.3.0 + version: 9.3.0 jest-extended: specifier: ^4.0.2 version: 4.0.2 @@ -691,6 +697,15 @@ importers: p-defer: specifier: ^4.0.1 version: 4.0.1 + p-event: + specifier: ^6.0.1 + version: 6.0.1 + p-locate: + specifier: ^6.0.0 + version: 6.0.0 + p-map: + specifier: ^7.0.2 + version: 7.0.2 p-state: specifier: ^2.0.1 version: 2.0.1 @@ -6100,6 +6115,10 @@ packages: resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} engines: {node: '>=12'} + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -6148,6 +6167,10 @@ packages: resolution: {integrity: sha512-izK3LJXvFsTOnompAXfyZRMJ6StMxwcdjgaoG20ZQH/8USERQnrSs7dtG3z6qiDHfeWhjT/WXWTCKug3pmoO1g==} engines: {node: '>=18'} + p-timeout@6.1.2: + resolution: {integrity: sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ==} + engines: {node: '>=14.16'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -11802,6 +11825,10 @@ snapshots: p-defer@4.0.1: {} + p-event@6.0.1: + dependencies: + p-timeout: 6.1.2 + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -11844,6 +11871,8 @@ snapshots: p-state@2.0.1: {} + p-timeout@6.1.2: {} + p-try@2.2.0: {} package-json@10.0.0: