Skip to content

Commit c70c0a7

Browse files
committed
feat(gen): optionals
1 parent eb43d35 commit c70c0a7

File tree

9 files changed

+294
-15
lines changed

9 files changed

+294
-15
lines changed

lib/gen/go.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ export function generateGo (schema, options = {}) {
4040
// Second pass: generate types with proper ordering
4141
for (const [typeName, typeDefn] of Object.entries(schema.types)) {
4242
if ('struct' in typeDefn) {
43+
// Validate optional fields for tuple representation
44+
if (typeDefn.struct.representation && 'tuple' in typeDefn.struct.representation) {
45+
const fields = Object.entries(typeDefn.struct.fields)
46+
let foundOptional = false
47+
for (const [fieldName, fieldDefn] of fields) {
48+
if (foundOptional && !fieldDefn.optional) {
49+
throw new Error(`Struct "${typeName}": optional field must be at the end when using tuple representation, but field "${fieldName}" is required after optional fields`)
50+
}
51+
if (fieldDefn.optional) {
52+
foundOptional = true
53+
}
54+
}
55+
}
56+
4357
typesrc += `type ${typeName} struct {\n`
4458

4559
for (let [fieldName, fieldDefn] of Object.entries(typeDefn.struct.fields)) {
@@ -121,8 +135,8 @@ export function generateGo (schema, options = {}) {
121135
}
122136
fieldName = fixGoName(annotations, fieldName)
123137
const gotag = annotations.reduce((acc, a) => 'gotag' in a ? ' ' + a.gotag : acc, '')
124-
// Handle nullable fields by making them pointers
125-
if (fieldDefn.nullable) {
138+
// Handle nullable and optional fields by making them pointers
139+
if (fieldDefn.nullable || fieldDefn.optional) {
126140
fieldType = '*' + fieldType
127141
}
128142
typesrc += `\t${fieldName} ${fieldType}${gotag}${linecomment}\n`

lib/gen/rust.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ export function generateRust (schema) {
1414
let typesrc = ''
1515
for (const [typeName, typeDefn] of Object.entries(schema.types)) {
1616
if ('struct' in typeDefn) {
17+
// Validate optional fields for tuple representation
18+
if (typeDefn.struct.representation && 'tuple' in typeDefn.struct.representation) {
19+
const fields = Object.entries(typeDefn.struct.fields)
20+
let foundOptional = false
21+
for (const [fieldName, fieldDefn] of fields) {
22+
if (foundOptional && !fieldDefn.optional) {
23+
throw new Error(`Struct "${typeName}": optional field must be at the end when using tuple representation, but field "${fieldName}" is required after optional fields`)
24+
}
25+
if (fieldDefn.optional) {
26+
foundOptional = true
27+
}
28+
}
29+
}
30+
1731
/** @type {string[]} */
1832
const derive = []
1933
if ('representation' in typeDefn.struct && typeof typeDefn.struct.representation === 'object' && 'tuple' in typeDefn.struct.representation) {
@@ -122,10 +136,32 @@ export function generateRust (schema) {
122136
}
123137
}
124138
fieldName = fixRustName(annotations, fieldName)
125-
// Handle nullable fields
126-
if (fieldDefn.nullable) {
139+
140+
// Handle optional and nullable fields
141+
const isMapRepr = !typeDefn.struct.representation || 'map' in typeDefn.struct.representation
142+
const isTupleRepr = typeDefn.struct.representation && 'tuple' in typeDefn.struct.representation
143+
144+
if (fieldDefn.optional && fieldDefn.nullable) {
145+
// Optional + nullable: Option<Option<T>>
146+
fieldType = `Option<Option<${fieldType}>>`
147+
if (isMapRepr) {
148+
typesrc += ' #[serde(skip_serializing_if = "Option::is_none")]\n'
149+
} else if (isTupleRepr) {
150+
typesrc += ' #[serde(default)]\n'
151+
}
152+
} else if (fieldDefn.optional) {
153+
// Just optional: Option<T>
154+
fieldType = `Option<${fieldType}>`
155+
if (isMapRepr) {
156+
typesrc += ' #[serde(skip_serializing_if = "Option::is_none")]\n'
157+
} else if (isTupleRepr) {
158+
typesrc += ' #[serde(default)]\n'
159+
}
160+
} else if (fieldDefn.nullable) {
161+
// Just nullable: Option<T> but always serialize
127162
fieldType = `Option<${fieldType}>`
128163
}
164+
129165
typesrc += ` pub ${fieldName}: ${fieldType},${linecomment}\n`
130166
}
131167

lib/gen/typescript.js

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,27 @@ export function generateTypeScript (schema) {
1818
}
1919
const typeKind = Object.keys(typeDefn)[0]
2020
if ('struct' in typeDefn) {
21+
// Validate optional fields for tuple representation
22+
if (typeDefn.struct.representation && 'tuple' in typeDefn.struct.representation) {
23+
const fields = Object.entries(typeDefn.struct.fields)
24+
let foundOptional = false
25+
for (const [fieldName, fieldDefn] of fields) {
26+
if (foundOptional && !fieldDefn.optional) {
27+
throw new Error(`Struct "${typeName}": optional field must be at the end when using tuple representation, but field "${fieldName}" is required after optional fields`)
28+
}
29+
if (fieldDefn.optional) {
30+
foundOptional = true
31+
}
32+
}
33+
}
34+
2135
typesrc += `export type ${typeName} = {\n`
2236

2337
/** @type {string[]} */
2438
const fieldValidators = []
2539
let requiredFieldCount = 0
2640
for (let [fieldName, fieldDefn] of Object.entries(typeDefn.struct.fields)) {
27-
if (!fieldDefn.optional && !fieldDefn.nullable) {
41+
if (!fieldDefn.optional) {
2842
requiredFieldCount++
2943
}
3044
/** @type { { [k in string]: string }[]} */
@@ -104,13 +118,21 @@ export function generateTypeScript (schema) {
104118
}
105119
}
106120
fieldName = fixTypeScriptName(annotations, fieldName)
107-
// Handle nullable fields - they become optional with | null
108-
if (fieldDefn.nullable) {
121+
// Handle optional and nullable fields
122+
if (fieldDefn.optional && fieldDefn.nullable) {
123+
// Both optional and nullable
109124
typesrc += ` ${fieldName}?: ${fieldType} | null${linecomment}\n`
125+
} else if (fieldDefn.optional) {
126+
// Just optional
127+
typesrc += ` ${fieldName}?: ${fieldType}${linecomment}\n`
128+
} else if (fieldDefn.nullable) {
129+
// Just nullable - field is required but can be null
130+
typesrc += ` ${fieldName}: ${fieldType} | null${linecomment}\n`
110131
} else {
111-
typesrc += ` ${fieldName}${fieldDefn.optional ? '?' : ''}: ${fieldType}${linecomment}\n`
132+
// Required field
133+
typesrc += ` ${fieldName}: ${fieldType}${linecomment}\n`
112134
}
113-
const inCheck = !fieldDefn.optional && !fieldDefn.nullable ? `'${fieldName}' in value &&` : `!('${fieldName}' in value) ||`
135+
const inCheck = !fieldDefn.optional ? `'${fieldName}' in value &&` : `!('${fieldName}' in value) ||`
114136
if (isMap) {
115137
const kind = fixTypeScriptType(imports, '@ipld/schema/schema-schema.js#KindMap', false)
116138
let valueType = getTypeScriptType([], mapValueType)

test/fixtures/gen/optionals.md

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# Optional Fields Code Generation Tests
2+
3+
This file tests optional field code generation for Go, Rust, and TypeScript.
4+
5+
## Schema
6+
7+
[testmark]:# (test/schema)
8+
```ipldsch
9+
# Struct with optional fields in map representation
10+
type User struct {
11+
id String
12+
name String
13+
email optional String
14+
age optional Int
15+
verified Bool
16+
}
17+
18+
# Struct with nullable and optional fields
19+
type Profile struct {
20+
userId String
21+
bio optional nullable String
22+
avatar optional String
23+
lastLogin nullable Int
24+
}
25+
26+
# Struct with optional fields at end for tuple representation
27+
type TupleData struct {
28+
required1 String
29+
required2 Int
30+
optional1 optional String
31+
optional2 optional Bool
32+
} representation tuple
33+
```
34+
35+
## Expected Go output
36+
37+
[testmark]:# (test/golang)
38+
```go
39+
package main
40+
41+
type User struct {
42+
id string
43+
name string
44+
email *string
45+
age *int64
46+
verified bool
47+
}
48+
49+
type Profile struct {
50+
userId string
51+
bio *string
52+
avatar *string
53+
lastLogin *int64
54+
}
55+
56+
type TupleData struct {
57+
required1 string
58+
required2 int64
59+
optional1 *string
60+
optional2 *bool
61+
}
62+
```
63+
64+
## Expected Rust output
65+
66+
[testmark]:# (test/rust)
67+
```rust
68+
use serde::{Deserialize, Serialize};
69+
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
70+
71+
#[derive(Deserialize, Serialize)]
72+
pub struct User {
73+
pub id: String,
74+
pub name: String,
75+
#[serde(skip_serializing_if = "Option::is_none")]
76+
pub email: Option<String>,
77+
#[serde(skip_serializing_if = "Option::is_none")]
78+
pub age: Option<i64>,
79+
pub verified: bool,
80+
}
81+
82+
#[derive(Deserialize, Serialize)]
83+
pub struct Profile {
84+
pub user_id: String,
85+
#[serde(skip_serializing_if = "Option::is_none")]
86+
pub bio: Option<Option<String>>,
87+
#[serde(skip_serializing_if = "Option::is_none")]
88+
pub avatar: Option<String>,
89+
pub last_login: Option<i64>,
90+
}
91+
92+
#[derive(Deserialize_tuple, Serialize_tuple)]
93+
pub struct TupleData {
94+
pub required1: String,
95+
pub required2: i64,
96+
#[serde(default)]
97+
pub optional1: Option<String>,
98+
#[serde(default)]
99+
pub optional2: Option<bool>,
100+
}
101+
```
102+
103+
## Expected TypeScript output
104+
105+
[testmark]:# (test/typescript)
106+
```typescript
107+
import {
108+
KindBool,
109+
KindInt,
110+
KindMap,
111+
KindString,
112+
} from '@ipld/schema/schema-schema.js'
113+
114+
export type User = {
115+
id: KindString
116+
name: KindString
117+
email?: KindString
118+
age?: KindInt
119+
verified: KindBool
120+
}
121+
122+
export namespace User {
123+
export function isUser(value: any): value is User {
124+
if (!KindMap.isKindMap(value)) {
125+
return false
126+
}
127+
const keyCount = Object.keys(value).length
128+
return keyCount >= 3 && keyCount <= 5 &&
129+
('id' in value && ((KindString.isKindString(value.id)))) &&
130+
('name' in value && ((KindString.isKindString(value.name)))) &&
131+
(!('email' in value) || ((KindString.isKindString(value.email)))) &&
132+
(!('age' in value) || ((KindInt.isKindInt(value.age)))) &&
133+
('verified' in value && ((KindBool.isKindBool(value.verified))))
134+
}
135+
}
136+
137+
export type Profile = {
138+
userId: KindString
139+
bio?: KindString | null
140+
avatar?: KindString
141+
lastLogin: KindInt | null
142+
}
143+
144+
export namespace Profile {
145+
export function isProfile(value: any): value is Profile {
146+
if (!KindMap.isKindMap(value)) {
147+
return false
148+
}
149+
const keyCount = Object.keys(value).length
150+
return keyCount >= 2 && keyCount <= 4 &&
151+
('userId' in value && ((KindString.isKindString(value.userId)))) &&
152+
(!('bio' in value) || (value.bio === null || (KindString.isKindString(value.bio)))) &&
153+
(!('avatar' in value) || ((KindString.isKindString(value.avatar)))) &&
154+
('lastLogin' in value && (value.lastLogin === null || (KindInt.isKindInt(value.lastLogin))))
155+
}
156+
}
157+
158+
export type TupleData = {
159+
required1: KindString
160+
required2: KindInt
161+
optional1?: KindString
162+
optional2?: KindBool
163+
}
164+
165+
export namespace TupleData {
166+
export function isTupleData(value: any): value is TupleData {
167+
if (!KindMap.isKindMap(value)) {
168+
return false
169+
}
170+
const keyCount = Object.keys(value).length
171+
return keyCount >= 2 && keyCount <= 4 &&
172+
('required1' in value && ((KindString.isKindString(value.required1)))) &&
173+
('required2' in value && ((KindInt.isKindInt(value.required2)))) &&
174+
(!('optional1' in value) || ((KindString.isKindString(value.optional1)))) &&
175+
(!('optional2' in value) || ((KindBool.isKindBool(value.optional2))))
176+
}
177+
}
178+
```

test/fixtures/gen/unions-kinded.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ export namespace ListConfig {
229229

230230
export type Settings = {
231231
name: KindString
232-
config?: ConfigValue | null
232+
config: ConfigValue | null
233233
}
234234

235235
export namespace Settings {
@@ -238,9 +238,9 @@ export namespace Settings {
238238
return false
239239
}
240240
const keyCount = Object.keys(value).length
241-
return keyCount >= 1 && keyCount <= 2 &&
241+
return keyCount === 2 &&
242242
('name' in value && ((KindString.isKindString(value.name)))) &&
243-
(!('config' in value) || (value.config === null || (ConfigValue.isConfigValue(value.config))))
243+
('config' in value && (value.config === null || (ConfigValue.isConfigValue(value.config))))
244244
}
245245
}
246246
```

test/test-gen.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,32 @@ describe('Generate kinded union types', () => {
181181
assert.strictEqual(ts, expectedTypeScript)
182182
})
183183
})
184+
185+
describe('Generate optional fields', () => {
186+
let schema, expectedGo, expectedRust, expectedTypeScript
187+
188+
before(async () => {
189+
const contents = await readFile(new URL('./fixtures/gen/optionals.md', import.meta.url), 'utf8')
190+
const dirEnt = index(parse(contents))
191+
const schemaText = dirEnt.children.get('test').children.get('schema').hunk.body
192+
schema = fromDSL(schemaText, { includeComments: true, includeAnnotations: true })
193+
expectedGo = dirEnt.children.get('test').children.get('golang').hunk.body
194+
expectedRust = dirEnt.children.get('test').children.get('rust').hunk.body
195+
expectedTypeScript = dirEnt.children.get('test').children.get('typescript').hunk.body
196+
})
197+
198+
it('Go', async () => {
199+
const go = generateGo(schema, { package: 'main' })
200+
assert.strictEqual(go, expectedGo)
201+
})
202+
203+
it('Rust', async () => {
204+
const rust = generateRust(schema)
205+
assert.strictEqual(rust, expectedRust)
206+
})
207+
208+
it('TypeScript', async () => {
209+
const ts = generateTypeScript(schema)
210+
assert.strictEqual(ts, expectedTypeScript)
211+
})
212+
})

types/lib/gen/go.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

types/lib/gen/rust.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

types/lib/gen/typescript.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)