Skip to content

Commit d0c4a18

Browse files
authored
feat!: add Swift @DocumentID generation and Swift field renaming (#214)
* Implement document id field and platform options * Tests * Improved tests
1 parent cefee9f commit d0c4a18

31 files changed

Lines changed: 1604 additions & 438 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'typesync-cli': minor
3+
---
4+
5+
Swift: auto-emit `@DocumentID var id: String?` on every generated document-model struct, plus two new schema-level overrides for the Swift generator.
6+
7+
- The Firebase iOS SDK populates `@DocumentID` properties from the document path (and excludes them from the encoded body), so generated structs are now drop-in usable with `getDocument(as:)` / `setData(from:)` without manual edits.
8+
- New per-document-model option `swift.documentIdProperty.name` lets you rename the auto-generated `@DocumentID` property (default: `id`). Set this when your document body already has a field whose Firestore key is `id`, since the Firebase iOS SDK refuses to decode a document where the `@DocumentID` property name matches a body wire key.
9+
- New per-field option `swift.name` lets you rename a body property in the generated Swift output without changing its Firestore wire key. Useful for dodging Swift keywords or for ergonomics. The renderer routes the original Firestore key through a generated `CodingKeys` enum.
10+
- The Swift generator now throws when (a) a document model has a body field whose Firestore key matches the `@DocumentID` property name (rename one or the other via the options above), or (b) two body fields resolve to the same Swift property name. Both errors include the offending field names and a concrete remediation.
11+
12+
This is a behavior change for Swift consumers: every generated document struct gains an `id: String?` property and an `import FirebaseFirestore` statement. Schemas with a body-side `id` field on a document model must opt in to a non-`id` `@DocumentID` property name via `swift: { documentIdProperty: { name: 'documentId' } }` (or similar) on the document model to keep generating successfully.
13+
14+
The new options are structured as per-platform blocks (`swift: { ... }`) so future generators (Python, TypeScript, etc.) can layer in their own field-level and model-level overrides without further breaking changes.

schema.local.json

Lines changed: 421 additions & 367 deletions
Large diffs are not rendered by default.

src/converters/definition-to-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export function fieldToSchema(fieldName: string, field: definition.types.ObjectF
9494
readonly: !!field.readonly,
9595
docs: field.docs ?? null,
9696
name: fieldName,
97+
...(field.swift !== undefined ? { platformOptions: { swift: { ...field.swift } } } : {}),
9798
};
9899
}
99100

src/definition/impl/_zod-schemas.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,19 @@ export const type: z.ZodType<types.Type> = z.lazy(() =>
169169
.describe('Any valid type.')
170170
);
171171

172+
export const swiftFieldOptions = z
173+
.object({
174+
name: z
175+
.string()
176+
.min(1)
177+
.optional()
178+
.describe(
179+
"Overrides the Swift property name used to decode this field. Encoding is unaffected: the field is still serialized to Firestore under the schema's field name (the Swift renderer routes the original name through `CodingKeys`). Useful when the schema name collides with a Swift keyword or with an auto-generated property such as `@DocumentID var id`."
180+
),
181+
})
182+
.strict()
183+
.describe('Swift-specific overrides for an object field.');
184+
172185
export const objectField = z
173186
.object({
174187
type: type,
@@ -180,6 +193,9 @@ export const objectField = z
180193
'Whether this field is read-only. Defaults to false. This information is used by the Security Rules generator when producing validators that detect whether a read-only field has been affected by a write.'
181194
),
182195
docs: z.string().optional().describe('Optional documentation for the object field.'),
196+
swift: swiftFieldOptions
197+
.optional()
198+
.describe('Per-platform overrides for the Swift generator. See `SwiftFieldOptions`.'),
183199
})
184200
.strict()
185201
.describe('An object field.');
@@ -193,6 +209,26 @@ export const aliasModel = z
193209
.strict()
194210
.describe('An alias model');
195211

212+
export const swiftDocumentModelOptions = z
213+
.object({
214+
documentIdProperty: z
215+
.object({
216+
name: z
217+
.string()
218+
.min(1)
219+
.describe(
220+
"The Swift property name for the auto-generated `@DocumentID`-annotated field. Defaults to `id`. Set this to a non-`id` value (e.g. `documentId`) when the schema's document body has a field whose Firestore key is also `id`, since the Firebase iOS SDK refuses to decode a document where the `@DocumentID` property name matches an existing body field's key."
221+
),
222+
})
223+
.strict()
224+
.optional()
225+
.describe(
226+
"Configuration for the auto-generated `@DocumentID`-annotated property that the Swift generator emits on every document-model struct. The Firebase iOS SDK populates this property from the document's path on read and excludes it from the encoded body on write."
227+
),
228+
})
229+
.strict()
230+
.describe('Swift-specific overrides for a document model.');
231+
196232
export const documentModel = z
197233
.object({
198234
model: z.literal('document').describe(`A literal field indicating that this is a 'document' model.`),
@@ -204,6 +240,9 @@ export const documentModel = z
204240
.describe(
205241
`An exact or generic path to the document. Must be a string consisting of path segments separated by a '/' (slash). Each segment can either be a literal ID or a generic ID of the collection or document. A literal ID is a plain string, such as 'users', while a generic ID must be enclosed in curly braces (e.g. '{userId}').`
206242
),
243+
swift: swiftDocumentModelOptions
244+
.optional()
245+
.describe('Per-platform overrides for the Swift generator. See `SwiftDocumentModelOptions`.'),
207246
})
208247
.strict()
209248
.describe('A document model.');

src/definition/impl/impl.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@ export interface DocumentModel {
1111
docs?: string;
1212
type: types.Object;
1313
path: string;
14+
swift?: SwiftDocumentModelOptions;
15+
}
16+
17+
/**
18+
* Swift-specific overrides for a document model. Only consumed by the Swift
19+
* generator; ignored by every other generator.
20+
*/
21+
export interface SwiftDocumentModelOptions {
22+
/**
23+
* Configuration for the auto-generated `@DocumentID`-annotated property
24+
* that the Swift generator emits on every document model struct.
25+
*/
26+
documentIdProperty?: {
27+
/**
28+
* The Swift property name for the auto-generated `@DocumentID` field.
29+
* Defaults to `id`.
30+
*
31+
* Set this to a non-`id` value (e.g. `documentId`) when the schema's
32+
* document body already has a field whose Firestore key is `id`, since
33+
* the Firebase iOS SDK refuses to decode a document where the
34+
* `@DocumentID` property name matches an existing body field's key.
35+
*/
36+
name: string;
37+
};
1438
}
1539

1640
export type Model = AliasModel | DocumentModel;

src/definition/types/_types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,23 @@ export interface ObjectField {
8181
optional?: boolean;
8282
readonly?: boolean;
8383
docs?: string;
84+
swift?: SwiftFieldOptions;
85+
}
86+
87+
/**
88+
* Swift-specific overrides for an object field. Only consumed by the Swift
89+
* generator; ignored by every other generator.
90+
*/
91+
export interface SwiftFieldOptions {
92+
/**
93+
* Overrides the Swift property name used to decode this field.
94+
*
95+
* Encoding is unaffected: the field is still serialized to Firestore under
96+
* the schema's field name (the Swift renderer routes the original name
97+
* through `CodingKeys`). Useful when the schema name collides with a Swift
98+
* keyword or with an auto-generated property such as `@DocumentID var id`.
99+
*/
100+
name?: string;
84101
}
85102

86103
export interface DiscriminatedUnion {

src/errors/generator.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,33 @@ export class MixedEnumValueTypesNotSupportedError extends SwiftGeneratorError {
1111
);
1212
}
1313
}
14+
15+
export class SwiftPropertyNameCollisionError extends SwiftGeneratorError {
16+
/**
17+
* `sources` describes where each colliding name came from. Body fields are
18+
* cited by their Firestore key (the schema field name); the auto-generated
19+
* `@DocumentID` property is cited as the literal string `@DocumentID`.
20+
*/
21+
public constructor(modelName: string, propertyName: string, sources: readonly string[]) {
22+
const involvesDocumentId = sources.includes('@DocumentID');
23+
const fieldSources = sources.filter(s => s !== '@DocumentID');
24+
const formattedSources = sources.map(n => (n === '@DocumentID' ? n : `'${n}'`)).join(', ');
25+
const remediation = involvesDocumentId
26+
? `Either rename the auto-generated \`@DocumentID\` property by adding \`swift: { documentIdProperty: { name: '<unique-name>' } }\` to the document model, or rename the conflicting field${fieldSources.length > 1 ? 's' : ''} by setting \`swift: { name: '<unique-name>' }\` on ${fieldSources.length > 1 ? 'one of them' : 'it'}.`
27+
: `Disambiguate one of them by setting \`swift: { name: '<unique-name>' }\` on the field in your schema definition.`;
28+
const causeBlurb = involvesDocumentId
29+
? `In the generated Swift struct, ${fieldSources.map(n => `field '${n}'`).join(' and ')} would have the same property name as the auto-generated \`@DocumentID\` property, which Swift does not allow. `
30+
: '';
31+
super(
32+
`Two or more properties on model '${modelName}' resolve to the same Swift property name '${propertyName}'. Conflicting source(s): ${formattedSources}. ${causeBlurb}${remediation}`
33+
);
34+
}
35+
}
36+
37+
export class SwiftDocumentIdPropertyCollidesWithFieldError extends SwiftGeneratorError {
38+
public constructor(modelName: string, fieldName: string, documentIdPropertyName: string) {
39+
super(
40+
`Document model '${modelName}' declares a body field named '${fieldName}', whose Firestore key matches the auto-generated \`@DocumentID\` property name '${documentIdPropertyName}'. The Firebase iOS SDK refuses to decode such documents because the path-derived id and the body field would clash on the same wire key. Rename the \`@DocumentID\` property by adding \`swift: { documentIdProperty: { name: '<unique-name>' } }\` to the document model in your schema definition (e.g. \`name: 'documentId'\`). The body field then keeps its current Firestore key and Swift name.`
41+
);
42+
}
43+
}

src/generators/python/_adjust-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function adjustSchemaForPython(prevSchema: schema.Schema): schema.python.
5858
optional: r.field.optional,
5959
readonly: r.field.readonly,
6060
type: r.flattenResult.flattenedType,
61+
platformOptions: r.field.platformOptions,
6162
})),
6263
additionalFields: objectType.additionalFields,
6364
};

src/generators/rules/_adjust-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export function adjustSchemaForRules(prevSchema: schema.Schema): schema.rules.Sc
3232
optional: field.optional,
3333
readonly: field.readonly,
3434
type: flattenType(field.type),
35+
platformOptions: field.platformOptions,
3536
})),
3637
additionalFields: objectType.additionalFields,
3738
};

0 commit comments

Comments
 (0)