diff --git a/packages/parse/__tests__/ContractAnalyzer.multiFile.test.ts b/packages/parse/__tests__/ContractAnalyzer.multiFile.test.ts index 16ecf7a..b4d9701 100644 --- a/packages/parse/__tests__/ContractAnalyzer.multiFile.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.multiFile.test.ts @@ -1188,4 +1188,92 @@ describe('ContractAnalyzer - Multi-File Analysis', () => { ]); }); }); + + describe('null and undefined union types', () => { + it('should handle multi-file contract with null union parameters', () => { + const sourceFiles = { + 'src/types.ts': ` + export interface UserData { + id: string; + name: string | null; + } + `, + 'src/contract.ts': ` + import { UserData } from './types'; + + export default class NullableContract { + state = { users: [] as UserData[] }; + + addUser(userData: UserData | null): boolean { + if (userData) { + this.state.users.push(userData); + return true; + } + return false; + } + + findUser(id: string | null): UserData | null { + return id ? this.state.users.find(u => u.id === id) || null : null; + } + } + `, + }; + + const result = analyzer.analyzeMultiFile(sourceFiles); + expect(result.queries).toEqual([ + { + name: 'findUser', + params: [{ name: 'id', type: 'string | null' }], + returnType: 'UserData | null', + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'addUser', + params: [{ name: 'userData', type: 'UserData | null' }], + returnType: 'boolean', + }, + ]); + }); + + it('should handle multi-file contract with undefined union parameters', () => { + const sourceFiles = { + 'src/models.ts': ` + export type OptionalString = string | undefined; + export type NullableNumber = number | null | undefined; + `, + 'src/contract.ts': ` + import { OptionalString, NullableNumber } from './models'; + + export default class UndefinedContract { + state = { data: {} }; + + processValue(value: OptionalString): string { + return value || 'default'; + } + + updateCounter(increment: NullableNumber): void { + this.state.data.counter = (this.state.data.counter || 0) + (increment || 0); + } + } + `, + }; + + const result = analyzer.analyzeMultiFile(sourceFiles); + expect(result.queries).toEqual([ + { + name: 'processValue', + params: [{ name: 'value', type: 'OptionalString' }], + returnType: 'string', + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'updateCounter', + params: [{ name: 'increment', type: 'NullableNumber' }], + returnType: 'void', + }, + ]); + }); + }); }); diff --git a/packages/parse/__tests__/ContractAnalyzer.multiFileSchema.test.ts b/packages/parse/__tests__/ContractAnalyzer.multiFileSchema.test.ts index 978b2d7..312f107 100644 --- a/packages/parse/__tests__/ContractAnalyzer.multiFileSchema.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.multiFileSchema.test.ts @@ -453,7 +453,7 @@ describe('ContractAnalyzer - Multi-File with Schema', () => { { name: 'checkStatus', params: [], - returnSchema: { anyOf: [{ type: 'boolean' }, {}] }, + returnSchema: { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, }, ]); expect(result.mutations).toEqual([ @@ -587,7 +587,7 @@ describe('ContractAnalyzer - Multi-File with Schema', () => { }, required: ['id', 'name', 'age'], }, - {}, + { type: 'null' }, ], }, }, @@ -1076,4 +1076,154 @@ describe('ContractAnalyzer - Multi-File with Schema', () => { }, ]); }); + + describe('null and undefined union types with schemas', () => { + it('should handle multi-file contract with null union schemas', () => { + const sourceFiles = { + 'src/types.ts': ` + export interface UserProfile { + id: string; + email: string | null; + verified: boolean; + } + `, + 'src/contract.ts': ` + import { UserProfile } from './types'; + + export default class NullableSchemaContract { + state = { profiles: [] as UserProfile[] }; + + getProfile(id: string | null): UserProfile | null { + return this.state.profiles[0]; + } + + createProfile(data: UserProfile | null): void { + if (data) { + this.state.profiles.push(data); + } + } + } + `, + }; + + const result = analyzer.analyzeMultiFileWithSchema(sourceFiles); + expect(result.queries).toEqual([ + { + name: 'getProfile', + params: [ + { + name: 'id', + schema: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + ], + returnSchema: { + anyOf: [ + { + type: 'object', + properties: { + id: { type: 'string' }, + email: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + verified: { type: 'boolean' }, + }, + required: ['id', 'email', 'verified'], + }, + { type: 'null' }, + ], + }, + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'createProfile', + params: [ + { + name: 'data', + schema: { + anyOf: [ + { + type: 'object', + properties: { + id: { type: 'string' }, + email: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + verified: { type: 'boolean' }, + }, + required: ['id', 'email', 'verified'], + }, + { type: 'null' }, + ], + }, + }, + ], + returnSchema: {}, + }, + ]); + }); + + it('should handle multi-file contract with undefined union schemas', () => { + const sourceFiles = { + 'src/config.ts': ` + export type OptionalConfig = { + timeout?: number | undefined; + retries: number | null; + }; + `, + 'src/contract.ts': ` + import { OptionalConfig } from './config'; + + export default class UndefinedSchemaContract { + state = { settings: {} }; + + updateConfig(config: OptionalConfig): OptionalConfig { + this.state.settings = { ...this.state.settings, ...config }; + return config; + } + + getTimeout(defaultValue: number | undefined): number { + return this.state.settings.timeout ?? defaultValue ?? 5000; + } + } + `, + }; + + const result = analyzer.analyzeMultiFileWithSchema(sourceFiles); + expect(result.queries).toEqual([ + { + name: 'getTimeout', + params: [ + { + name: 'defaultValue', + schema: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + ], + returnSchema: { type: 'number' }, + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'updateConfig', + params: [ + { + name: 'config', + schema: { + type: 'object', + properties: { + timeout: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + retries: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + required: ['retries'], + }, + }, + ], + returnSchema: { + type: 'object', + properties: { + timeout: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + retries: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + required: ['retries'], + }, + }, + ]); + }); + }); }); diff --git a/packages/parse/__tests__/ContractAnalyzer.singleFile.test.ts b/packages/parse/__tests__/ContractAnalyzer.singleFile.test.ts index 9de2104..11f9461 100644 --- a/packages/parse/__tests__/ContractAnalyzer.singleFile.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.singleFile.test.ts @@ -666,4 +666,72 @@ describe('ContractAnalyzer - Single File Analysis', () => { expect(result.mutations.map((m) => m.name)).toEqual(['addItem', 'processItem']); }); }); + + describe('null and undefined union types', () => { + it('should handle parameters with null union types', () => { + const code = ` + export default class Contract { + state: any; + + add(num: number | null) { + if (num !== null) { + this.state.value += num; + } + } + + getName(id: string | null): string { + return id ? this.state.names[id] : 'unknown'; + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { + name: 'getName', + params: [{ name: 'id', type: 'string | null' }], + returnType: 'string', + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'add', + params: [{ name: 'num', type: 'number | null' }], + returnType: 'void', + }, + ]); + }); + + it('should handle parameters with undefined union types and nullable return types', () => { + const code = ` + export default class Contract { + state: any; + + updateCount(increment: number | undefined) { + this.state.count += increment || 0; + } + + findUser(id: string): User | null | undefined { + return this.state.users.find(u => u.id === id); + } + } + `; + + const result = analyzer.analyzeFromCode(code); + expect(result.queries).toEqual([ + { + name: 'findUser', + params: [{ name: 'id', type: 'string' }], + returnType: 'User | null | undefined', + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'updateCount', + params: [{ name: 'increment', type: 'number | undefined' }], + returnType: 'void', + }, + ]); + }); + }); }); diff --git a/packages/parse/__tests__/ContractAnalyzer.singleFileSchema.test.ts b/packages/parse/__tests__/ContractAnalyzer.singleFileSchema.test.ts index 5f22e0b..634200c 100644 --- a/packages/parse/__tests__/ContractAnalyzer.singleFileSchema.test.ts +++ b/packages/parse/__tests__/ContractAnalyzer.singleFileSchema.test.ts @@ -372,4 +372,101 @@ describe('ContractAnalyzer - Single File with Schema', () => { ]); expect(result.mutations).toEqual([]); }); + + it('should handle null union types in parameters and return schemas', () => { + const code = ` + export default class Contract { + getValue(id: string | null): number | null { + return id ? 42 : null; + } + + setData(data: { value: number | null }): void { + this.state.data = data; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'getValue', + params: [ + { + name: 'id', + schema: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + ], + returnSchema: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'setData', + params: [ + { + name: 'data', + schema: { + type: 'object', + properties: { + value: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + required: ['value'], + }, + }, + ], + returnSchema: {}, + }, + ]); + }); + + it('should handle undefined union types and complex nullable schemas', () => { + const code = ` + export default class Contract { + processItems(items: Array): Array { + return items.map(item => item ? item.length : null); + } + + updateSettings(config: { timeout?: number | null }): void { + this.state.config = config; + } + } + `; + + const result = analyzer.analyzeWithSchema(code); + expect(result.queries).toEqual([ + { + name: 'processItems', + params: [ + { + name: 'items', + schema: { + type: 'array', + items: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + ], + returnSchema: { + type: 'array', + items: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + }, + ]); + expect(result.mutations).toEqual([ + { + name: 'updateSettings', + params: [ + { + name: 'config', + schema: { + type: 'object', + properties: { + timeout: { anyOf: [{ type: 'number' }, { type: 'null' }] }, + }, + }, + }, + ], + returnSchema: {}, + }, + ]); + }); }); diff --git a/packages/parse/src/schema-converter.ts b/packages/parse/src/schema-converter.ts index cb4c8b0..2c859b4 100644 --- a/packages/parse/src/schema-converter.ts +++ b/packages/parse/src/schema-converter.ts @@ -46,6 +46,8 @@ export class SchemaConverter { if (t.isTSStringKeyword(node)) return { type: 'string' }; if (t.isTSNumberKeyword(node)) return { type: 'number' }; if (t.isTSBooleanKeyword(node)) return { type: 'boolean' }; + if (t.isTSNullKeyword(node)) return { type: 'null' }; + if (t.isTSUndefinedKeyword(node)) return { type: 'null' }; // JSON Schema doesn't have undefined, use null // literal types (e.g. 'on', 1, true) if (t.isTSLiteralType(node)) { const lit = node.literal;