diff --git a/cli/src/utils/resource_types.ts b/cli/src/utils/resource_types.ts index 5bd56c9a8e..8317ee7ab5 100644 --- a/cli/src/utils/resource_types.ts +++ b/cli/src/utils/resource_types.ts @@ -4,18 +4,29 @@ function quotePropName(name: string): string { return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name); } -export function compileResourceTypeToTsType(schema: Schema) { - function rec(x: { [name: string]: SchemaProperty }, root = false) { - let res = "{\n"; +function isPropertyMap(x: unknown): x is { [name: string]: SchemaProperty } { + return typeof x === "object" && x !== null && !Array.isArray(x); +} + +// Schemas are free-form jsonb: the column is nullable and hub types such as +// `record` or `dbt_profile` carry `{}` / `{"type":"object"}` with no +// `properties`. Anything that is not a property map compiles to `any`, since a +// throw here aborts the whole rt.d.ts generation. +export function compileResourceTypeToTsType(schema: Schema | undefined | null) { + function rec(x: unknown): string { + if (!isPropertyMap(x)) { + return "any"; + } const entries = Object.entries(x); if (entries.length == 0) { return "any"; } + let res = "{\n"; let i = 0; for (let [name, prop] of entries) { - if (prop.type == "object") { - res += ` ${quotePropName(name)}: ${rec(prop.properties ?? {})}`; - } else if (prop.type == "array") { + if (prop?.type == "object") { + res += ` ${quotePropName(name)}: ${rec(prop.properties)}`; + } else if (prop?.type == "array") { res += ` ${quotePropName(name)}: ${prop?.items?.type ?? "any"}[]`; } else { let typ = prop?.type ?? "any"; @@ -33,5 +44,5 @@ export function compileResourceTypeToTsType(schema: Schema) { return res; } - return rec(schema.properties, true); + return rec(schema?.properties); } diff --git a/cli/test/resource_types_unit.test.ts b/cli/test/resource_types_unit.test.ts index 0966ae25bf..c8bebb1baa 100644 --- a/cli/test/resource_types_unit.test.ts +++ b/cli/test/resource_types_unit.test.ts @@ -52,6 +52,26 @@ test("non-identifier property names are double-quoted", () => { expect(out).toContain(' "3leading": boolean'); }); +// WIN-2392: hub types like `record` (schema `{}`) or `dbt_profile` +// (`{"type":"object"}`) have no `properties`, and the schema column itself is +// nullable. A type whose property map is missing must still compile, otherwise +// it aborts the whole rt.d.ts generation. +test("schemas without a usable property map compile to any", () => { + expect(compileResourceTypeToTsType(undefined)).toBe("any"); + expect(compileResourceTypeToTsType(null)).toBe("any"); + expect(compileResourceTypeToTsType({ type: "object" } as any)).toBe("any"); + expect(compileResourceTypeToTsType({ properties: null } as any)).toBe("any"); + expect(compileResourceTypeToTsType(schema({}))).toBe("any"); +}); + +test("a null property compiles to any instead of throwing", () => { + const out = compileResourceTypeToTsType( + schema({ host: null, port: { type: "integer" } } as any) + ); + expect(out).toContain(" host: any"); + expect(out).toContain(" port: number"); +}); + test("nested object and array property names are quoted too", () => { const out = compileResourceTypeToTsType( schema({ diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index dc2f046850..d0e6d05a1a 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -59,7 +59,7 @@ Settings, Users } from 'lucide-svelte' - import { capitalize, formatS3Object, toCamel, type Item } from '$lib/utils' + import { capitalize, formatS3Object, isObject, toCamel, type Item } from '$lib/utils' import DropdownV2 from './DropdownV2.svelte' import type { Schema, SchemaProperty, SupportedLanguage } from '$lib/common' import ScriptVersionHistory from './ScriptVersionHistory.svelte' @@ -394,17 +394,17 @@ const dispatch = createEventDispatcher() function compile(schema: Schema) { - function rec(x: { [name: string]: SchemaProperty }, root = false) { + function rec(x: { [name: string]: SchemaProperty } | undefined, root = false) { let res = '{\n' - const entries = Object.entries(x) + const entries = Object.entries(isObject(x) ? x : {}) if (entries.length == 0) { return 'any' } let i = 0 for (let [name, prop] of entries) { - if (prop.type == 'object') { - res += `${name}: ${rec(prop.properties ?? {})}` - } else if (prop.type == 'array') { + if (prop?.type == 'object') { + res += `${name}: ${rec(prop.properties)}` + } else if (prop?.type == 'array') { res += `${name}: ${prop?.items?.type ?? 'any'}[]` } else { let typ = prop?.type ?? 'any' @@ -423,7 +423,7 @@ } return res } - return rec(schema.properties, true) + return rec(schema?.properties, true) } async function quicktypeJSONSchema(targetLanguage, typeName, jsonSchemaString, rendererOptions) { @@ -484,22 +484,23 @@ function phpCompile(schema: Schema) { let res = ' ' - const entries = Object.entries(schema.properties) + const properties = schema?.properties + const entries = Object.entries(isObject(properties) ? properties : {}) if (entries.length === 0) { - return 'array' + return '' } let i = 0 for (let [name, prop] of entries) { let typ = 'array' - if (prop.type === 'array') { + if (prop?.type === 'array') { typ = 'array' - } else if (prop.type === 'string') { + } else if (prop?.type === 'string') { typ = 'string' - } else if (prop.type === 'number') { + } else if (prop?.type === 'number') { typ = 'float' - } else if (prop.type === 'integer') { + } else if (prop?.type === 'integer') { typ = 'int' - } else if (prop.type === 'boolean') { + } else if (prop?.type === 'boolean') { typ = 'bool' } res += `public ${typ} $${name};` @@ -512,22 +513,24 @@ } function pythonCompile(schema: Schema) { let res = '' - const entries = Object.entries(schema.properties) + const properties = schema?.properties + const entries = Object.entries(isObject(properties) ? properties : {}) if (entries.length === 0) { - return 'dict' + // the result is inserted as a `class X(TypedDict):` body + return 'pass' } let i = 0 for (let [name, prop] of entries) { let typ = 'dict' - if (prop.type === 'array') { + if (prop?.type === 'array') { typ = 'list' - } else if (prop.type === 'string') { + } else if (prop?.type === 'string') { typ = 'str' - } else if (prop.type === 'number') { + } else if (prop?.type === 'number') { typ = 'float' - } else if (prop.type === 'integer') { + } else if (prop?.type === 'integer') { typ = 'int' - } else if (prop.type === 'boolean') { + } else if (prop?.type === 'boolean') { typ = 'bool' } res += `${name}: ${typ}` diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 6cabc9bb2c..35e6f5169a 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -1,6 +1,6 @@ import { ResourceService, JobService } from '$lib/gen/services.gen' import type { AIProvider, AIProviderModel, ResourceType, ScriptLang } from '$lib/gen/types.gen' -import { capitalize, isObject, toCamel } from '$lib/utils' +import { capitalize, toCamel } from '$lib/utils' import { compile, phpCompile, pythonCompile } from '../../utils' import type { ChatCompletionSystemMessageParam, @@ -41,16 +41,13 @@ export function formatResourceTypes( allResourceTypes: ResourceType[], lang: 'python3' | 'php' | 'bun' | 'deno' | 'nativets' | 'bunnative' ) { - const resourceTypes = allResourceTypes.filter( - (rt) => isObject(rt.schema) && 'properties' in rt.schema && isObject(rt.schema.properties) - ) if (lang === 'python3') { - const result = resourceTypes.map((resourceType) => { + const result = allResourceTypes.map((resourceType) => { return `class ${resourceType.name}(TypedDict):\n${pythonCompile(resourceType.schema as any)}` }) return '\n**Make sure to rename conflicting imported modules**\n' + result.join('\n\n') } else if (lang === 'php') { - const result = resourceTypes.map((resourceType) => { + const result = allResourceTypes.map((resourceType) => { return `class ${toCamel(capitalize(resourceType.name))} {\n${phpCompile( resourceType.schema as any )}\n}` @@ -58,7 +55,7 @@ export function formatResourceTypes( return '\n' + result.join('\n\n') } else { let resultStr = 'namespace RT {\n' - const result = resourceTypes.map((resourceType) => { + const result = allResourceTypes.map((resourceType) => { return ` type ${toCamel(capitalize(resourceType.name))} = ${compile( resourceType.schema as any ).replaceAll('\n', '\n ')}` diff --git a/frontend/src/lib/components/copilot/utils.resourceTypes.test.ts b/frontend/src/lib/components/copilot/utils.resourceTypes.test.ts new file mode 100644 index 0000000000..95a126f91a --- /dev/null +++ b/frontend/src/lib/components/copilot/utils.resourceTypes.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { formatResourceTypes } from './utils' + +// Hub resource types such as `record` (schema `{}`) or `dbt_profile` +// (`{"type":"object"}`) carry no `properties`, and the schema column is +// nullable, so any workspace can hold a type whose property map is missing. +const resourceTypes = [ + { name: 'record', schema: {} }, + { name: 'dbt_profile', schema: { type: 'object' } }, + { name: 'null_schema', schema: null }, + { name: 'null_properties', schema: { type: 'object', properties: null } }, + { name: 'ok', schema: { type: 'object', properties: { host: { type: 'string' } } } } +] as any + +describe('formatResourceTypes tolerates resource types without a property map', () => { + it('emits `any` for typescript and keeps the valid types', () => { + const out = formatResourceTypes(resourceTypes, 'typescript') + expect(out).toContain('type Record = any') + expect(out).toContain('host: string') + }) + + it('emits an indented `pass` body for python', () => { + const out = formatResourceTypes(resourceTypes, 'python3') + expect(out).toContain('class record(TypedDict):\n pass') + expect(out).toContain('host: str') + }) + + it('emits an empty class body for php', () => { + const out = formatResourceTypes(resourceTypes, 'php') + expect(out).toContain('class Record {\n\n}') + expect(out).toContain('public string $host;') + }) +}) diff --git a/frontend/src/lib/components/copilot/utils.ts b/frontend/src/lib/components/copilot/utils.ts index 50f6513b26..d8ca541ef5 100644 --- a/frontend/src/lib/components/copilot/utils.ts +++ b/frontend/src/lib/components/copilot/utils.ts @@ -2,7 +2,7 @@ import type { Schema, SchemaProperty } from '../../common' import type { ResourceType, ScriptLang } from '../../gen' -import { capitalize, toCamel } from '$lib/utils' +import { capitalize, isObject, toCamel } from '$lib/utils' import YAML from 'yaml' export const getCommentSymbol = ( @@ -40,17 +40,17 @@ export const getCommentSymbol = ( } export function compile(schema: Schema) { - function rec(x: { [name: string]: SchemaProperty }, root = false) { + function rec(x: { [name: string]: SchemaProperty } | undefined, root = false) { let res = '{\n' - const entries = Object.entries(x) + const entries = Object.entries(isObject(x) ? x : {}) if (entries.length == 0) { return 'any' } let i = 0 for (let [name, prop] of entries) { - if (prop.type == 'object') { - res += ` ${name}: ${rec(prop.properties ?? {})}` - } else if (prop.type == 'array') { + if (prop?.type == 'object') { + res += ` ${name}: ${rec(prop.properties)}` + } else if (prop?.type == 'array') { res += ` ${name}: ${prop?.items?.type ?? 'any'}[]` } else { let typ = prop?.type ?? 'any' @@ -68,27 +68,29 @@ export function compile(schema: Schema) { return res } - return rec(schema.properties, true) + return rec(schema?.properties, true) } export function pythonCompile(schema: Schema) { let res = '' - const entries = Object.entries(schema.properties) + const properties = schema?.properties + const entries = Object.entries(isObject(properties) ? properties : {}) if (entries.length === 0) { - return 'dict' + // callers embed the result as a `class X(TypedDict):` body + return ' pass' } let i = 0 for (let [name, prop] of entries) { let typ = 'dict' - if (prop.type === 'array') { + if (prop?.type === 'array') { typ = 'list' - } else if (prop.type === 'string') { + } else if (prop?.type === 'string') { typ = 'str' - } else if (prop.type === 'number') { + } else if (prop?.type === 'number') { typ = 'float' - } else if (prop.type === 'integer') { + } else if (prop?.type === 'integer') { typ = 'int' - } else if (prop.type === 'boolean') { + } else if (prop?.type === 'boolean') { typ = 'bool' } res += ` ${name}: ${typ}` @@ -102,22 +104,23 @@ export function pythonCompile(schema: Schema) { export function phpCompile(schema: Schema) { let res = '' - const entries = Object.entries(schema.properties) + const properties = schema?.properties + const entries = Object.entries(isObject(properties) ? properties : {}) if (entries.length === 0) { - return 'array' + return '' } let i = 0 for (let [name, prop] of entries) { let typ = 'array' - if (prop.type === 'array') { + if (prop?.type === 'array') { typ = 'array' - } else if (prop.type === 'string') { + } else if (prop?.type === 'string') { typ = 'string' - } else if (prop.type === 'number') { + } else if (prop?.type === 'number') { typ = 'float' - } else if (prop.type === 'integer') { + } else if (prop?.type === 'integer') { typ = 'int' - } else if (prop.type === 'boolean') { + } else if (prop?.type === 'boolean') { typ = 'bool' } res += ` public ${typ} $${name};`