From 6788dab7217f83ebd16e23871c9116f60085a5fc Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 21:07:26 +0300 Subject: [PATCH 1/6] Implement --- docs/schema/types.mdx | 40 ++++++++++++++ schema.local.json | 5 ++ src/converters/definition-to-schema.ts | 2 + .../zod/__tests__/build-zod-schema.test.ts | 14 ++++- .../zod/__tests__/codegen-emitter.test.ts | 12 +++++ src/core/zod/_codegen-emitter.ts | 30 +++++++++++ src/core/zod/_emitter.ts | 10 ++++ src/core/zod/_runtime-emitter.ts | 7 ++- src/core/zod/build-zod-schema.ts | 2 + src/definition/_guards.ts | 1 + src/definition/impl/_zod-schemas.ts | 19 ++++++- src/definition/types/_types.ts | 12 ++++- .../python/__tests__/generator.test.ts | 16 +++++- src/generators/python/_adjust-schema.ts | 1 + src/generators/python/_converters.ts | 6 +++ src/generators/python/_impl.ts | 54 ++++++++++++++++++- src/generators/python/_types.ts | 8 +++ .../rules/__tests__/generator.test.ts | 2 + src/generators/rules/_adjust-schema.ts | 1 + src/generators/rules/_converters.ts | 6 +++ src/generators/rules/_has-readonly-field.ts | 1 + .../rules/_readonly-field-predicates.ts | 1 + src/generators/rules/_type-predicates.ts | 6 +++ .../swift/__tests__/generator.test.ts | 2 + src/generators/swift/_adjust-schema.ts | 1 + src/generators/swift/_converters.ts | 6 +++ src/generators/swift/_impl.ts | 1 + src/generators/ts/__tests__/generator.test.ts | 2 + src/generators/ts/_converters.ts | 6 +++ .../zod/__tests__/generator.test.ts | 18 ++++--- src/generators/zod/_impl.ts | 4 +- src/generators/zod/_type-traversal.ts | 14 +++-- src/generators/zod/_types.ts | 6 +++ src/platforms/python/_expressions.ts | 13 +++++ src/platforms/python/_guards.ts | 1 + src/platforms/python/_types.ts | 12 ++++- src/platforms/rules/_guards.ts | 1 + src/platforms/rules/_types.ts | 13 ++++- src/platforms/swift/_expressions.ts | 13 +++++ src/platforms/swift/_guards.ts | 1 + src/platforms/swift/_types.ts | 10 +++- src/platforms/ts/_expressions.ts | 14 +++++ src/platforms/ts/_guards.ts | 1 + src/platforms/ts/_types.ts | 13 ++++- .../python/__tests__/renderer.test.ts | 29 ++++++++++ src/renderers/python/_impl.ts | 11 +++- .../swift/__tests__/renderer.test.ts | 38 +++++++++++++ src/renderers/swift/_impl.ts | 54 ++++++++++++++++++- src/renderers/ts/__tests__/renderer.test.ts | 21 ++++++++ src/renderers/zod/__tests__/renderer.test.ts | 32 ++++++++++- src/renderers/zod/_impl.ts | 13 +++-- .../core/__tests__/validate-type.test.ts | 8 +++ src/schema/core/_zod-schemas.ts | 10 +++- src/schema/core/types.ts | 1 + src/schema/generic.ts | 18 ++++++- src/schema/python/types.ts | 1 + src/schema/rules/types.ts | 1 + src/schema/swift/types.ts | 1 + src/schema/ts/types.ts | 1 + 59 files changed, 615 insertions(+), 32 deletions(-) diff --git a/docs/schema/types.mdx b/docs/schema/types.mdx index 86915a20..44006e8d 100644 --- a/docs/schema/types.mdx +++ b/docs/schema/types.mdx @@ -296,6 +296,46 @@ function isValidExample(data) { +## `document-reference` + +Represents a Firestore [document reference](https://firebase.google.com/docs/reference/js/firestore_.documentreference) — a pointer to another Firestore document. Use this in place of storing a document ID as a `string` when you want the field to round-trip as a native reference under each Firestore SDK. + + + Firestore only supports storing **document** references in document fields, not collection references — the stored value's path must end on a document id. The type name mirrors the SDK class `DocumentReference` to make this constraint obvious at the schema-definition layer. + + + + The `document-reference` type intentionally does not encode the target collection. Firestore itself does not enforce a target collection on stored references; if you need to enforce that contract, validate it in application code or in your security rules. + + + + +```yaml definition.yml +Example: + model: alias + type: document-reference +``` + +```ts models.ts +export type Example = firestore.DocumentReference; +``` + +```swift models.swift +typealias Example = DocumentReference +``` + +```python models.py +Example = firestore.DocumentReference +``` + +```javascript firestore.rules +function isValidExample(data) { + return (data is path); +} +``` + + + ## `literal` Represents a literal type. diff --git a/schema.local.json b/schema.local.json index cdeb8989..c78fae93 100644 --- a/schema.local.json +++ b/schema.local.json @@ -77,6 +77,11 @@ "type": "string", "const": "bytes", "description": "A bytes type." + }, + { + "type": "string", + "const": "document-reference", + "description": "A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name." } ], "description": "A primitive type" diff --git a/src/converters/definition-to-schema.ts b/src/converters/definition-to-schema.ts index 62bf386d..0b47fe32 100644 --- a/src/converters/definition-to-schema.ts +++ b/src/converters/definition-to-schema.ts @@ -22,6 +22,8 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ return { type: 'timestamp' }; case 'bytes': return { type: 'bytes' }; + case 'document-reference': + return { type: 'document-reference' }; default: assertNever(t); } diff --git a/src/core/zod/__tests__/build-zod-schema.test.ts b/src/core/zod/__tests__/build-zod-schema.test.ts index e2cd411f..0501e384 100644 --- a/src/core/zod/__tests__/build-zod-schema.test.ts +++ b/src/core/zod/__tests__/build-zod-schema.test.ts @@ -1,4 +1,4 @@ -import { Timestamp } from 'firebase-admin/firestore'; +import { DocumentReference, Timestamp } from 'firebase-admin/firestore'; import { schema } from '../../../schema/index.js'; import { buildZodSchemaMap } from '../build-zod-schema.js'; @@ -19,6 +19,7 @@ describe('buildZodSchemaMap()', () => { BoolAlias: { model: 'alias', type: 'boolean' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, BytesAlias: { model: 'alias', type: 'bytes' }, + ReferenceAlias: { model: 'alias', type: 'document-reference' }, NilAlias: { model: 'alias', type: 'nil' }, AnyAlias: { model: 'alias', type: 'any' }, UnknownAlias: { model: 'alias', type: 'unknown' }, @@ -53,6 +54,17 @@ describe('buildZodSchemaMap()', () => { expect(getSchemaForModel(s, 'BytesAlias').safeParse(new Uint8Array([1, 2, 3])).success).toBe(false); expect(getSchemaForModel(s, 'BytesAlias').safeParse('hello').success).toBe(false); }); + + it('accepts only firestore DocumentReference instances for reference', () => { + // We can't construct a real `DocumentReference` directly (its constructor + // is private in the admin typings), so we fake an object whose prototype + // chain matches the class — this mirrors how validators see refs that + // arrive via the admin SDK at runtime. + const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference; + expect(getSchemaForModel(s, 'ReferenceAlias').safeParse(fakeRef).success).toBe(true); + expect(getSchemaForModel(s, 'ReferenceAlias').safeParse({ path: 'users/abc' }).success).toBe(false); + expect(getSchemaForModel(s, 'ReferenceAlias').safeParse('users/abc').success).toBe(false); + }); }); describe('enums and literals', () => { diff --git a/src/core/zod/__tests__/codegen-emitter.test.ts b/src/core/zod/__tests__/codegen-emitter.test.ts index ad968d5e..7b5d7854 100644 --- a/src/core/zod/__tests__/codegen-emitter.test.ts +++ b/src/core/zod/__tests__/codegen-emitter.test.ts @@ -80,6 +80,18 @@ describe('createCodegenZodEmitter()', () => { expect(emit({ type: 'timestamp' }, { target: 'firebase-admin@13' })).toBe('z.instanceof(firestore.Timestamp)'); expect(emit({ type: 'timestamp' }, { target: 'firebase@10' })).toBe('z.instanceof(firestore.Timestamp)'); }); + + it('casts firestore.DocumentReference (private/strict constructor) for every supported target', () => { + // `DocumentReference` has a private/strict constructor across every + // supported SDK target, so the codegen emits a constructor-shape cast + // mirroring the `firestore.Bytes` strategy. The cast is erased at + // runtime; the instance check still runs against the real class object. + const expected = + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)'; + expect(emit({ type: 'document-reference' }, { target: 'firebase-admin@13' })).toBe(expected); + expect(emit({ type: 'document-reference' }, { target: 'firebase@10' })).toBe(expected); + expect(emit({ type: 'document-reference' }, { target: 'react-native-firebase@21' })).toBe(expected); + }); }); describe('records, arrays, and tuples', () => { diff --git a/src/core/zod/_codegen-emitter.ts b/src/core/zod/_codegen-emitter.ts index b1febe2c..d299c307 100644 --- a/src/core/zod/_codegen-emitter.ts +++ b/src/core/zod/_codegen-emitter.ts @@ -45,6 +45,7 @@ export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmi const timestampExpression = expressionForTimestampInstanceCheck(target); const bytesExpression = expressionForBytesInstanceCheck(target); + const documentReferenceExpression = expressionForDocumentReferenceInstanceCheck(target); return { any: () => 'z.any()', @@ -56,6 +57,7 @@ export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmi double: () => 'z.number()', timestamp: () => `z.instanceof(${timestampExpression})`, bytes: () => `z.instanceof(${bytesExpression})`, + documentReference: () => `z.instanceof(${documentReferenceExpression})`, stringLiteral: value => `z.literal(${JSON.stringify(value)})`, intLiteral: value => `z.literal(${value})`, @@ -178,3 +180,31 @@ function expressionForBytesInstanceCheck(target: TSGenerationTarget): string { assertNever(target); } } + +function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget): string { + switch (target) { + case 'firebase-admin@13': + case 'firebase-admin@12': + case 'firebase-admin@11': + case 'firebase-admin@10': + // The admin SDK exports `DocumentReference` as a regular class with a + // public constructor (technically the constructor is `private` in + // newer admin SDK typings too — we cast through `unknown` defensively + // so the check compiles across every supported admin major). + return 'firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference'; + case 'firebase@11': + case 'firebase@10': + case 'firebase@9': + case 'react-native-firebase@21': + case 'react-native-firebase@20': + case 'react-native-firebase@19': + // Both the modular `firebase/firestore` SDK and React Native Firebase + // declare `DocumentReference` with a private constructor, so the same + // cast trick used for `firestore.Bytes` is required here. The cast is + // erased at runtime; the instance check still runs against the real + // class object. + return 'firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference'; + default: + assertNever(target); + } +} diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts index f51c2732..d7288ca2 100644 --- a/src/core/zod/_emitter.ts +++ b/src/core/zod/_emitter.ts @@ -24,6 +24,16 @@ export interface ZodEmitter { double(): TOut; timestamp(): TOut; bytes(): TOut; + /** + * Emits a validator for a Firestore document reference value. The runtime + * emitter uses `z.instanceof(DocumentReference)`; the codegen emitter + * mirrors that against the active TypeScript Firebase SDK target. + * + * Named to disambiguate from the unrelated `reference(modelName)` below, + * which emits a reference to another model (an alias reference) in the + * generated schema. + */ + documentReference(): TOut; stringLiteral(value: string): TOut; intLiteral(value: number): TOut; diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts index d7789948..5daa0dba 100644 --- a/src/core/zod/_runtime-emitter.ts +++ b/src/core/zod/_runtime-emitter.ts @@ -1,4 +1,4 @@ -import { Timestamp } from 'firebase-admin/firestore'; +import { DocumentReference, Timestamp } from 'firebase-admin/firestore'; import { z } from 'zod'; import type { ZodEmitter } from './_emitter.js'; @@ -28,6 +28,11 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte double: () => z.number(), timestamp: () => z.instanceof(Timestamp), bytes: () => z.instanceof(Buffer), + // `DocumentReference` declares a private constructor in the admin SDK's + // typings, which violates the `new (...args: any[]) => any` constraint + // `z.instanceof` enforces. The cast is erased at runtime; the instance + // check still runs against the real class object. + documentReference: () => z.instanceof(DocumentReference as unknown as new (...args: never[]) => DocumentReference), stringLiteral: value => z.literal(value), intLiteral: value => z.literal(value), diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts index 8580e137..42a0cda9 100644 --- a/src/core/zod/build-zod-schema.ts +++ b/src/core/zod/build-zod-schema.ts @@ -31,6 +31,8 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit return emitter.timestamp(); case 'bytes': return emitter.bytes(); + case 'document-reference': + return emitter.documentReference(); case 'string-literal': return emitter.stringLiteral(type.value); case 'int-literal': diff --git a/src/definition/_guards.ts b/src/definition/_guards.ts index be684aa7..09c5f32e 100644 --- a/src/definition/_guards.ts +++ b/src/definition/_guards.ts @@ -13,6 +13,7 @@ export function isPrimitiveType(candidate: unknown): candidate is types.Primitiv case 'double': case 'timestamp': case 'bytes': + case 'document-reference': return true; default: assertNeverNoThrow(c); diff --git a/src/definition/impl/_zod-schemas.ts b/src/definition/impl/_zod-schemas.ts index 71a9eb5a..90f31793 100644 --- a/src/definition/impl/_zod-schemas.ts +++ b/src/definition/impl/_zod-schemas.ts @@ -20,8 +20,25 @@ export const timestampType = z.literal('timestamp').describe('A timestamp type.' export const bytesType = z.literal('bytes').describe('A bytes type.'); +export const documentReferenceType = z + .literal('document-reference') + .describe( + 'A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name.' + ); + export const primitiveType = z - .union([anyType, unknownType, nilType, stringType, booleanType, intType, doubleType, timestampType, bytesType]) + .union([ + anyType, + unknownType, + nilType, + stringType, + booleanType, + intType, + doubleType, + timestampType, + bytesType, + documentReferenceType, + ]) .describe('A primitive type'); export const stringLiteralType = z diff --git a/src/definition/types/_types.ts b/src/definition/types/_types.ts index 4878dcdb..80637720 100644 --- a/src/definition/types/_types.ts +++ b/src/definition/types/_types.ts @@ -16,7 +16,17 @@ export type Timestamp = 'timestamp'; export type Bytes = 'bytes'; -export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes; +/** + * A Firestore document reference value. Use this when a field stores a + * pointer to another document (e.g. `users/alice`) directly, rather than + * the document's id as a `string`. + * + * Firestore only allows storing document references in fields (not collection + * references), so the name explicitly mirrors the SDK class `DocumentReference`. + */ +export type DocumentReference = 'document-reference'; + +export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference; export interface StringLiteral { type: 'literal'; diff --git a/src/generators/python/__tests__/generator.test.ts b/src/generators/python/__tests__/generator.test.ts index 7aec1c8a..f031278b 100644 --- a/src/generators/python/__tests__/generator.test.ts +++ b/src/generators/python/__tests__/generator.test.ts @@ -8,7 +8,19 @@ function createGenerator() { describe('PythonGeneratorImpl', () => { it('produces an empty generation for an empty schema', () => { const generation = createGenerator().generate(schema.createSchema()); - expect(generation).toEqual({ type: 'python', declarations: [] }); + expect(generation).toEqual({ type: 'python', declarations: [], usesDocumentReference: false }); + }); + + it('flags `usesDocumentReference` only when the schema references the Firestore document-reference type', () => { + const withoutReference = schema.createSchemaFromDefinition({ + Plain: { model: 'alias', type: 'string' }, + }); + const withReference = schema.createSchemaFromDefinition({ + OwnerRef: { model: 'alias', type: 'document-reference' }, + }); + + expect(createGenerator().generate(withoutReference).usesDocumentReference).toBe(false); + expect(createGenerator().generate(withReference).usesDocumentReference).toBe(true); }); it('emits an alias declaration for each primitive alias model with the correct Python type', () => { @@ -22,6 +34,7 @@ describe('PythonGeneratorImpl', () => { DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, BytesAlias: { model: 'alias', type: 'bytes' }, + ReferenceAlias: { model: 'alias', type: 'document-reference' }, }); const generation = createGenerator().generate(s); @@ -36,6 +49,7 @@ describe('PythonGeneratorImpl', () => { DoubleAlias: { type: 'float' }, TimestampAlias: { type: 'datetime' }, BytesAlias: { type: 'bytes' }, + ReferenceAlias: { type: 'document-reference' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedPythonTypeByModelName).length); diff --git a/src/generators/python/_adjust-schema.ts b/src/generators/python/_adjust-schema.ts index 042b50b7..1cedc1bb 100644 --- a/src/generators/python/_adjust-schema.ts +++ b/src/generators/python/_adjust-schema.ts @@ -115,6 +115,7 @@ export function adjustSchemaForPython(prevSchema: schema.Schema): schema.python. case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/python/_converters.ts b/src/generators/python/_converters.ts index 267e491a..9542d6c6 100644 --- a/src/generators/python/_converters.ts +++ b/src/generators/python/_converters.ts @@ -38,6 +38,10 @@ export function bytesTypeToPython(_t: schema.python.types.Bytes): python.Bytes { return { type: 'bytes' }; } +export function documentReferenceTypeToPython(_t: schema.python.types.DocumentReference): python.DocumentReference { + return { type: 'document-reference' }; +} + export function literalTypeToPython(t: schema.python.types.Literal): python.Literal { return { type: 'literal', value: t.value }; } @@ -88,6 +92,8 @@ export function flatTypeToPython(t: schema.python.types.Type): python.Type { return timestampTypeToPython(t); case 'bytes': return bytesTypeToPython(t); + case 'document-reference': + return documentReferenceTypeToPython(t); case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/python/_impl.ts b/src/generators/python/_impl.ts index 3b83d184..c2927a78 100644 --- a/src/generators/python/_impl.ts +++ b/src/generators/python/_impl.ts @@ -28,7 +28,14 @@ class PythonGeneratorImpl implements PythonGenerator { const d = this.createDeclarationForDocumentModel(model); declarations.push(d); }); - return { type: 'python', declarations }; + // Used by the renderer to decide whether to emit + // `from google.cloud import firestore`. We walk the source schema rather + // than the adjusted schema so the check is independent of the alias + // extraction that happens inside `adjustSchemaForPython`. + const usesDocumentReference = + s.aliasModels.some(m => schemaTypeUsesDocumentReference(m.type)) || + s.documentModels.some(m => schemaTypeUsesDocumentReference(m.type)); + return { type: 'python', declarations, usesDocumentReference }; } private createDeclarationForAliasModel(model: schema.python.AliasModel): PythonDeclaration { @@ -42,6 +49,7 @@ class PythonGeneratorImpl implements PythonGenerator { case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': @@ -128,3 +136,47 @@ class PythonGeneratorImpl implements PythonGenerator { export function createPythonGenerator(config: PythonGeneratorConfig): PythonGenerator { return new PythonGeneratorImpl(config); } + +/** + * Walks a Typesync schema type tree and reports whether any node is the + * `document-reference` primitive. Alias references are intentionally not + * followed — each alias model's own type is visited separately by the caller, + * so any `document-reference` usage is eventually seen at its definition site + * regardless of how many alias references point at it. + */ +function schemaTypeUsesDocumentReference(type: schema.types.Type): boolean { + switch (type.type) { + case 'any': + case 'unknown': + case 'nil': + case 'string': + case 'boolean': + case 'int': + case 'double': + case 'timestamp': + case 'bytes': + case 'string-literal': + case 'int-literal': + case 'boolean-literal': + case 'string-enum': + case 'int-enum': + case 'alias': + return false; + case 'document-reference': + return true; + case 'tuple': + return type.elements.some(schemaTypeUsesDocumentReference); + case 'list': + return schemaTypeUsesDocumentReference(type.elementType); + case 'map': + return schemaTypeUsesDocumentReference(type.valueType); + case 'object': + return type.fields.some(f => schemaTypeUsesDocumentReference(f.type)); + case 'simple-union': + return type.variants.some(schemaTypeUsesDocumentReference); + case 'discriminated-union': + return type.variants.some(schemaTypeUsesDocumentReference); + default: + assertNever(type); + } +} diff --git a/src/generators/python/_types.ts b/src/generators/python/_types.ts index 10c0d411..246ac5e6 100644 --- a/src/generators/python/_types.ts +++ b/src/generators/python/_types.ts @@ -28,6 +28,14 @@ export type PythonDeclaration = PythonAliasDeclaration | PythonEnumClassDeclarat export interface PythonGeneration { type: 'python'; declarations: PythonDeclaration[]; + /** + * Whether the generated file references the Firestore `DocumentReference` + * class anywhere. The renderer uses this to decide whether to emit the + * `from google.cloud import firestore` import. We avoid emitting that + * import unconditionally because not every Typesync user pulls in + * `google-cloud-firestore` (it is a transitive dep of `firebase-admin`). + */ + usesDocumentReference: boolean; } export interface PythonGeneratorConfig { diff --git a/src/generators/rules/__tests__/generator.test.ts b/src/generators/rules/__tests__/generator.test.ts index 3ed60254..b1df7405 100644 --- a/src/generators/rules/__tests__/generator.test.ts +++ b/src/generators/rules/__tests__/generator.test.ts @@ -29,6 +29,7 @@ describe('RulesGeneratorImpl', () => { ADouble: { model: 'alias', type: 'double' }, ATs: { model: 'alias', type: 'timestamp' }, ABytes: { model: 'alias', type: 'bytes' }, + ARef: { model: 'alias', type: 'document-reference' }, }); const generation = createGenerator().generate(s); @@ -40,6 +41,7 @@ describe('RulesGeneratorImpl', () => { ADouble: { type: 'number' }, ATs: { type: 'timestamp' }, ABytes: { type: 'bytes' }, + ARef: { type: 'path' }, }; expect(generation.typeValidatorDeclarations).toHaveLength(Object.keys(expectedRulesTypeByModelName).length); diff --git a/src/generators/rules/_adjust-schema.ts b/src/generators/rules/_adjust-schema.ts index 3e5b5d5e..6d03854e 100644 --- a/src/generators/rules/_adjust-schema.ts +++ b/src/generators/rules/_adjust-schema.ts @@ -75,6 +75,7 @@ export function adjustSchemaForRules(prevSchema: schema.Schema): schema.rules.Sc case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_converters.ts b/src/generators/rules/_converters.ts index 71221374..66f1384f 100644 --- a/src/generators/rules/_converters.ts +++ b/src/generators/rules/_converters.ts @@ -38,6 +38,10 @@ export function bytesTypeToRules(_t: schema.rules.types.Bytes): rules.Bytes { return { type: 'bytes' }; } +export function documentReferenceTypeToRules(_t: schema.rules.types.DocumentReference): rules.Path { + return { type: 'path' }; +} + export function stringLiteralTypeToRules(t: schema.rules.types.StringLiteral): rules.Literal { return { type: 'literal', value: t.value }; } @@ -127,6 +131,8 @@ export function flatTypeToRules(t: schema.rules.types.Type): rules.Type { return timestampTypeToRules(t); case 'bytes': return bytesTypeToRules(t); + case 'document-reference': + return documentReferenceTypeToRules(t); case 'string-literal': return stringLiteralTypeToRules(t); case 'int-literal': diff --git a/src/generators/rules/_has-readonly-field.ts b/src/generators/rules/_has-readonly-field.ts index e229499a..a35eef69 100644 --- a/src/generators/rules/_has-readonly-field.ts +++ b/src/generators/rules/_has-readonly-field.ts @@ -12,6 +12,7 @@ export function typeHasReadonlyField(t: schema.rules.types.Type, adjustedSchema: case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_readonly-field-predicates.ts b/src/generators/rules/_readonly-field-predicates.ts index 3fbcf5bc..83db028e 100644 --- a/src/generators/rules/_readonly-field-predicates.ts +++ b/src/generators/rules/_readonly-field-predicates.ts @@ -27,6 +27,7 @@ export function readonlyFieldPredicateForType( case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/rules/_type-predicates.ts b/src/generators/rules/_type-predicates.ts index a73a7ad1..f29d0bdf 100644 --- a/src/generators/rules/_type-predicates.ts +++ b/src/generators/rules/_type-predicates.ts @@ -33,6 +33,10 @@ export function typePredicateForBytesType(t: rules.Bytes, varName: string): rule return { type: 'type-equality', varName, varType: t }; } +export function typePredicateForPathType(t: rules.Path, varName: string): rules.Predicate { + return { type: 'type-equality', varName, varType: t }; +} + export function typePredicateForLiteralType(t: rules.Literal, varName: string): rules.Predicate { return { type: 'value-equality', varName, varValue: typeof t.value === 'string' ? `'${t.value}'` : `${t.value}` }; } @@ -174,6 +178,8 @@ export function typePredicateForType(t: rules.Type, varName: string, ctx: Contex return typePredicateForTimestampType(t, varName); case 'bytes': return typePredicateForBytesType(t, varName); + case 'path': + return typePredicateForPathType(t, varName); case 'literal': return typePredicateForLiteralType(t, varName); case 'enum': diff --git a/src/generators/swift/__tests__/generator.test.ts b/src/generators/swift/__tests__/generator.test.ts index 3d398dfb..cfde25de 100644 --- a/src/generators/swift/__tests__/generator.test.ts +++ b/src/generators/swift/__tests__/generator.test.ts @@ -26,6 +26,7 @@ describe('SwiftGeneratorImpl', () => { DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, BytesAlias: { model: 'alias', type: 'bytes' }, + ReferenceAlias: { model: 'alias', type: 'document-reference' }, }); const generation = createGenerator().generate(s); @@ -40,6 +41,7 @@ describe('SwiftGeneratorImpl', () => { DoubleAlias: { type: 'double' }, TimestampAlias: { type: 'date' }, BytesAlias: { type: 'data' }, + ReferenceAlias: { type: 'document-reference' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedSwiftTypeByModelName).length); diff --git a/src/generators/swift/_adjust-schema.ts b/src/generators/swift/_adjust-schema.ts index e2e44c1b..c0272eea 100644 --- a/src/generators/swift/_adjust-schema.ts +++ b/src/generators/swift/_adjust-schema.ts @@ -115,6 +115,7 @@ export function adjustSchemaForSwift(prevSchema: schema.Schema): schema.swift.Sc case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/swift/_converters.ts b/src/generators/swift/_converters.ts index 111ef8f6..6d6677eb 100644 --- a/src/generators/swift/_converters.ts +++ b/src/generators/swift/_converters.ts @@ -38,6 +38,10 @@ export function bytesTypeToSwift(_t: schema.swift.types.Bytes): swift.Data { return { type: 'data' }; } +export function documentReferenceTypeToSwift(_t: schema.swift.types.DocumentReference): swift.DocumentReference { + return { type: 'document-reference' }; +} + export function stringLiteralTypeToSwift(_t: schema.swift.types.StringLiteral): swift.String { return { type: 'string' }; } @@ -99,6 +103,8 @@ export function flatTypeToSwift(t: schema.swift.types.Type): swift.Type { return timestampTypeToSwift(t); case 'bytes': return bytesTypeToSwift(t); + case 'document-reference': + return documentReferenceTypeToSwift(t); case 'string-literal': return stringLiteralTypeToSwift(t); case 'int-literal': diff --git a/src/generators/swift/_impl.ts b/src/generators/swift/_impl.ts index 23aca336..1d38b178 100644 --- a/src/generators/swift/_impl.ts +++ b/src/generators/swift/_impl.ts @@ -63,6 +63,7 @@ class SwiftGeneratorImpl implements SwiftGenerator { case 'double': case 'timestamp': case 'bytes': + case 'document-reference': case 'string-literal': case 'int-literal': case 'boolean-literal': diff --git a/src/generators/ts/__tests__/generator.test.ts b/src/generators/ts/__tests__/generator.test.ts index 0822914b..b9548015 100644 --- a/src/generators/ts/__tests__/generator.test.ts +++ b/src/generators/ts/__tests__/generator.test.ts @@ -23,6 +23,7 @@ describe('TSGeneratorImpl', () => { DoubleAlias: { model: 'alias', type: 'double' }, TimestampAlias: { model: 'alias', type: 'timestamp' }, BytesAlias: { model: 'alias', type: 'bytes' }, + ReferenceAlias: { model: 'alias', type: 'document-reference' }, }); const generation = createGenerator().generate(s); @@ -37,6 +38,7 @@ describe('TSGeneratorImpl', () => { DoubleAlias: { type: 'number' }, TimestampAlias: { type: 'timestamp' }, BytesAlias: { type: 'bytes' }, + ReferenceAlias: { type: 'document-reference' }, }; expect(generation.declarations).toHaveLength(Object.keys(expectedTsTypeByModelName).length); diff --git a/src/generators/ts/_converters.ts b/src/generators/ts/_converters.ts index 886efbe0..2fb60091 100644 --- a/src/generators/ts/_converters.ts +++ b/src/generators/ts/_converters.ts @@ -38,6 +38,10 @@ export function bytesTypeToTS(_t: schema.ts.types.Bytes): ts.Bytes { return { type: 'bytes' }; } +export function documentReferenceTypeToTS(_t: schema.ts.types.DocumentReference): ts.DocumentReference { + return { type: 'document-reference' }; +} + export function stringLiteralTypeToTS(t: schema.ts.types.StringLiteral): ts.Literal { return { type: 'literal', value: t.value }; } @@ -110,6 +114,8 @@ export function typeToTS(t: schema.ts.types.Type): ts.Type { return timestampTypeToTS(t); case 'bytes': return bytesTypeToTS(t); + case 'document-reference': + return documentReferenceTypeToTS(t); case 'string-literal': return stringLiteralTypeToTS(t); case 'int-literal': diff --git a/src/generators/zod/__tests__/generator.test.ts b/src/generators/zod/__tests__/generator.test.ts index 4054d86a..174956e4 100644 --- a/src/generators/zod/__tests__/generator.test.ts +++ b/src/generators/zod/__tests__/generator.test.ts @@ -28,6 +28,7 @@ describe('ZodGeneratorImpl', () => { declarations: [], usesTimestamp: false, usesBytes: false, + usesDocumentReference: false, }); }); @@ -163,22 +164,25 @@ describe('ZodGeneratorImpl', () => { expect(profile?.expression).toContain(`bio: z.string().optional()`); }); - it('reports usesTimestamp/usesBytes accurately by walking every model type', () => { - const sBoth = schema.createSchemaFromDefinition({ + it('reports usesTimestamp/usesBytes/usesDocumentReference accurately by walking every model type', () => { + const sAll = schema.createSchemaFromDefinition({ Stamp: { model: 'alias', type: 'timestamp' }, Blob: { model: 'alias', type: 'bytes' }, + OwnerRef: { model: 'alias', type: 'document-reference' }, }); - const gBoth = createGenerator().generate(sBoth); - expect(gBoth.usesTimestamp).toBe(true); - expect(gBoth.usesBytes).toBe(true); + const gAll = createGenerator().generate(sAll); + expect(gAll.usesTimestamp).toBe(true); + expect(gAll.usesBytes).toBe(true); + expect(gAll.usesDocumentReference).toBe(true); const sNone = schema.createSchemaFromDefinition({ Name: { model: 'alias', type: 'string' } }); const gNone = createGenerator().generate(sNone); expect(gNone.usesTimestamp).toBe(false); expect(gNone.usesBytes).toBe(false); + expect(gNone.usesDocumentReference).toBe(false); }); - it('detects bytes/timestamp usage even when nested inside lists/maps/objects', () => { + it('detects bytes/timestamp/document-reference usage even when nested inside lists/maps/objects', () => { const s = schema.createSchemaFromDefinition({ Doc: { model: 'document', @@ -188,6 +192,7 @@ describe('ZodGeneratorImpl', () => { fields: { createdAt: { type: 'timestamp' }, attachments: { type: { type: 'list', elementType: 'bytes' } }, + owners: { type: { type: 'map', valueType: 'document-reference' } }, }, }, }, @@ -195,6 +200,7 @@ describe('ZodGeneratorImpl', () => { const generation = createGenerator().generate(s); expect(generation.usesTimestamp).toBe(true); expect(generation.usesBytes).toBe(true); + expect(generation.usesDocumentReference).toBe(true); }); it('does not mutate the input schema', () => { diff --git a/src/generators/zod/_impl.ts b/src/generators/zod/_impl.ts index bf3f535d..d7720237 100644 --- a/src/generators/zod/_impl.ts +++ b/src/generators/zod/_impl.ts @@ -1,7 +1,7 @@ import { ZOD_INFERRED_TYPE_NAME_PATTERN_PARAM, ZOD_SCHEMA_NAME_PATTERN_PARAM } from '../../constants.js'; import { buildZodFromType, createCodegenZodEmitter } from '../../core/zod/index.js'; import { schema } from '../../schema/index.js'; -import { typeUsesBytes, typeUsesTimestamp } from './_type-traversal.js'; +import { typeUsesBytes, typeUsesDocumentReference, typeUsesTimestamp } from './_type-traversal.js'; import type { ZodGeneration, ZodGenerator, ZodGeneratorConfig, ZodSchemaDeclaration } from './_types.js'; class ZodGeneratorImpl implements ZodGenerator { @@ -26,12 +26,14 @@ class ZodGeneratorImpl implements ZodGenerator { const usesTimestamp = this.schemaUses(s, typeUsesTimestamp); const usesBytes = this.schemaUses(s, typeUsesBytes); + const usesDocumentReference = this.schemaUses(s, typeUsesDocumentReference); return { type: 'zod', declarations, usesTimestamp, usesBytes, + usesDocumentReference, }; } diff --git a/src/generators/zod/_type-traversal.ts b/src/generators/zod/_type-traversal.ts index 80c6f3f6..38924d80 100644 --- a/src/generators/zod/_type-traversal.ts +++ b/src/generators/zod/_type-traversal.ts @@ -7,11 +7,11 @@ import { assertNever } from '../../util/assert.js'; * the rendered file needs to import the Firestore SDK at all. * * Alias references are intentionally not followed: each alias model's own type - * is walked separately by the caller, so any `timestamp`/`bytes` usage is - * eventually visited at its definition site regardless of how many references - * point at it. + * is walked separately by the caller, so any `timestamp`/`bytes`/`document-reference` + * usage is eventually visited at its definition site regardless of how many + * alias references point at it. */ -function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boolean { +function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes' | 'document-reference'): boolean { switch (type.type) { case 'any': case 'unknown': @@ -31,6 +31,8 @@ function typeContains(type: schema.types.Type, kind: 'timestamp' | 'bytes'): boo return kind === 'timestamp'; case 'bytes': return kind === 'bytes'; + case 'document-reference': + return kind === 'document-reference'; case 'tuple': return type.elements.some(el => typeContains(el, kind)); case 'list': @@ -55,3 +57,7 @@ export function typeUsesTimestamp(type: schema.types.Type): boolean { export function typeUsesBytes(type: schema.types.Type): boolean { return typeContains(type, 'bytes'); } + +export function typeUsesDocumentReference(type: schema.types.Type): boolean { + return typeContains(type, 'document-reference'); +} diff --git a/src/generators/zod/_types.ts b/src/generators/zod/_types.ts index 20958847..e039d44f 100644 --- a/src/generators/zod/_types.ts +++ b/src/generators/zod/_types.ts @@ -44,6 +44,12 @@ export interface ZodGeneration { * (`Buffer` for the admin SDK; `firestore.Bytes`/`firestore.Blob` otherwise). */ usesBytes: boolean; + /** + * Whether the generated file references the Firestore `DocumentReference` + * class anywhere. The renderer pairs this with `usesTimestamp`/`usesBytes` + * when deciding whether the Firestore SDK import is needed. + */ + usesDocumentReference: boolean; } export interface ZodGeneratorConfig { diff --git a/src/platforms/python/_expressions.ts b/src/platforms/python/_expressions.ts index 4e8ee944..3778e68d 100644 --- a/src/platforms/python/_expressions.ts +++ b/src/platforms/python/_expressions.ts @@ -8,6 +8,7 @@ import type { Datetime, Dict, DiscriminatedUnion, + DocumentReference, Float, Int, List, @@ -60,6 +61,16 @@ export function expressionForBytesType(_t: Bytes): Expression { return { content: 'bytes' }; } +/** + * The Python Firestore SDK exposes document references as + * `google.cloud.firestore.DocumentReference`. We render this as + * `firestore.DocumentReference` and rely on the renderer to add a + * matching `from google.cloud import firestore` import. + */ +export function expressionForDocumentReferenceType(_t: DocumentReference): Expression { + return { content: 'firestore.DocumentReference' }; +} + export function expressionForLiteralType(t: Literal): Expression { switch (typeof t.value) { case 'string': @@ -124,6 +135,8 @@ export function expressionForType(t: Type): Expression { return expressionForDatetimeType(t); case 'bytes': return expressionForBytesType(t); + case 'document-reference': + return expressionForDocumentReferenceType(t); case 'literal': return expressionForLiteralType(t); case 'tuple': diff --git a/src/platforms/python/_guards.ts b/src/platforms/python/_guards.ts index db0206ba..e556e5a3 100644 --- a/src/platforms/python/_guards.ts +++ b/src/platforms/python/_guards.ts @@ -10,6 +10,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'bool': case 'datetime': case 'bytes': + case 'document-reference': case 'int': case 'float': return true; diff --git a/src/platforms/python/_types.ts b/src/platforms/python/_types.ts index 1dbbe2fa..1720a9dc 100644 --- a/src/platforms/python/_types.ts +++ b/src/platforms/python/_types.ts @@ -34,7 +34,17 @@ export interface Bytes { readonly type: 'bytes'; } -export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes; +/** + * A Firestore document reference. Maps to `firestore.DocumentReference` in + * the generated Python output (i.e. `google.cloud.firestore.DocumentReference`, + * which is what `firebase_admin.firestore.client()` hands back). Firestore + * only allows storing document references in fields, hence the explicit name. + */ +export interface DocumentReference { + readonly type: 'document-reference'; +} + +export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes | DocumentReference; export interface Literal { readonly type: 'literal'; diff --git a/src/platforms/rules/_guards.ts b/src/platforms/rules/_guards.ts index 1c15b8db..f647d8fe 100644 --- a/src/platforms/rules/_guards.ts +++ b/src/platforms/rules/_guards.ts @@ -10,6 +10,7 @@ export function isRulesDataType(t: Type): t is RulesDataType { case 'number': case 'timestamp': case 'bytes': + case 'path': case 'list': case 'map': return true; diff --git a/src/platforms/rules/_types.ts b/src/platforms/rules/_types.ts index a26c886b..76af9dc2 100644 --- a/src/platforms/rules/_types.ts +++ b/src/platforms/rules/_types.ts @@ -30,7 +30,16 @@ export interface Bytes { readonly type: 'bytes'; } -export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes; +/** + * A Firestore document reference. The Cloud Firestore Security Rules + * language exposes document references as the built-in `path` type, so we + * mirror that name here. + */ +export interface Path { + readonly type: 'path'; +} + +export type Primitive = Any | String | Bool | Float | Int | Number | Timestamp | Bytes | Path; export interface Literal { readonly type: 'literal'; @@ -87,4 +96,4 @@ export interface Alias { export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | DiscriminatedUnion | SimpleUnion | Alias; -export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | List | Map; +export type RulesDataType = String | Bool | Float | Int | Number | Timestamp | Bytes | Path | List | Map; diff --git a/src/platforms/swift/_expressions.ts b/src/platforms/swift/_expressions.ts index 2f65a8e9..f561662a 100644 --- a/src/platforms/swift/_expressions.ts +++ b/src/platforms/swift/_expressions.ts @@ -6,6 +6,7 @@ import type { Data, Date, Dictionary, + DocumentReference, Double, Int, List, @@ -51,6 +52,16 @@ export function expressionForDataType(_t: Data): Expression { return { content: 'Data' }; } +/** + * Firestore document references are exposed by the iOS SDK as + * `FirebaseFirestore.DocumentReference`. The renderer adds the + * `import FirebaseFirestore` whenever a reference appears in the + * generation, which lets us emit the bare type name here. + */ +export function expressionForDocumentReferenceType(_t: DocumentReference): Expression { + return { content: 'DocumentReference' }; +} + export function expressionForTupleType(t: Tuple): Expression { const commaSeparatedExpressions = t.elements.map(vt => expressionForType(vt).content).join(', '); return { content: `(${commaSeparatedExpressions})` }; @@ -88,6 +99,8 @@ export function expressionForType(t: Type): Expression { return expressionForDateType(t); case 'data': return expressionForDataType(t); + case 'document-reference': + return expressionForDocumentReferenceType(t); case 'tuple': return expressionForTupleType(t); case 'list': diff --git a/src/platforms/swift/_guards.ts b/src/platforms/swift/_guards.ts index ccf01c71..fd79b71c 100644 --- a/src/platforms/swift/_guards.ts +++ b/src/platforms/swift/_guards.ts @@ -11,6 +11,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'double': case 'date': case 'data': + case 'document-reference': return true; case 'tuple': case 'list': diff --git a/src/platforms/swift/_types.ts b/src/platforms/swift/_types.ts index 4cfb3244..8df98517 100644 --- a/src/platforms/swift/_types.ts +++ b/src/platforms/swift/_types.ts @@ -30,7 +30,15 @@ export interface Data { readonly type: 'data'; } -export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data; +/** + * A Firestore document reference. Maps to `DocumentReference` (from + * `FirebaseFirestore`) in the generated Swift output. + */ +export interface DocumentReference { + readonly type: 'document-reference'; +} + +export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data | DocumentReference; export interface Tuple { readonly type: 'tuple'; diff --git a/src/platforms/ts/_expressions.ts b/src/platforms/ts/_expressions.ts index 63e9964b..50211042 100644 --- a/src/platforms/ts/_expressions.ts +++ b/src/platforms/ts/_expressions.ts @@ -7,6 +7,7 @@ import type { Any, Boolean, Bytes, + DocumentReference, Enum, List, Literal, @@ -87,6 +88,17 @@ export function expressionForBytesType(_t: Bytes, options: ExpressionOptions): E } } +/** + * The Firestore SDKs all expose document references as `DocumentReference` + * under the `firestore` import, so we emit the same expression for every + * target. The reference is parameterized by `firestore.DocumentData` so the + * type lines up with what the SDKs hand back when no per-collection converter + * is in play. + */ +export function expressionForDocumentReferenceType(_t: DocumentReference): Expression { + return { content: 'firestore.DocumentReference' }; +} + export function expressionForLiteralType(t: Literal): Expression { switch (typeof t.value) { case 'string': @@ -178,6 +190,8 @@ export function expressionForType(t: Type, options: ExpressionOptions): Expressi return expressionForTimestampType(t); case 'bytes': return expressionForBytesType(t, options); + case 'document-reference': + return expressionForDocumentReferenceType(t); case 'literal': return expressionForLiteralType(t); case 'enum': diff --git a/src/platforms/ts/_guards.ts b/src/platforms/ts/_guards.ts index db4e0953..4289e698 100644 --- a/src/platforms/ts/_guards.ts +++ b/src/platforms/ts/_guards.ts @@ -11,6 +11,7 @@ export function isPrimitiveType(t: Type): t is Primitive { case 'number': case 'timestamp': case 'bytes': + case 'document-reference': return true; case 'literal': case 'enum': diff --git a/src/platforms/ts/_types.ts b/src/platforms/ts/_types.ts index 675cf1bb..46e82218 100644 --- a/src/platforms/ts/_types.ts +++ b/src/platforms/ts/_types.ts @@ -30,7 +30,18 @@ export interface Bytes { readonly type: 'bytes'; } -export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes; +/** + * A Firestore document reference (the `firestore.DocumentReference` runtime + * class). Maps to `firestore.DocumentReference` in + * the generated TypeScript output regardless of the active Firebase SDK + * target. Firestore only supports document references as stored values, so + * the type intentionally mirrors the SDK class name. + */ +export interface DocumentReference { + readonly type: 'document-reference'; +} + +export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes | DocumentReference; export interface Literal { readonly type: 'literal'; diff --git a/src/renderers/python/__tests__/renderer.test.ts b/src/renderers/python/__tests__/renderer.test.ts index e3b4c7a2..a5caf06a 100644 --- a/src/renderers/python/__tests__/renderer.test.ts +++ b/src/renderers/python/__tests__/renderer.test.ts @@ -17,6 +17,7 @@ describe('PythonRendererImpl', () => { it('renders alias declarations covering every type expression with model docs', async () => { const generation: PythonGeneration = { type: 'python', + usesDocumentReference: false, declarations: [ { type: 'alias', @@ -72,6 +73,7 @@ describe('PythonRendererImpl', () => { it('renders enum-class declarations with each member as a class attribute', async () => { const generation: PythonGeneration = { type: 'python', + usesDocumentReference: false, declarations: [ { type: 'enum-class', @@ -107,6 +109,7 @@ describe('PythonRendererImpl', () => { it('renders pydantic-class declarations with optional fields, field docs, and additionalAttributes Config flag', async () => { const generation: PythonGeneration = { type: 'python', + usesDocumentReference: false, declarations: [ { type: 'pydantic-class', @@ -131,6 +134,7 @@ describe('PythonRendererImpl', () => { it('uses the configured undefined sentinel name across the static declarations and optional field defaults', async () => { const generation: PythonGeneration = { type: 'python', + usesDocumentReference: false, declarations: [ { type: 'pydantic-class', @@ -149,9 +153,34 @@ describe('PythonRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/custom-undefined-sentinel.py'); }); + it('imports `firestore` from google.cloud only when `usesDocumentReference` is true', async () => { + const baseDeclaration = { + type: 'alias' as const, + modelName: 'OwnerRef', + modelType: { type: 'document-reference' as const }, + modelDocs: null, + }; + + const withoutReference = await createRenderer().render({ + type: 'python', + usesDocumentReference: false, + declarations: [], + }); + expect(withoutReference.content).not.toContain('from google.cloud import firestore'); + + const withReference = await createRenderer().render({ + type: 'python', + usesDocumentReference: true, + declarations: [baseDeclaration], + }); + expect(withReference.content).toContain('from google.cloud import firestore'); + expect(withReference.content).toContain('OwnerRef = firestore.DocumentReference'); + }); + it('uses a custom Pydantic base class import and parent when customPydanticBase is provided', async () => { const generation: PythonGeneration = { type: 'python', + usesDocumentReference: false, declarations: [ { type: 'pydantic-class', diff --git a/src/renderers/python/_impl.ts b/src/renderers/python/_impl.ts index e07c14d1..d5bd2612 100644 --- a/src/renderers/python/_impl.ts +++ b/src/renderers/python/_impl.ts @@ -23,7 +23,7 @@ class PythonRendererImpl implements PythonRenderer { public async render(g: PythonGeneration): Promise { const b = new StringBuilder(); - b.append(`${this.generateImportStatements()}\n\n`); + b.append(`${this.generateImportStatements(g)}\n\n`); b.append(`${this.generateStaticDeclarations()}\n\n`); b.append(`# Model Definitions\n\n`); @@ -38,7 +38,7 @@ class PythonRendererImpl implements PythonRenderer { return rootFile; } - private generateImportStatements() { + private generateImportStatements(g: PythonGeneration) { const b = new StringBuilder(); b.append(`from __future__ import annotations\n\n`); b.append(`import typing\n`); @@ -47,6 +47,13 @@ class PythonRendererImpl implements PythonRenderer { b.append(`import pydantic\n`); b.append(`from pydantic_core import core_schema\n`); b.append(`from typing_extensions import Annotated`); + if (g.usesDocumentReference) { + // `google-cloud-firestore` is a transitive dep of `firebase-admin`, so any + // Typesync user generating Python models for Firestore already has it + // installed. Importing it only when the schema actually contains a + // `document-reference` field keeps the generated file lean for everyone else. + b.append(`\nfrom google.cloud import firestore`); + } if (this.config.customPydanticBase) { const { importPath, className } = this.config.customPydanticBase; b.append(`\nfrom ${importPath} import ${className}`); diff --git a/src/renderers/swift/__tests__/renderer.test.ts b/src/renderers/swift/__tests__/renderer.test.ts index 3f6eda43..c7e2e616 100644 --- a/src/renderers/swift/__tests__/renderer.test.ts +++ b/src/renderers/swift/__tests__/renderer.test.ts @@ -158,6 +158,44 @@ describe('SwiftRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-id-struct.swift'); }); + it('imports FirebaseFirestore when any property resolves to DocumentReference (even nested in a list)', async () => { + const baseGeneration = (modelType: SwiftGeneration['declarations'][number]): SwiftGeneration => ({ + type: 'swift', + declarations: [modelType], + }); + + const withDirectRef = await createRenderer().render( + baseGeneration({ + type: 'typealias', + modelName: 'OwnerRef', + modelDocs: null, + modelType: { type: 'document-reference' }, + }) + ); + expect(withDirectRef.content).toContain('import FirebaseFirestore'); + expect(withDirectRef.content).toContain('typealias OwnerRef = DocumentReference'); + + const withListOfRefs = await createRenderer().render( + baseGeneration({ + type: 'typealias', + modelName: 'OwnerRefs', + modelDocs: null, + modelType: { type: 'list', elementType: { type: 'document-reference' } }, + }) + ); + expect(withListOfRefs.content).toContain('import FirebaseFirestore'); + + const withoutRefs = await createRenderer().render( + baseGeneration({ + type: 'typealias', + modelName: 'Username', + modelDocs: null, + modelType: { type: 'string' }, + }) + ); + expect(withoutRefs.content).not.toContain('import FirebaseFirestore'); + }); + it('renders a struct whose @DocumentID property is renamed via swift.documentIdProperty.name (so a body-side `id` field can coexist)', async () => { const generation: SwiftGeneration = { type: 'swift', diff --git a/src/renderers/swift/_impl.ts b/src/renderers/swift/_impl.ts index 6c85c06c..571d2f32 100644 --- a/src/renderers/swift/_impl.ts +++ b/src/renderers/swift/_impl.ts @@ -50,7 +50,7 @@ class SwiftRendererImpl implements SwiftRenderer { } private requiresFirestoreImport(g: SwiftGeneration): boolean { - return g.declarations.some(d => d.type === 'struct' && d.modelType.documentIdProperty !== null); + return g.declarations.some(d => declarationUsesFirestore(d)); } private renderDeclaration(declaration: SwiftDeclaration) { @@ -312,3 +312,55 @@ class SwiftRendererImpl implements SwiftRenderer { export function createSwiftRenderer(config: SwiftRendererConfig): SwiftRenderer { return new SwiftRendererImpl(config); } + +/** + * A Swift declaration "needs Firestore" when it either has an auto-generated + * `@DocumentID` property (which is what the iOS SDK populates on read) or when + * any of its property types resolves to `DocumentReference`. Detecting the + * latter requires walking the type tree because references may be nested + * inside lists, dictionaries, tuples, etc. + */ +function declarationUsesFirestore(d: SwiftDeclaration): boolean { + switch (d.type) { + case 'struct': { + if (d.modelType.documentIdProperty !== null) return true; + const allProperties = [...d.modelType.literalProperties, ...d.modelType.regularProperties]; + return allProperties.some(p => swiftTypeUsesDocumentReference(p.type)); + } + case 'typealias': + return swiftTypeUsesDocumentReference(d.modelType); + case 'simple-union-enum': + return d.modelType.values.some(v => swiftTypeUsesDocumentReference(v.type)); + case 'string-enum': + case 'int-enum': + case 'discriminated-union-enum': + return false; + default: + assertNever(d); + } +} + +function swiftTypeUsesDocumentReference(t: swift.Type): boolean { + switch (t.type) { + case 'document-reference': + return true; + case 'any': + case 'nil': + case 'string': + case 'bool': + case 'int': + case 'double': + case 'date': + case 'data': + case 'alias': + return false; + case 'tuple': + return t.elements.some(swiftTypeUsesDocumentReference); + case 'list': + return swiftTypeUsesDocumentReference(t.elementType); + case 'dictionary': + return swiftTypeUsesDocumentReference(t.valueType); + default: + assertNever(t); + } +} diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts index 4309697b..cbab4533 100644 --- a/src/renderers/ts/__tests__/renderer.test.ts +++ b/src/renderers/ts/__tests__/renderer.test.ts @@ -180,4 +180,25 @@ describe('TSRendererImpl', () => { 'export type Payload = firestore.Blob;' ); }); + + it('renders document-reference as firestore.DocumentReference for every target family', async () => { + const generation: TSGeneration = { + type: 'ts', + declarations: [ + { type: 'alias', modelName: 'OwnerRef', modelType: { type: 'document-reference' }, modelDocs: null }, + ], + }; + + const expectedSubstring = 'export type OwnerRef = firestore.DocumentReference;'; + + await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain( + expectedSubstring + ); + await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain( + expectedSubstring + ); + await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain( + expectedSubstring + ); + }); }); diff --git a/src/renderers/zod/__tests__/renderer.test.ts b/src/renderers/zod/__tests__/renderer.test.ts index 3866c5fa..c9ffdde8 100644 --- a/src/renderers/zod/__tests__/renderer.test.ts +++ b/src/renderers/zod/__tests__/renderer.test.ts @@ -16,6 +16,7 @@ describe('ZodRendererImpl', () => { type: 'zod', usesTimestamp: true, usesBytes: false, + usesDocumentReference: false, declarations: [ { type: 'schema', @@ -47,6 +48,7 @@ describe('ZodRendererImpl', () => { type: 'zod', usesTimestamp: false, usesBytes: false, + usesDocumentReference: false, declarations: [ { type: 'schema', @@ -73,11 +75,12 @@ describe('ZodRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/with-inferred-types.ts'); }); - it('omits the Firestore SDK import when no model uses timestamp or bytes', async () => { + it('omits the Firestore SDK import when no model uses timestamp, bytes, or document-reference', async () => { const generation: ZodGeneration = { type: 'zod', usesTimestamp: false, usesBytes: false, + usesDocumentReference: false, declarations: [ { type: 'schema', @@ -101,6 +104,7 @@ describe('ZodRendererImpl', () => { type: 'zod', usesTimestamp: false, usesBytes: true, + usesDocumentReference: false, declarations: [ { type: 'schema', @@ -118,6 +122,30 @@ describe('ZodRendererImpl', () => { expect(result.content).not.toContain(`firebase-admin/firestore`); }); + it('emits the Firestore SDK import for the admin target when document-reference is used (DocumentReference lives under firestore)', async () => { + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: false, + usesDocumentReference: true, + declarations: [ + { + type: 'schema', + modelName: 'OwnerRef', + schemaName: 'OwnerRefSchema', + inferredTypeName: null, + modelDocs: null, + modelKind: 'alias', + expression: + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)', + }, + ], + }; + + const result = await createRenderer({ target: 'firebase-admin@13' }).render(generation); + expect(result.content).toContain(`import * as firestore from 'firebase-admin/firestore';`); + }); + it('emits the right Firestore SDK import for each target family', async () => { const targetsByExpectedImport: Record = { [`import * as firestore from 'firebase-admin/firestore';`]: ['firebase-admin@13', 'firebase-admin@12'], @@ -134,6 +162,7 @@ describe('ZodRendererImpl', () => { type: 'zod', usesTimestamp: true, usesBytes: false, + usesDocumentReference: false, declarations: [ { type: 'schema', @@ -160,6 +189,7 @@ describe('ZodRendererImpl', () => { type: 'zod', usesTimestamp: false, usesBytes: false, + usesDocumentReference: false, declarations: [ { type: 'schema', diff --git a/src/renderers/zod/_impl.ts b/src/renderers/zod/_impl.ts index b9d34c8a..e8de155d 100644 --- a/src/renderers/zod/_impl.ts +++ b/src/renderers/zod/_impl.ts @@ -60,15 +60,20 @@ class ZodRendererImpl implements ZodRenderer { /** * Computes the Firestore SDK import (if any) needed by the rendered file. We - * intentionally key off the generation's `usesTimestamp` / `usesBytes` flags - * rather than scanning the rendered string so that the renderer stays in lock - * step with the generator's intent and emits no dead imports. + * intentionally key off the generation's `usesTimestamp` / `usesBytes` / + * `usesDocumentReference` flags rather than scanning the rendered string so + * that the renderer stays in lock step with the generator's intent and emits + * no dead imports. */ private getFirestoreImportStatement(g: ZodGeneration): string | null { const needsTimestampImport = g.usesTimestamp; const needsBytesImport = g.usesBytes && bytesRequiresFirestoreImport(this.config.target); + // `DocumentReference` always lives under the Firestore SDK namespace + // (every supported target), so any document-reference usage forces the + // import. + const needsDocumentReferenceImport = g.usesDocumentReference; - if (!needsTimestampImport && !needsBytesImport) return null; + if (!needsTimestampImport && !needsBytesImport && !needsDocumentReferenceImport) return null; return getFirestoreImportForTarget(this.config.target); } diff --git a/src/schema/core/__tests__/validate-type.test.ts b/src/schema/core/__tests__/validate-type.test.ts index b8d8e8bb..b04e14ca 100644 --- a/src/schema/core/__tests__/validate-type.test.ts +++ b/src/schema/core/__tests__/validate-type.test.ts @@ -74,6 +74,14 @@ describe('schema.validateType()', () => { }); }); + describe('document-reference', () => { + it('does not throw if the type is valid', () => { + const schema = createSchema(); + const t: types.DocumentReference = { type: 'document-reference' }; + expect(() => schema.validateType(t)).not.toThrow(); + }); + }); + describe('string-literal', () => { it('does not throw if the type is valid', () => { const schema = createSchema(); diff --git a/src/schema/core/_zod-schemas.ts b/src/schema/core/_zod-schemas.ts index f91bed2d..8cdbb57b 100644 --- a/src/schema/core/_zod-schemas.ts +++ b/src/schema/core/_zod-schemas.ts @@ -61,6 +61,12 @@ export function createZodSchemasForSchema(schema: Schema) { }) .strict(); + const documentReferenceType = z + .object({ + type: z.literal('document-reference'), + }) + .strict(); + const primitiveType = anyType .or(unknownType) .or(nilType) @@ -69,7 +75,8 @@ export function createZodSchemasForSchema(schema: Schema) { .or(intType) .or(doubleType) .or(timestampType) - .or(bytesType); + .or(bytesType) + .or(documentReferenceType); const stringLiteralType = z .object({ @@ -389,6 +396,7 @@ export function createZodSchemasForSchema(schema: Schema) { doubleType, timestampType, bytesType, + documentReferenceType, primitiveType, stringLiteralType, intLiteralType, diff --git a/src/schema/core/types.ts b/src/schema/core/types.ts index 97e70c35..1942b7e8 100644 --- a/src/schema/core/types.ts +++ b/src/schema/core/types.ts @@ -22,6 +22,7 @@ export { Double, Timestamp, Bytes, + DocumentReference, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/generic.ts b/src/schema/generic.ts index e08116eb..ece0adfc 100644 --- a/src/schema/generic.ts +++ b/src/schema/generic.ts @@ -38,7 +38,23 @@ export interface Bytes { type: 'bytes'; } -export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes; +/** + * A Firestore document reference value. Firestore lets you store a reference + * to another document directly inside a document — i.e. a pointer to a + * location like `users/alice` — rather than its string id. Each platform + * exposes it as its idiomatic class (`firestore.DocumentReference` in + * TypeScript, `DocumentReference` in Swift, `firestore.DocumentReference` in + * Python) and Security Rules sees it as a `path`. + * + * Firestore only allows storing document references in fields (not collection + * references), so the name mirrors the SDK class `DocumentReference` exactly + * to make that constraint obvious at the schema-definition layer. + */ +export interface DocumentReference { + type: 'document-reference'; +} + +export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference; export interface StringLiteral { type: 'string-literal'; diff --git a/src/schema/python/types.ts b/src/schema/python/types.ts index 677d9523..fc960ec9 100644 --- a/src/schema/python/types.ts +++ b/src/schema/python/types.ts @@ -21,6 +21,7 @@ export { Double, Timestamp, Bytes, + DocumentReference, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/rules/types.ts b/src/schema/rules/types.ts index 97e70c35..1942b7e8 100644 --- a/src/schema/rules/types.ts +++ b/src/schema/rules/types.ts @@ -22,6 +22,7 @@ export { Double, Timestamp, Bytes, + DocumentReference, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/swift/types.ts b/src/schema/swift/types.ts index 7e2686d1..41ca439a 100644 --- a/src/schema/swift/types.ts +++ b/src/schema/swift/types.ts @@ -21,6 +21,7 @@ export { Double, Timestamp, Bytes, + DocumentReference, Primitive, StringLiteral, IntLiteral, diff --git a/src/schema/ts/types.ts b/src/schema/ts/types.ts index 97e70c35..1942b7e8 100644 --- a/src/schema/ts/types.ts +++ b/src/schema/ts/types.ts @@ -22,6 +22,7 @@ export { Double, Timestamp, Bytes, + DocumentReference, Primitive, StringLiteral, IntLiteral, From 5e366c5a71e6d5ff4c176d6c8cb1be783aff770c Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 21:30:06 +0300 Subject: [PATCH 2/6] Integration tests --- scripts/integration-test.ts | 19 ++- src/renderers/python/_impl.ts | 22 ++- .../samples/references/note-link.json | 11 ++ .../_fixtures/schemas/references.yml | 48 ++++++ .../python/tests/test_references.py | 118 +++++++++++++++ .../ReferencesIntegrationTests.swift | 124 +++++++++++++++ .../typescript/generated/web/references.ts | 14 ++ .../typescript/tests/references.admin.test.ts | 125 +++++++++++++++ .../typescript/tests/references.web.test.ts | 143 ++++++++++++++++++ .../zod/generated/v3/references.ts | 25 +++ .../zod/generated/v4-web/references.ts | 25 +++ .../zod/generated/v4/references.ts | 25 +++ .../zod/tests/references.admin.test.ts | 77 ++++++++++ .../zod/tests/references.parsing.test.ts | 84 ++++++++++ .../zod/tests/references.web.test.ts | 91 +++++++++++ 15 files changed, 938 insertions(+), 13 deletions(-) create mode 100644 tests/integration/_fixtures/samples/references/note-link.json create mode 100644 tests/integration/_fixtures/schemas/references.yml create mode 100644 tests/integration/python/tests/test_references.py create mode 100644 tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift create mode 100644 tests/integration/typescript/generated/web/references.ts create mode 100644 tests/integration/typescript/tests/references.admin.test.ts create mode 100644 tests/integration/typescript/tests/references.web.test.ts create mode 100644 tests/integration/zod/generated/v3/references.ts create mode 100644 tests/integration/zod/generated/v4-web/references.ts create mode 100644 tests/integration/zod/generated/v4/references.ts create mode 100644 tests/integration/zod/tests/references.admin.test.ts create mode 100644 tests/integration/zod/tests/references.parsing.test.ts create mode 100644 tests/integration/zod/tests/references.web.test.ts diff --git a/scripts/integration-test.ts b/scripts/integration-test.ts index 4fe68097..31f6244e 100644 --- a/scripts/integration-test.ts +++ b/scripts/integration-test.ts @@ -156,12 +156,15 @@ const PLATFORMS: Record = { objectTypeFormat: 'interface', }); }, - // The `bytes` scenario is the only place where the wire-level - // representation differs across TS targets (Buffer vs firestore.Bytes - // vs firestore.Blob), so we emit it against the web SDK in addition - // to the admin SDK. The admin pass above writes `generated/secrets.ts`; - // this pass writes `generated/web/secrets.ts`, which is imported by - // the dedicated `secrets.web.test.ts` round-trip suite. The + // The `bytes` and `document-reference` scenarios are the only places + // where the wire-level representation differs across TS targets + // (Buffer vs firestore.Bytes vs firestore.Blob for bytes; and + // firebase-admin's vs firebase-web's `DocumentReference` for refs), + // so we emit those fixtures against the web SDK in addition to the + // admin SDK. The admin pass above writes + // `generated/{secrets,references}.ts`; this pass writes + // `generated/web/{secrets,references}.ts`, which is imported by the + // dedicated `*.web.test.ts` round-trip suites. The // react-native-firebase target is verified by the unit/snapshot tests // under `src/renderers/ts/__tests__/`; we don't run it here because // `@react-native-firebase/firestore` is RN-runtime-only and cannot @@ -169,7 +172,7 @@ const PLATFORMS: Record = { extraGenerations: [ { subdir: 'web', - onlyFixtures: ['secrets'], + onlyFixtures: ['secrets', 'references'], async generate(definition, outFile) { await typesync.generateTs({ definition, @@ -241,7 +244,7 @@ const PLATFORMS: Record = { }, { subdir: 'v4-web', - onlyFixtures: ['secrets'], + onlyFixtures: ['secrets', 'references'], async generate(definition, outFile) { await typesync.generateZod({ definition, diff --git a/src/renderers/python/_impl.ts b/src/renderers/python/_impl.ts index d5bd2612..0da5e99e 100644 --- a/src/renderers/python/_impl.ts +++ b/src/renderers/python/_impl.ts @@ -28,7 +28,7 @@ class PythonRendererImpl implements PythonRenderer { b.append(`# Model Definitions\n\n`); g.declarations.forEach(declaration => { - b.append(`${this.renderDeclaration(declaration)}\n\n`); + b.append(`${this.renderDeclaration(declaration, g)}\n\n`); }); const rootFile: RenderedFile = { @@ -135,14 +135,14 @@ class PythonRendererImpl implements PythonRenderer { return b.toString(); } - private renderDeclaration(declaration: PythonDeclaration) { + private renderDeclaration(declaration: PythonDeclaration, g: PythonGeneration) { switch (declaration.type) { case 'alias': return this.renderAliasDeclaration(declaration); case 'enum-class': return this.renderEnumClassDeclaration(declaration); case 'pydantic-class': { - return this.renderPydanticClassDeclaration(declaration); + return this.renderPydanticClassDeclaration(declaration, g); } default: assertNever(declaration); @@ -186,7 +186,7 @@ class PythonRendererImpl implements PythonRenderer { } } - private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration) { + private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration, g: PythonGeneration) { const { undefinedSentinelName } = this.config; const { modelName, modelType, modelDocs } = declaration; const b = new StringBuilder(); @@ -214,7 +214,19 @@ class PythonRendererImpl implements PythonRenderer { b.append(`${this.indent(1)}class Config:\n`); b.append(`${this.indent(2)}use_enum_values = True\n`); - b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n\n`); + b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n`); + // `firestore.DocumentReference` is a third-party class with no + // `__get_pydantic_core_schema__` hook, so Pydantic refuses to validate + // it by default. Opt every generated model into accepting arbitrary + // types whenever the file imports the Firestore Python client, so any + // model that has (or transitively references) a `document-reference` + // field can be validated. The opt-in is scoped per-file via the + // `usesDocumentReference` flag; generations without Firestore refs + // are unchanged. + if (g.usesDocumentReference) { + b.append(`${this.indent(2)}arbitrary_types_allowed = True\n`); + } + b.append('\n'); b.append(`${this.indent(1)}def __setattr__(self, name: str, value: typing.Any) -> None:\n`); modelType.attributes.forEach(attribute => { diff --git a/tests/integration/_fixtures/samples/references/note-link.json b/tests/integration/_fixtures/samples/references/note-link.json new file mode 100644 index 00000000..fc2a5710 --- /dev/null +++ b/tests/integration/_fixtures/samples/references/note-link.json @@ -0,0 +1,11 @@ +{ + "label": "primary-note-link", + "target_path": "targets/canonical", + "related_paths": ["notes/sibling-a", "notes/sibling-b", "notes/sibling-c"], + "by_label_paths": { + "primary": "targets/canonical", + "secondary": "targets/secondary", + "tertiary": "targets/tertiary" + }, + "created_at": "2024-05-09T10:00:00.000Z" +} diff --git a/tests/integration/_fixtures/schemas/references.yml b/tests/integration/_fixtures/schemas/references.yml new file mode 100644 index 00000000..6116a5c0 --- /dev/null +++ b/tests/integration/_fixtures/schemas/references.yml @@ -0,0 +1,48 @@ +# yaml-language-server: $schema=../../../../schema.local.json +# +# Shared integration-test fixture for the `document-reference` primitive. +# Each Firestore SDK represents document references with a platform-native +# class: +# +# - TypeScript (firebase-admin@13): firestore.DocumentReference +# - TypeScript (firebase@10, web): firestore.DocumentReference +# - Python (firebase-admin@6): firestore.DocumentReference +# - Swift (firebase@10): FirebaseFirestore.DocumentReference +# +# The schema deliberately mixes a top-level reference, a list-of-references, +# and a map-of-references so we exercise references in collections too. A +# plain string + timestamp sit alongside so we confirm references coexist +# with non-Firestore-typed fields without accidental coercion. +# +# Doc-level convention used by every per-platform test: +# * `target` is a reference to a sibling `/targets/{id}` document that +# stores the canonical entity the note links to. +# * `related` is a list of references to other notes (siblings in the +# same collection). +# * `by_label` is a map (free-form keys) whose values are references. + +NoteLink: + model: document + path: notes/{noteId} + docs: A document that points to other documents via Firestore document references. + type: + type: object + fields: + label: + type: string + docs: Human-readable label for the link (not a reference itself). + target: + type: document-reference + docs: A direct reference to a `/targets/{id}` document. + related: + type: + type: list + elementType: document-reference + docs: A list of related-note references to exercise references nested in a list. + by_label: + type: + type: map + valueType: document-reference + docs: A map of label -> reference to exercise references nested in a map. + created_at: + type: timestamp diff --git a/tests/integration/python/tests/test_references.py b/tests/integration/python/tests/test_references.py new file mode 100644 index 00000000..c32a8422 --- /dev/null +++ b/tests/integration/python/tests/test_references.py @@ -0,0 +1,118 @@ +"""Round-trip tests for the `references` fixture. + +Verifies that the `document-reference` primitive emitted by the Python +generator (`firestore.DocumentReference` / +`typing.List[firestore.DocumentReference]` / +`typing.Dict[str, firestore.DocumentReference]`) round-trips correctly +through the Firestore emulator using the official +`google-cloud-firestore` client (which is what `firebase-admin` uses +underneath). +""" + +from __future__ import annotations + +import json +import uuid +from pathlib import Path + +import pytest +from google.cloud import firestore + + +def _load_sample(fixtures_root: Path, name: str) -> dict: + return json.loads((fixtures_root / "samples" / "references" / f"{name}.json").read_text()) + + +@pytest.fixture +def references_module(import_generated_module): + return import_generated_module("references") + + +def test_note_link_round_trips_document_references_via_firestore_emulator( + references_module, + fixtures_root: Path, + firestore_client: firestore.Client, + isolated_collection: firestore.CollectionReference, +) -> None: + """A document with a top-level reference + list-of-references + + map-of-references survives a Pydantic-validate -> emulator-write -> + emulator-read -> Pydantic-validate cycle with each reference's path + preserved exactly.""" + + NoteLink = references_module.NoteLink + + sample = _load_sample(fixtures_root, "note-link") + + target = firestore_client.document(sample["target_path"]) + related = [firestore_client.document(p) for p in sample["related_paths"]] + by_label = {k: firestore_client.document(v) for k, v in sample["by_label_paths"].items()} + + # Sanity-check the fixture itself: distinct paths so a buggy SDK that + # aliased every reference to the same value would still be caught. + assert target.path == sample["target_path"] + assert len({r.path for r in related}) == len(related) + assert len(related) == 3 + assert len(by_label) == 3 + + note_link_in = NoteLink.model_validate( + { + "label": sample["label"], + "target": target, + "related": related, + "by_label": by_label, + "created_at": sample["created_at"], + } + ) + + # The generator emits Pydantic types that store `DocumentReference` + # values verbatim; check that nothing has been auto-coerced (e.g. to + # a string path) before we even reach Firestore. + assert isinstance(note_link_in.target, firestore.DocumentReference) + assert all(isinstance(r, firestore.DocumentReference) for r in note_link_in.related) + assert all(isinstance(v, firestore.DocumentReference) for v in note_link_in.by_label.values()) + + doc_ref = isolated_collection.document(uuid.uuid4().hex) + doc_ref.set(note_link_in.model_dump()) + + snapshot = doc_ref.get() + assert snapshot.exists, "expected the written document to be readable" + + raw = snapshot.to_dict() + # Wire-level expectations: the Firestore Python client returns refs + # as `DocumentReference` for top-level fields, list entries, and map + # values. + assert isinstance(raw["target"], firestore.DocumentReference) + assert isinstance(raw["related"], list) + assert all(isinstance(r, firestore.DocumentReference) for r in raw["related"]) + assert isinstance(raw["by_label"], dict) + assert all(isinstance(v, firestore.DocumentReference) for v in raw["by_label"].values()) + + # Re-validate through the generated Pydantic model to confirm the + # generated schema round-trips without rejecting plain + # `DocumentReference` values. + note_link_out = NoteLink.model_validate(raw) + assert note_link_out.label == sample["label"] + assert note_link_out.target.path == target.path + assert [r.path for r in note_link_out.related] == [r.path for r in related] + assert {k: v.path for k, v in note_link_out.by_label.items()} == { + k: v.path for k, v in by_label.items() + } + + +def test_note_link_rejects_string_target(references_module, firestore_client: firestore.Client) -> None: + """The generated Pydantic class should reject obvious type mismatches + on the reference-typed fields (e.g. a bare string path where a + `DocumentReference` instance is expected).""" + + NoteLink = references_module.NoteLink + + with pytest.raises(Exception): + NoteLink.model_validate( + { + "label": "x", + "target": "targets/canonical", + "related": [], + "by_label": {}, + "created_at": "2024-01-01T00:00:00.000Z", + } + ) diff --git a/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift new file mode 100644 index 00000000..18dddf19 --- /dev/null +++ b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift @@ -0,0 +1,124 @@ +import FirebaseFirestore +import Foundation +import Testing + +@testable import TypesyncIntegration + +// Round-trips a `document-reference`-typed document through the Firestore +// emulator using the **firebase-ios-sdk** Codable bridge. The Swift +// generator emits `FirebaseFirestore.DocumentReference` for each +// `document-reference` field, which is what the Firebase iOS SDK uses to +// represent Firestore's `reference` value type when encoding / decoding +// via `Firestore.Encoder` / `Firestore.Decoder`. +// +// We assert both the wire-level and Codable-level shapes: +// +// 1. `setData(from:)` accepts a `NoteLink` whose reference-typed fields +// are `DocumentReference` values without coercion (top-level, +// inside a list, and inside a map). +// 2. The raw snapshot (`snapshot.data()`) exposes those fields as +// `DocumentReference` (iOS SDK class), including refs nested inside +// a list and a map. +// 3. `snapshot.data(as: NoteLink.self)` rebuilds an equivalent +// `NoteLink` whose reference paths are preserved exactly. + +@Suite("References document-reference round-trip via emulator") +struct ReferencesIntegrationTests { + @Test("a NoteLink with DocumentReference fields (top-level + [DocumentReference] + [String: DocumentReference]) round-trips through the emulator with path preservation") + func noteLinkRoundTripsThroughEmulator() async throws { + let firestore = EmulatorClient.firestore() + let collection = firestore.collection(uniqueCollectionName()) + + let sample = try Fixtures.loadSampleAsDict(scenario: "references", name: "note-link") + + let targetPath = try unwrap(sample["target_path"] as? String, "references/note-link.target_path") + let relatedPaths = try unwrap(sample["related_paths"] as? [String], "references/note-link.related_paths") + let byLabelPaths = try unwrap(sample["by_label_paths"] as? [String: String], "references/note-link.by_label_paths") + let label = try unwrap(sample["label"] as? String, "references/note-link.label") + let createdAtRaw = try unwrap(sample["created_at"] as? String, "references/note-link.created_at") + + let target = firestore.document(targetPath) + let related = relatedPaths.map { firestore.document($0) } + var byLabel: [String: DocumentReference] = [:] + for (k, v) in byLabelPaths { + byLabel[k] = firestore.document(v) + } + + // Sanity-check the fixture: distinct paths so a buggy bridge that + // aliased every reference to the same value would still be + // caught. + #expect(target.path == targetPath) + #expect(Set(related.map { $0.path }).count == related.count) + #expect(related.count == 3) + #expect(byLabel.count == 3) + + let noteLinkIn = NoteLink( + label: label, + target: target, + related: related, + byLabel: byLabel, + createdAt: try parseISO8601(createdAtRaw) + ) + + let docRef = collection.document(UUID().uuidString) + try docRef.setData(from: noteLinkIn) + + let snapshot = try await docRef.getDocument() + try #require(snapshot.exists) + let raw = try #require(snapshot.data(), "expected non-empty document data") + + // Wire-level expectations: the iOS SDK exposes references through + // `DocumentReference` on the snapshot for top-level fields, list + // entries, and map values alike. + #expect(raw["target"] is DocumentReference) + let rawRelated = try #require(raw["related"] as? [DocumentReference], "related should decode as [DocumentReference]") + #expect(rawRelated.count == related.count) + let rawByLabel = try #require(raw["by_label"] as? [String: DocumentReference], "by_label should decode as [String: DocumentReference]") + #expect(rawByLabel.count == byLabel.count) + + let noteLinkOut = try snapshot.data(as: NoteLink.self) + + #expect(noteLinkOut.label == noteLinkIn.label) + #expect(noteLinkOut.target.path == target.path) + #expect(noteLinkOut.related.count == related.count) + for (i, ref) in noteLinkOut.related.enumerated() { + #expect(ref.path == related[i].path) + } + #expect(Set(noteLinkOut.byLabel.keys) == Set(byLabel.keys)) + for (k, expectedRef) in byLabel { + #expect(noteLinkOut.byLabel[k]?.path == expectedRef.path) + } + #expect( + abs(noteLinkOut.createdAt.timeIntervalSince1970 - noteLinkIn.createdAt.timeIntervalSince1970) < 0.001, + "timestamp should round-trip through Firestore within ms precision" + ) + } +} + +private func uniqueCollectionName() -> String { + "test_" + UUID().uuidString.replacingOccurrences(of: "-", with: "") +} + +private struct FixtureError: Error, CustomStringConvertible { + let description: String +} + +private func unwrap(_ value: T?, _ name: String) throws -> T { + guard let value else { + throw FixtureError(description: "Missing or wrongly-typed fixture field '\(name)'.") + } + return value +} + +private func parseISO8601(_ raw: String) throws -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: raw) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + if let date = formatter.date(from: raw) { + return date + } + throw FixtureError(description: "Invalid ISO-8601 date: \(raw)") +} diff --git a/tests/integration/typescript/generated/web/references.ts b/tests/integration/typescript/generated/web/references.ts new file mode 100644 index 00000000..40389705 --- /dev/null +++ b/tests/integration/typescript/generated/web/references.ts @@ -0,0 +1,14 @@ +import type * as firestore from 'firebase/firestore'; + +/** A document that points to other documents via Firestore document references. */ +export interface NoteLink { + /** Human-readable label for the link (not a reference itself). */ + label: string; + /** A direct reference to a `/targets/{id}` document. */ + target: firestore.DocumentReference; + /** A list of related-note references to exercise references nested in a list. */ + related: firestore.DocumentReference[]; + /** A map of label -> reference to exercise references nested in a map. */ + by_label: Record>; + created_at: firestore.Timestamp; +} diff --git a/tests/integration/typescript/tests/references.admin.test.ts b/tests/integration/typescript/tests/references.admin.test.ts new file mode 100644 index 00000000..726ea6eb --- /dev/null +++ b/tests/integration/typescript/tests/references.admin.test.ts @@ -0,0 +1,125 @@ +import { type App, initializeApp } from 'firebase-admin/app'; +import { DocumentReference, type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { NoteLink } from '../generated/references.js'; + +// Round-trips a `document-reference`-typed document through the Firestore +// emulator using the **firebase-admin@13** SDK. The admin SDK represents +// document references with the `DocumentReference` class exposed under +// `firebase-admin/firestore`; the generator emits that class for every +// `firebase-admin@*` target. This test asserts that: +// +// 1. A typed `NoteLink` constructed with admin `DocumentReference` values +// (top-level, inside a list, and inside a map) can be written to the +// emulator without coercion. +// 2. Reading the same document back yields `DocumentReference`-shaped +// values for every reference field, including those nested inside the +// list and the map. +// 3. The target paths survive the round-trip exactly: a reference pointed +// at `targets/canonical` before the write still points at +// `targets/canonical` after the read. + +const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures'); + +interface NoteLinkSample { + label: string; + target_path: string; + related_paths: string[]; + by_label_paths: Record; + created_at: string; +} + +function loadSample(scenario: string, name: string): NoteLinkSample { + const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`); + return JSON.parse(readFileSync(samplePath, 'utf8')) as NoteLinkSample; +} + +function ensureEmulatorEnv(): void { + if (!process.env.FIRESTORE_EMULATOR_HOST) { + throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.'); + } + process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration'; + process.env.GCLOUD_PROJECT ??= process.env.GOOGLE_CLOUD_PROJECT; +} + +describe('References document-reference round-trip (firebase-admin@13)', () => { + let app: App; + let firestore: Firestore; + + beforeAll(() => { + ensureEmulatorEnv(); + app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'references-admin-test-app'); + firestore = getFirestore(app); + }); + + afterAll(async () => { + await firestore.terminate(); + }); + + it('round-trips a typed NoteLink with DocumentReference-valued fields (top-level + list + map) through the emulator', async () => { + const sample = loadSample('references', 'note-link'); + + const target = firestore.doc(sample.target_path); + const related = sample.related_paths.map(p => firestore.doc(p)); + const byLabel: Record = {}; + for (const [k, v] of Object.entries(sample.by_label_paths)) { + byLabel[k] = firestore.doc(v); + } + + // Sanity-check the fixture: distinct paths so a buggy SDK that aliased + // every reference to the same value would still be caught. + expect(target.path).toBe(sample.target_path); + expect(new Set(related.map(r => r.path)).size).toBe(related.length); + expect(related.length).toBe(3); + expect(Object.keys(byLabel).length).toBe(3); + + const noteLinkIn: NoteLink = { + label: sample.label, + target, + related, + by_label: byLabel, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + await docRef.set(noteLinkIn); + + const snapshot = await docRef.get(); + expect(snapshot.exists).toBe(true); + + const raw = snapshot.data() as Record; + // Wire-level expectations: the admin SDK returns references as + // `DocumentReference` instances, including nested entries. + expect(raw.target).toBeInstanceOf(DocumentReference); + expect(Array.isArray(raw.related)).toBe(true); + for (const r of raw.related as unknown[]) { + expect(r).toBeInstanceOf(DocumentReference); + } + expect(typeof raw.by_label).toBe('object'); + for (const v of Object.values(raw.by_label as Record)) { + expect(v).toBeInstanceOf(DocumentReference); + } + + const noteLinkOut = snapshot.data() as NoteLink; + + expect(noteLinkOut.label).toBe(noteLinkIn.label); + expect(noteLinkOut.target.path).toBe(target.path); + expect(noteLinkOut.related.length).toBe(related.length); + noteLinkOut.related.forEach((r, i) => { + const expected = related[i]; + expect(expected).toBeDefined(); + expect(r.path).toBe(expected!.path); + }); + expect(Object.keys(noteLinkOut.by_label).sort()).toEqual(Object.keys(byLabel).sort()); + for (const [k, expectedRef] of Object.entries(byLabel)) { + const got = noteLinkOut.by_label[k]; + expect(got).toBeDefined(); + expect(got!.path).toBe(expectedRef.path); + } + expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); + }); +}); diff --git a/tests/integration/typescript/tests/references.web.test.ts b/tests/integration/typescript/tests/references.web.test.ts new file mode 100644 index 00000000..ffe5ce5a --- /dev/null +++ b/tests/integration/typescript/tests/references.web.test.ts @@ -0,0 +1,143 @@ +import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app'; +import { + DocumentReference, + type Firestore, + Timestamp, + connectFirestoreEmulator, + doc, + getDoc, + getFirestore, + setDoc, + terminate, +} from 'firebase/firestore'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { NoteLink } from '../generated/web/references.js'; + +// Round-trips a `document-reference`-typed document through the Firestore +// emulator using the **firebase@10 web SDK**. The web SDK exposes +// references through the `DocumentReference` class from `firebase/firestore` +// (a separate class from the admin SDK's `DocumentReference`, but with the +// same on-wire representation). The generator emits the web SDK's +// `DocumentReference` for the `firebase@*` targets and this test confirms +// that: +// +// 1. A typed `NoteLink` constructed with web `DocumentReference` values +// (top-level + nested in a list + nested in a map) is accepted by +// `setDoc` without coercion. +// 2. Reading back with `getDoc` returns `DocumentReference` instances +// for every reference field. +// 3. The reference paths are preserved exactly across the round-trip. + +const FIXTURES_ROOT = resolve(__dirname, '../../_fixtures'); + +interface NoteLinkSample { + label: string; + target_path: string; + related_paths: string[]; + by_label_paths: Record; + created_at: string; +} + +function loadSample(scenario: string, name: string): NoteLinkSample { + const samplePath = resolve(FIXTURES_ROOT, 'samples', scenario, `${name}.json`); + return JSON.parse(readFileSync(samplePath, 'utf8')) as NoteLinkSample; +} + +function ensureEmulatorEnv(): { host: string; port: number } { + const raw = process.env.FIRESTORE_EMULATOR_HOST; + if (!raw) { + throw new Error('FIRESTORE_EMULATOR_HOST is not set. Run via `yarn test:integration:typescript`.'); + } + const lastColon = raw.lastIndexOf(':'); + if (lastColon < 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} is not in host:port form`); + } + const host = raw.slice(0, lastColon); + const port = Number(raw.slice(lastColon + 1)); + if (!Number.isFinite(port) || port <= 0) { + throw new Error(`FIRESTORE_EMULATOR_HOST=${raw} has an invalid port`); + } + process.env.GOOGLE_CLOUD_PROJECT ??= 'demo-integration'; + return { host, port }; +} + +describe('References document-reference round-trip (firebase@10 web SDK)', () => { + let app: FirebaseApp; + let firestore: Firestore; + + beforeAll(() => { + const { host, port } = ensureEmulatorEnv(); + app = initializeWebApp( + { + apiKey: 'fake-api-key', + projectId: process.env.GOOGLE_CLOUD_PROJECT, + }, + 'references-web-test-app' + ); + firestore = getFirestore(app); + connectFirestoreEmulator(firestore, host, port); + }); + + afterAll(async () => { + await terminate(firestore); + }); + + it('round-trips a typed NoteLink with web-SDK DocumentReference values through the emulator', async () => { + const sample = loadSample('references', 'note-link'); + + const target = doc(firestore, sample.target_path); + const related = sample.related_paths.map(p => doc(firestore, p)); + const byLabel: Record = {}; + for (const [k, v] of Object.entries(sample.by_label_paths)) { + byLabel[k] = doc(firestore, v); + } + + expect(target.path).toBe(sample.target_path); + expect(new Set(related.map(r => r.path)).size).toBe(related.length); + + const noteLinkIn: NoteLink = { + label: sample.label, + target, + related, + by_label: byLabel, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`; + const docRef = doc(firestore, collection, crypto.randomUUID()); + await setDoc(docRef, noteLinkIn); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + + const raw = snapshot.data() as Record; + expect(raw.target).toBeInstanceOf(DocumentReference); + expect(Array.isArray(raw.related)).toBe(true); + for (const r of raw.related as unknown[]) { + expect(r).toBeInstanceOf(DocumentReference); + } + for (const v of Object.values(raw.by_label as Record)) { + expect(v).toBeInstanceOf(DocumentReference); + } + + const noteLinkOut = snapshot.data() as NoteLink; + + expect(noteLinkOut.label).toBe(noteLinkIn.label); + expect(noteLinkOut.target.path).toBe(target.path); + expect(noteLinkOut.related.length).toBe(related.length); + noteLinkOut.related.forEach((r, i) => { + const expected = related[i]; + expect(expected).toBeDefined(); + expect(r.path).toBe(expected!.path); + }); + for (const [k, expectedRef] of Object.entries(byLabel)) { + const got = noteLinkOut.by_label[k]; + expect(got).toBeDefined(); + expect(got!.path).toBe(expectedRef.path); + } + expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); + }); +}); diff --git a/tests/integration/zod/generated/v3/references.ts b/tests/integration/zod/generated/v3/references.ts new file mode 100644 index 00000000..ad37fb0a --- /dev/null +++ b/tests/integration/zod/generated/v3/references.ts @@ -0,0 +1,25 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v3'; + +/** A document that points to other documents via Firestore document references. */ +export const NoteLinkSchema = z + .object({ + label: z.string().describe('Human-readable label for the link (not a reference itself).'), + target: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A direct reference to a `/targets/{id}` document.'), + related: z + .array( + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A list of related-note references to exercise references nested in a list.'), + by_label: z + .record( + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A map of label -> reference to exercise references nested in a map.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .strict() + .describe('A document that points to other documents via Firestore document references.'); +export type NoteLink = z.infer; diff --git a/tests/integration/zod/generated/v4-web/references.ts b/tests/integration/zod/generated/v4-web/references.ts new file mode 100644 index 00000000..ae2a0776 --- /dev/null +++ b/tests/integration/zod/generated/v4-web/references.ts @@ -0,0 +1,25 @@ +import * as firestore from 'firebase/firestore'; +import { z } from 'zod-v4'; + +/** A document that points to other documents via Firestore document references. */ +export const NoteLinkSchema = z + .strictObject({ + label: z.string().describe('Human-readable label for the link (not a reference itself).'), + target: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A direct reference to a `/targets/{id}` document.'), + related: z + .array( + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A list of related-note references to exercise references nested in a list.'), + by_label: z + .record( + z.string(), + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A map of label -> reference to exercise references nested in a map.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A document that points to other documents via Firestore document references.'); +export type NoteLink = z.infer; diff --git a/tests/integration/zod/generated/v4/references.ts b/tests/integration/zod/generated/v4/references.ts new file mode 100644 index 00000000..978e661d --- /dev/null +++ b/tests/integration/zod/generated/v4/references.ts @@ -0,0 +1,25 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod-v4'; + +/** A document that points to other documents via Firestore document references. */ +export const NoteLinkSchema = z + .strictObject({ + label: z.string().describe('Human-readable label for the link (not a reference itself).'), + target: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A direct reference to a `/targets/{id}` document.'), + related: z + .array( + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A list of related-note references to exercise references nested in a list.'), + by_label: z + .record( + z.string(), + z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + ) + .describe('A map of label -> reference to exercise references nested in a map.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A document that points to other documents via Firestore document references.'); +export type NoteLink = z.infer; diff --git a/tests/integration/zod/tests/references.admin.test.ts b/tests/integration/zod/tests/references.admin.test.ts new file mode 100644 index 00000000..6e2ded6e --- /dev/null +++ b/tests/integration/zod/tests/references.admin.test.ts @@ -0,0 +1,77 @@ +import { type App, initializeApp } from 'firebase-admin/app'; +import { DocumentReference, type Firestore, Timestamp, getFirestore } from 'firebase-admin/firestore'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { NoteLinkSchema } from '../generated/v4/references.js'; +import { ensureEmulatorEnv, loadSample } from './_helpers.js'; + +interface NoteLinkSample { + label: string; + target_path: string; + related_paths: string[]; + by_label_paths: Record; + created_at: string; +} + +describe('references firebase-admin@13 round-trip (v4 zod schema)', () => { + let app: App; + let firestore: Firestore; + + beforeAll(() => { + ensureEmulatorEnv(); + app = initializeApp({ projectId: process.env.GOOGLE_CLOUD_PROJECT }, 'zod-references-admin-app'); + firestore = getFirestore(app); + }); + + afterAll(async () => { + await firestore.terminate(); + }); + + it("validates a NoteLink read from the emulator with the admin SDK's DocumentReference shape", async () => { + const sample = loadSample('references', 'note-link') as NoteLinkSample; + + const target = firestore.doc(sample.target_path); + const related = sample.related_paths.map(p => firestore.doc(p)); + const byLabel: Record = {}; + for (const [k, v] of Object.entries(sample.by_label_paths)) { + byLabel[k] = firestore.doc(v); + } + + const noteLinkIn = { + label: sample.label, + target, + related, + by_label: byLabel, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = firestore.collection(`test_${crypto.randomUUID().replaceAll('-', '')}`); + const docRef = collection.doc(crypto.randomUUID()); + await docRef.set(noteLinkIn); + + const snapshot = await docRef.get(); + expect(snapshot.exists).toBe(true); + + const parsed = NoteLinkSchema.safeParse(snapshot.data()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.label).toBe(noteLinkIn.label); + // The admin SDK returns refs as DocumentReference instances; the + // schema is bound to the admin SDK's DocumentReference class for + // this generated file (firebase-admin@13 target). + expect(parsed.data.target).toBeInstanceOf(DocumentReference); + expect(parsed.data.target.path).toBe(target.path); + expect(parsed.data.related).toHaveLength(related.length); + for (let i = 0; i < related.length; i += 1) { + expect(parsed.data.related[i]).toBeInstanceOf(DocumentReference); + expect(parsed.data.related[i]!.path).toBe(related[i]!.path); + } + for (const [k, expectedRef] of Object.entries(byLabel)) { + const got = parsed.data.by_label[k]; + expect(got).toBeInstanceOf(DocumentReference); + expect(got!.path).toBe(expectedRef.path); + } + expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); + } + }); +}); diff --git a/tests/integration/zod/tests/references.parsing.test.ts b/tests/integration/zod/tests/references.parsing.test.ts new file mode 100644 index 00000000..d33362ea --- /dev/null +++ b/tests/integration/zod/tests/references.parsing.test.ts @@ -0,0 +1,84 @@ +import { DocumentReference as AdminDocumentReference, Timestamp } from 'firebase-admin/firestore'; +import { describe, expect, it } from 'vitest'; + +import * as v3Admin from '../generated/v3/references.js'; +import * as v4Web from '../generated/v4-web/references.js'; +import * as v4Admin from '../generated/v4/references.js'; + +// Pure-Zod parsing tests for the `references` fixture (which exercises +// the `document-reference` primitive). The reference representation +// differs per target: +// +// * firebase-admin: firestore.DocumentReference from `firebase-admin/firestore` +// * firebase web SDK: firestore.DocumentReference from `firebase/firestore` +// +// We exercise all three combinations the orchestrator emits today: +// v3 admin, v4 admin, v4 web. + +const ts = new Timestamp(1_700_000_000, 0); + +/** + * The admin `DocumentReference` constructor is private; the SDK only + * exposes ways to obtain one through a `Firestore` instance. The + * generated schemas only check that the value is an `instanceof + * DocumentReference`, so a `Object.create`-based fake whose prototype is + * `DocumentReference.prototype` is sufficient to satisfy the schema + * without booting Firebase. The dedicated emulator round-trip suites + * (`references.admin.test.ts` / `references.web.test.ts`) cover the real + * SDK instances. + */ +function fakeAdminRef(): AdminDocumentReference { + return Object.create(AdminDocumentReference.prototype) as AdminDocumentReference; +} + +const adminInput = () => ({ + label: 'note link', + target: fakeAdminRef(), + related: [fakeAdminRef(), fakeAdminRef()], + by_label: { primary: fakeAdminRef(), secondary: fakeAdminRef() }, + created_at: ts, +}); + +describe.each([ + { name: 'v3 admin', mod: v3Admin }, + { name: 'v4 admin', mod: v4Admin }, +])('references admin ($name)', ({ mod }) => { + it('accepts a NoteLink built from DocumentReference values', () => { + expect(mod.NoteLinkSchema.safeParse(adminInput()).success).toBe(true); + }); + it('rejects a NoteLink whose `target` is a bare string path instead of a DocumentReference', () => { + expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), target: 'targets/canonical' }).success).toBe(false); + }); + it('rejects a NoteLink whose `related` list contains a non-DocumentReference entry', () => { + expect( + mod.NoteLinkSchema.safeParse({ + ...adminInput(), + related: [fakeAdminRef(), 'targets/canonical'], + }).success + ).toBe(false); + }); + it('rejects a NoteLink whose `by_label` map contains a non-DocumentReference value', () => { + expect( + mod.NoteLinkSchema.safeParse({ + ...adminInput(), + by_label: { primary: fakeAdminRef(), secondary: 'targets/canonical' }, + }).success + ).toBe(false); + }); + it('accepts empty `related` list and empty `by_label` map', () => { + expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), related: [], by_label: {} }).success).toBe(true); + }); +}); + +describe('references web (v4)', () => { + it('rejects admin-shaped DocumentReference values against the web-SDK-bound schema', () => { + // The web schema's `instanceof` check is bound to + // `firebase/firestore`'s DocumentReference, an entirely different + // class from `firebase-admin/firestore`'s DocumentReference. Passing + // an admin-shaped ref should therefore fail at parse time. We don't + // construct a web-SDK reference here because it requires booting + // the Firebase web SDK; the dedicated emulator round-trip in + // `references.web.test.ts` covers the positive path. + expect(v4Web.NoteLinkSchema.safeParse(adminInput()).success).toBe(false); + }); +}); diff --git a/tests/integration/zod/tests/references.web.test.ts b/tests/integration/zod/tests/references.web.test.ts new file mode 100644 index 00000000..3b704c0c --- /dev/null +++ b/tests/integration/zod/tests/references.web.test.ts @@ -0,0 +1,91 @@ +import { type FirebaseApp, initializeApp as initializeWebApp } from 'firebase/app'; +import { + DocumentReference, + type Firestore, + Timestamp, + connectFirestoreEmulator, + doc, + getDoc, + getFirestore, + setDoc, + terminate, +} from 'firebase/firestore'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { NoteLinkSchema } from '../generated/v4-web/references.js'; +import { ensureEmulatorEnv, loadSample } from './_helpers.js'; + +interface NoteLinkSample { + label: string; + target_path: string; + related_paths: string[]; + by_label_paths: Record; + created_at: string; +} + +describe('references firebase web SDK round-trip (v4 zod schema)', () => { + let app: FirebaseApp; + let firestore: Firestore; + + beforeAll(() => { + const { host, port } = ensureEmulatorEnv(); + app = initializeWebApp( + { + apiKey: 'fake-api-key', + projectId: process.env.GOOGLE_CLOUD_PROJECT, + }, + 'zod-references-web-app' + ); + firestore = getFirestore(app); + connectFirestoreEmulator(firestore, host, port); + }); + + afterAll(async () => { + await terminate(firestore); + }); + + it("validates a NoteLink read from the emulator with the web SDK's `firestore.DocumentReference` shape", async () => { + const sample = loadSample('references', 'note-link') as NoteLinkSample; + + const target = doc(firestore, sample.target_path); + const related = sample.related_paths.map(p => doc(firestore, p)); + const byLabel: Record = {}; + for (const [k, v] of Object.entries(sample.by_label_paths)) { + byLabel[k] = doc(firestore, v); + } + + const noteLinkIn = { + label: sample.label, + target, + related, + by_label: byLabel, + created_at: Timestamp.fromDate(new Date(sample.created_at)), + }; + + const collection = `test_${crypto.randomUUID().replaceAll('-', '')}`; + const docRef = doc(firestore, collection, crypto.randomUUID()); + await setDoc(docRef, noteLinkIn); + + const snapshot = await getDoc(docRef); + expect(snapshot.exists()).toBe(true); + + const parsed = NoteLinkSchema.safeParse(snapshot.data()); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.label).toBe(noteLinkIn.label); + expect(parsed.data.target).toBeInstanceOf(DocumentReference); + expect(parsed.data.target.path).toBe(target.path); + expect(parsed.data.related).toHaveLength(related.length); + for (let i = 0; i < related.length; i += 1) { + expect(parsed.data.related[i]).toBeInstanceOf(DocumentReference); + expect(parsed.data.related[i]!.path).toBe(related[i]!.path); + } + for (const [k, expectedRef] of Object.entries(byLabel)) { + const got = parsed.data.by_label[k]; + expect(got).toBeInstanceOf(DocumentReference); + expect(got!.path).toBe(expectedRef.path); + } + expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); + } + }); +}); From 782fd5974a3d27a3c521a49270a88276e05c48b5 Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 22:18:01 +0300 Subject: [PATCH 3/6] Allow specifying model --- docs/schema/types.mdx | 70 ++++++++++- schema.local.json | 117 +++++++++++------- src/converters/definition-to-schema.ts | 8 ++ .../zod/__tests__/build-zod-schema.test.ts | 44 +++++++ .../zod/__tests__/codegen-emitter.test.ts | 53 +++++++- src/core/zod/_codegen-emitter.ts | 26 +++- src/core/zod/_emitter.ts | 16 ++- src/core/zod/_runtime-emitter.ts | 8 +- src/core/zod/build-zod-schema.ts | 35 ++++-- src/definition/_guards.ts | 9 ++ src/definition/impl/_zod-schemas.ts | 17 +++ src/definition/types/_types.ts | 40 +++++- src/generators/python/_converters.ts | 4 +- src/generators/swift/_converters.ts | 4 +- src/generators/ts/__tests__/generator.test.ts | 15 +++ src/generators/ts/_converters.ts | 4 +- .../zod/__tests__/generator.test.ts | 32 +++++ src/generators/zod/_impl.ts | 5 +- src/platforms/python/_types.ts | 7 ++ src/platforms/swift/_types.ts | 6 + src/platforms/ts/_expressions.ts | 12 +- src/platforms/ts/_types.ts | 11 +- src/renderers/ts/__tests__/renderer.test.ts | 29 +++++ src/schema/abstract.ts | 4 + .../core/__tests__/validate-type.test.ts | 27 +++- src/schema/core/_zod-schemas.ts | 19 ++- src/schema/core/impl.ts | 59 +++++++-- src/schema/generic.ts | 13 ++ .../samples/references/note-link.json | 2 + .../_fixtures/schemas/references.yml | 42 ++++++- .../python/tests/test_references.py | 18 ++- .../ReferencesIntegrationTests.swift | 20 ++- .../typescript/generated/web/references.ts | 11 ++ .../typescript/tests/references.admin.test.ts | 33 ++++- .../typescript/tests/references.web.test.ts | 18 ++- .../zod/generated/v3/references.ts | 15 +++ .../zod/generated/v4-web/references.ts | 17 +++ .../zod/generated/v4/references.ts | 17 +++ .../zod/tests/references.admin.test.ts | 14 +++ .../zod/tests/references.parsing.test.ts | 10 ++ .../zod/tests/references.web.test.ts | 10 ++ 41 files changed, 815 insertions(+), 106 deletions(-) diff --git a/docs/schema/types.mdx b/docs/schema/types.mdx index 44006e8d..5ca64ee1 100644 --- a/docs/schema/types.mdx +++ b/docs/schema/types.mdx @@ -305,7 +305,7 @@ Represents a Firestore [document reference](https://firebase.google.com/docs/ref - The `document-reference` type intentionally does not encode the target collection. Firestore itself does not enforce a target collection on stored references; if you need to enforce that contract, validate it in application code or in your security rules. + Without an explicit `model`, the `document-reference` type intentionally does not encode the target document's shape. If you want the generated TypeScript and Zod types to be narrowed to a specific target model, use the parameterized form below. @@ -336,6 +336,74 @@ function isValidExample(data) { +### Parameterized form + +`document-reference` also accepts an object form with an optional `model` field that names the target document (or alias) model. Typesync validates that the referenced model exists in the same schema and threads the target through to the generated code. + +The narrowing only takes effect on platforms whose Firestore SDK class is generic: + +- **TypeScript** — `firestore.DocumentReference` is emitted instead of `firestore.DocumentReference`. +- **Zod** — the inferred type from `z.infer` carries the same narrowed shape. The runtime `instanceof` check is identical for both forms — narrowing is purely at the type level. +- **Python**, **Swift**, **Security Rules** — these SDK classes are not generic, so the emitted type is identical to the bare form. The schema-level validation that `model` resolves still runs. + + + Zod self-references (e.g. `NoteLink.next: DocumentReference`) are emitted **without** the generic in Zod to avoid `TS2456 Type alias circularly references itself` on the `z.infer`-derived type. Pure `generate-ts` output is unaffected and emits the narrowed self-reference. Cross-model references narrow on both targets. + + + + +```yaml definition.yml +Author: + model: document + path: authors/{authorId} + type: + type: object + fields: + name: { type: string } + +Book: + model: document + path: books/{bookId} + type: + type: object + fields: + title: { type: string } + author: + type: + type: document-reference + model: Author +``` + +```ts models.ts +export interface Author { + name: string; +} +export interface Book { + title: string; + author: firestore.DocumentReference; +} +``` + +```python models.py +class Author(TypesyncModel): + name: str +class Book(TypesyncModel): + title: str + author: firestore.DocumentReference # not narrowed; Python SDK class is not generic +``` + +```swift models.swift +struct Author: Codable { + var name: String +} +struct Book: Codable { + var title: String + var author: DocumentReference // not narrowed; iOS SDK class is not generic +} +``` + + + ## `literal` Represents a literal type. diff --git a/schema.local.json b/schema.local.json index c78fae93..32b3d602 100644 --- a/schema.local.json +++ b/schema.local.json @@ -34,57 +34,78 @@ { "anyOf": [ { - "type": "string", - "const": "any", - "description": "Any type." - }, - { - "type": "string", - "const": "unknown", - "description": "An unknown type." - }, - { - "type": "string", - "const": "nil", - "description": "A nil type." - }, - { - "type": "string", - "const": "string", - "description": "A string type." - }, - { - "type": "string", - "const": "boolean", - "description": "A boolean type." - }, - { - "type": "string", - "const": "int", - "description": "An integer type." - }, - { - "type": "string", - "const": "double", - "description": "A double type." - }, - { - "type": "string", - "const": "timestamp", - "description": "A timestamp type." - }, - { - "type": "string", - "const": "bytes", - "description": "A bytes type." + "anyOf": [ + { + "type": "string", + "const": "any", + "description": "Any type." + }, + { + "type": "string", + "const": "unknown", + "description": "An unknown type." + }, + { + "type": "string", + "const": "nil", + "description": "A nil type." + }, + { + "type": "string", + "const": "string", + "description": "A string type." + }, + { + "type": "string", + "const": "boolean", + "description": "A boolean type." + }, + { + "type": "string", + "const": "int", + "description": "An integer type." + }, + { + "type": "string", + "const": "double", + "description": "A double type." + }, + { + "type": "string", + "const": "timestamp", + "description": "A timestamp type." + }, + { + "type": "string", + "const": "bytes", + "description": "A bytes type." + }, + { + "type": "string", + "const": "document-reference", + "description": "A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name." + } + ], + "description": "A primitive type" }, { - "type": "string", - "const": "document-reference", - "description": "A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name." + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "document-reference" + }, + "model": { + "description": "Name of the target model (alias or document) that the referenced document belongs to. When specified, generators that support generics (TypeScript and Zod) narrow the emitted type to `DocumentReference` instead of `DocumentReference`. Generators without generic `DocumentReference` classes (Python, Swift) ignore this field at the type level but the schema validator still checks that the named model exists.", + "type": "string", + "minLength": 1 + } + }, + "required": ["type"], + "additionalProperties": false, + "description": "A Firestore document reference type, parameterized by the target model. Equivalent to the bare `document-reference` string form when `model` is omitted." } - ], - "description": "A primitive type" + ] }, { "anyOf": [ diff --git a/src/converters/definition-to-schema.ts b/src/converters/definition-to-schema.ts index 0b47fe32..b66e89a8 100644 --- a/src/converters/definition-to-schema.ts +++ b/src/converters/definition-to-schema.ts @@ -29,6 +29,12 @@ export function primitiveTypeToSchema(t: definition.types.Primitive): schema.typ } } +export function parameterizedDocumentReferenceTypeToSchema( + t: definition.types.ParameterizedDocumentReference +): schema.types.DocumentReference { + return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' }; +} + export function stringLiteralTypeToSchema(t: definition.types.StringLiteral): schema.types.StringLiteral { return { type: 'string-literal', value: t.value }; } @@ -147,6 +153,8 @@ export function typeToSchema(t: definition.types.Type): schema.types.Type { } switch (t.type) { + case 'document-reference': + return parameterizedDocumentReferenceTypeToSchema(t); case 'literal': return literalTypeToSchema(t); case 'enum': diff --git a/src/core/zod/__tests__/build-zod-schema.test.ts b/src/core/zod/__tests__/build-zod-schema.test.ts index 0501e384..fbc35bea 100644 --- a/src/core/zod/__tests__/build-zod-schema.test.ts +++ b/src/core/zod/__tests__/build-zod-schema.test.ts @@ -65,6 +65,50 @@ describe('buildZodSchemaMap()', () => { expect(getSchemaForModel(s, 'ReferenceAlias').safeParse({ path: 'users/abc' }).success).toBe(false); expect(getSchemaForModel(s, 'ReferenceAlias').safeParse('users/abc').success).toBe(false); }); + + it('treats the parameterized form identically to the bare form at runtime', () => { + // The runtime emitter discards the target-model hint: the validator + // only checks `instanceof DocumentReference`. Narrowing happens purely + // at the type level via the codegen emitter; the runtime semantics are + // the same for both forms. + const sParam = schema.createSchemaFromDefinition({ + Target: { + model: 'document', + path: 'targets/{targetId}', + type: { type: 'object', fields: { name: { type: 'string' } } }, + }, + Source: { + model: 'document', + path: 'sources/{sourceId}', + type: { + type: 'object', + fields: { ref: { type: { type: 'document-reference', model: 'Target' } } }, + }, + }, + }); + const sourceSchema = getSchemaForModel(sParam, 'Source'); + const fakeRef = Object.create(DocumentReference.prototype) as DocumentReference; + expect(sourceSchema.safeParse({ ref: fakeRef }).success).toBe(true); + expect(sourceSchema.safeParse({ ref: 'targets/canonical' }).success).toBe(false); + }); + + it('rejects a parameterized document-reference whose `model` does not exist in the schema', () => { + // Schema-level validation guards us against typos and dangling + // references at definition-parse time, before any code generation + // runs. + expect(() => + schema.createSchemaFromDefinition({ + Source: { + model: 'document', + path: 'sources/{sourceId}', + type: { + type: 'object', + fields: { ref: { type: { type: 'document-reference', model: 'Ghost' } } }, + }, + }, + }) + ).toThrow(/Ghost/); + }); }); describe('enums and literals', () => { diff --git a/src/core/zod/__tests__/codegen-emitter.test.ts b/src/core/zod/__tests__/codegen-emitter.test.ts index 7b5d7854..e1106492 100644 --- a/src/core/zod/__tests__/codegen-emitter.test.ts +++ b/src/core/zod/__tests__/codegen-emitter.test.ts @@ -4,14 +4,18 @@ import { buildZodFromType } from '../build-zod-schema.js'; function emit( type: schema.types.Type, - overrides: { variant?: 'v3' | 'v4'; target?: 'firebase-admin@13' | 'firebase@10' | 'react-native-firebase@21' } = {} + overrides: { + variant?: 'v3' | 'v4'; + target?: 'firebase-admin@13' | 'firebase@10' | 'react-native-firebase@21'; + currentModel?: string; + } = {} ) { const emitter = createCodegenZodEmitter({ variant: overrides.variant ?? 'v4', target: overrides.target ?? 'firebase-admin@13', getSchemaIdentifierForModel: name => `${name}Schema`, }); - return buildZodFromType(type, emitter); + return buildZodFromType(type, emitter, { currentModel: overrides.currentModel }); } describe('createCodegenZodEmitter()', () => { @@ -92,6 +96,51 @@ describe('createCodegenZodEmitter()', () => { expect(emit({ type: 'document-reference' }, { target: 'firebase@10' })).toBe(expected); expect(emit({ type: 'document-reference' }, { target: 'react-native-firebase@21' })).toBe(expected); }); + + it('narrows the cast generic to the target model when document-reference has a `model` and the target differs from the enclosing model', () => { + // Parameterized form: `model: Target` → emitter narrows the cast's + // generic so `z.infer` carries `DocumentReference`. + const narrowed = emit( + { type: 'document-reference', model: 'Target' }, + { target: 'firebase-admin@13', currentModel: 'NoteLink' } + ); + expect(narrowed).toBe( + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + + const narrowedWeb = emit( + { type: 'document-reference', model: 'Target' }, + { target: 'firebase@10', currentModel: 'NoteLink' } + ); + expect(narrowedWeb).toBe( + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + }); + + it('suppresses narrowing when the document-reference target equals the enclosing model (self-reference)', () => { + // A self-reference (e.g. `NoteLink.next: DocumentReference`) + // would otherwise trigger TS2456 ("circularly references itself") on + // the `z.infer`-derived type. The codegen falls back to the unnarrowed + // cast in that case. The pure `generate-ts` output is unaffected and + // emits the narrowed self-reference. + const out = emit( + { type: 'document-reference', model: 'NoteLink' }, + { target: 'firebase-admin@13', currentModel: 'NoteLink' } + ); + expect(out).toBe( + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + }); + + it('emits the narrow even when there is no enclosing model (e.g. ad-hoc walks)', () => { + const out = emit( + { type: 'document-reference', model: 'Foo' }, + { target: 'firebase-admin@13', currentModel: undefined } + ); + expect(out).toBe( + 'z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + }); }); describe('records, arrays, and tuples', () => { diff --git a/src/core/zod/_codegen-emitter.ts b/src/core/zod/_codegen-emitter.ts index d299c307..8819023d 100644 --- a/src/core/zod/_codegen-emitter.ts +++ b/src/core/zod/_codegen-emitter.ts @@ -45,7 +45,6 @@ export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmi const timestampExpression = expressionForTimestampInstanceCheck(target); const bytesExpression = expressionForBytesInstanceCheck(target); - const documentReferenceExpression = expressionForDocumentReferenceInstanceCheck(target); return { any: () => 'z.any()', @@ -57,7 +56,18 @@ export function createCodegenZodEmitter(config: ZodCodegenEmitterConfig): ZodEmi double: () => 'z.number()', timestamp: () => `z.instanceof(${timestampExpression})`, bytes: () => `z.instanceof(${bytesExpression})`, - documentReference: () => `z.instanceof(${documentReferenceExpression})`, + documentReference: (targetModel, currentModel) => { + // Narrow only when the target is a *different* model from the one + // whose schema we're currently emitting. A self-reference (e.g. + // `NoteLink.next: DocumentReference`) would otherwise feed + // `NoteLink`'s inferred type back into its own schema declaration, + // triggering TS2456 ("Type alias circularly references itself") and + // TS7022 (implicit `any` on the schema) at compile time. Pure + // TypeScript `generate-ts` handles self-references natively via + // interface hoisting; Zod's `z.infer` chain cannot. + const effectiveModel = targetModel === currentModel ? undefined : targetModel; + return `z.instanceof(${expressionForDocumentReferenceInstanceCheck(target, effectiveModel)})`; + }, stringLiteral: value => `z.literal(${JSON.stringify(value)})`, intLiteral: value => `z.literal(${value})`, @@ -181,7 +191,13 @@ function expressionForBytesInstanceCheck(target: TSGenerationTarget): string { } } -function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget): string { +function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget, model: string | undefined): string { + // The generic parameter narrows the inferred type from + // `z.infer` to `DocumentReference`. The cast + // is erased at runtime; the `instanceof` check still runs against the real + // class object so any `DocumentReference` instance is accepted regardless + // of its (unobservable) generic parameter. + const narrowed = model !== undefined ? `firestore.DocumentReference<${model}>` : 'firestore.DocumentReference'; switch (target) { case 'firebase-admin@13': case 'firebase-admin@12': @@ -191,7 +207,7 @@ function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget) // public constructor (technically the constructor is `private` in // newer admin SDK typings too — we cast through `unknown` defensively // so the check compiles across every supported admin major). - return 'firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference'; + return `firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed}`; case 'firebase@11': case 'firebase@10': case 'firebase@9': @@ -203,7 +219,7 @@ function expressionForDocumentReferenceInstanceCheck(target: TSGenerationTarget) // cast trick used for `firestore.Bytes` is required here. The cast is // erased at runtime; the instance check still runs against the real // class object. - return 'firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference'; + return `firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed}`; default: assertNever(target); } diff --git a/src/core/zod/_emitter.ts b/src/core/zod/_emitter.ts index d7288ca2..236a52f3 100644 --- a/src/core/zod/_emitter.ts +++ b/src/core/zod/_emitter.ts @@ -29,11 +29,25 @@ export interface ZodEmitter { * emitter uses `z.instanceof(DocumentReference)`; the codegen emitter * mirrors that against the active TypeScript Firebase SDK target. * + * When `targetModel` is provided (i.e. the schema's `document-reference` + * is the parameterized form), the codegen emitter narrows the cast to + * `DocumentReference` so the inferred type from + * `z.infer` carries the target shape. The runtime emitter + * ignores `targetModel` because the `instanceof` check is the same either + * way. + * + * `currentModel` is the name of the model whose Zod schema currently being + * built. The codegen emitter uses it to suppress narrowing for + * self-references (`targetModel === currentModel`), which would otherwise + * trigger TS2456 (`Type alias circularly references itself`) on the + * inferred type. `undefined` means "no enclosing model" (e.g. ad-hoc + * validation passes); narrowing is always emitted in that case. + * * Named to disambiguate from the unrelated `reference(modelName)` below, * which emits a reference to another model (an alias reference) in the * generated schema. */ - documentReference(): TOut; + documentReference(targetModel: string | undefined, currentModel: string | undefined): TOut; stringLiteral(value: string): TOut; intLiteral(value: number): TOut; diff --git a/src/core/zod/_runtime-emitter.ts b/src/core/zod/_runtime-emitter.ts index 5daa0dba..d264dba8 100644 --- a/src/core/zod/_runtime-emitter.ts +++ b/src/core/zod/_runtime-emitter.ts @@ -32,7 +32,13 @@ export function createRuntimeZodEmitter(registry: RuntimeZodRegistry): ZodEmitte // typings, which violates the `new (...args: any[]) => any` constraint // `z.instanceof` enforces. The cast is erased at runtime; the instance // check still runs against the real class object. - documentReference: () => z.instanceof(DocumentReference as unknown as new (...args: never[]) => DocumentReference), + // `targetModel` / `currentModel` are irrelevant at runtime: the + // validator only needs to confirm the value is a `DocumentReference` + // instance. Narrowing to a target model happens purely at the type + // level via the codegen emitter's generic cast; the runtime emitter + // discards both hints. + documentReference: (_targetModel, _currentModel) => + z.instanceof(DocumentReference as unknown as new (...args: never[]) => DocumentReference), stringLiteral: value => z.literal(value), intLiteral: value => z.literal(value), diff --git a/src/core/zod/build-zod-schema.ts b/src/core/zod/build-zod-schema.ts index 42a0cda9..d126dc98 100644 --- a/src/core/zod/build-zod-schema.ts +++ b/src/core/zod/build-zod-schema.ts @@ -5,13 +5,32 @@ import { assertNever } from '../../util/assert.js'; import type { ZodEmitter } from './_emitter.js'; import { type RuntimeZodRegistry, createRuntimeZodEmitter } from './_runtime-emitter.js'; +/** + * Optional context threaded through `buildZodFromType` while walking a model's + * type tree. Currently only carries the enclosing model name so the + * `document-reference` emitter can detect self-references and suppress + * narrowing that would otherwise trigger TS2456 on the inferred type. + */ +export interface BuildZodFromTypeContext { + /** + * Name of the model whose Zod schema is currently being built (alias or + * document). `undefined` for ad-hoc walks that aren't bound to a specific + * model (e.g. driving the runtime emitter directly). + */ + currentModel?: string; +} + /** * Walks a Typesync schema type and drives the specified emitter to produce an output * value. This function is the single place in the codebase where schema types are * translated into Zod-shaped constructs, so both runtime validation and future codegen * share the exact same rules. */ -export function buildZodFromType(type: schema.types.Type, emitter: ZodEmitter): TOut { +export function buildZodFromType( + type: schema.types.Type, + emitter: ZodEmitter, + context: BuildZodFromTypeContext = {} +): TOut { switch (type.type) { case 'any': return emitter.any(); @@ -32,7 +51,7 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit case 'bytes': return emitter.bytes(); case 'document-reference': - return emitter.documentReference(); + return emitter.documentReference(type.model, context.currentModel); case 'string-literal': return emitter.stringLiteral(type.value); case 'int-literal': @@ -44,27 +63,27 @@ export function buildZodFromType(type: schema.types.Type, emitter: ZodEmit case 'int-enum': return emitter.intEnum(type.members.map(m => m.value)); case 'tuple': - return emitter.tuple(type.elements.map(el => buildZodFromType(el, emitter))); + return emitter.tuple(type.elements.map(el => buildZodFromType(el, emitter, context))); case 'list': - return emitter.array(buildZodFromType(type.elementType, emitter)); + return emitter.array(buildZodFromType(type.elementType, emitter, context)); case 'map': - return emitter.record(buildZodFromType(type.valueType, emitter)); + return emitter.record(buildZodFromType(type.valueType, emitter, context)); case 'object': return emitter.object( type.fields.map(field => ({ name: field.name, - value: buildZodFromType(field.type, emitter), + value: buildZodFromType(field.type, emitter, context), optional: field.optional, docs: field.docs, })), type.additionalFields ); case 'simple-union': - return emitter.simpleUnion(type.variants.map(v => buildZodFromType(v, emitter))); + return emitter.simpleUnion(type.variants.map(v => buildZodFromType(v, emitter, context))); case 'discriminated-union': return emitter.discriminatedUnion( type.discriminant, - type.variants.map(v => buildZodFromType(v, emitter)) + type.variants.map(v => buildZodFromType(v, emitter, context)) ); case 'alias': return emitter.reference(type.name); diff --git a/src/definition/_guards.ts b/src/definition/_guards.ts index 09c5f32e..1e2ae41d 100644 --- a/src/definition/_guards.ts +++ b/src/definition/_guards.ts @@ -63,3 +63,12 @@ export function isSimpleUnionType(t: types.Type): t is types.SimpleUnion { export function isAliasType(t: types.Type): t is types.Alias { return !isPrimitiveType(t) && typeof t === 'string'; } + +/** + * Recognizes the object form of `document-reference`, i.e. + * `{ type: 'document-reference', model?: string }`. The bare string form + * `'document-reference'` is recognized by `isPrimitiveType` instead. + */ +export function isParameterizedDocumentReferenceType(t: types.Type): t is types.ParameterizedDocumentReference { + return typeof t === 'object' && t !== null && (t as { type?: unknown }).type === 'document-reference'; +} diff --git a/src/definition/impl/_zod-schemas.ts b/src/definition/impl/_zod-schemas.ts index 90f31793..2911f64d 100644 --- a/src/definition/impl/_zod-schemas.ts +++ b/src/definition/impl/_zod-schemas.ts @@ -26,6 +26,22 @@ export const documentReferenceType = z 'A Firestore document reference type. Use this for fields that store a pointer to another Firestore document (e.g. a `DocumentReference` to `users/alice`) rather than the document id as a `string`. Firestore only allows storing document references in fields (not collection references), hence the explicit name.' ); +export const parameterizedDocumentReferenceType = z + .object({ + type: z.literal('document-reference'), + model: z + .string() + .min(1) + .optional() + .describe( + 'Name of the target model (alias or document) that the referenced document belongs to. When specified, generators that support generics (TypeScript and Zod) narrow the emitted type to `DocumentReference` instead of `DocumentReference`. Generators without generic `DocumentReference` classes (Python, Swift) ignore this field at the type level but the schema validator still checks that the named model exists.' + ), + }) + .strict() + .describe( + 'A Firestore document reference type, parameterized by the target model. Equivalent to the bare `document-reference` string form when `model` is omitted.' + ); + export const primitiveType = z .union([ anyType, @@ -177,6 +193,7 @@ export const aliasType = z.string().describe('An alias type.'); export const type: z.ZodType = z.lazy(() => primitiveType + .or(parameterizedDocumentReferenceType) .or(literalType) .or(enumType) .or(tupleType) diff --git a/src/definition/types/_types.ts b/src/definition/types/_types.ts index 80637720..73d8a5c8 100644 --- a/src/definition/types/_types.ts +++ b/src/definition/types/_types.ts @@ -17,17 +17,39 @@ export type Timestamp = 'timestamp'; export type Bytes = 'bytes'; /** - * A Firestore document reference value. Use this when a field stores a - * pointer to another document (e.g. `users/alice`) directly, rather than - * the document's id as a `string`. + * A Firestore document reference value (string form). Use this when a field + * stores a pointer to another document (e.g. `users/alice`) directly, rather + * than the document's id as a `string`. * * Firestore only allows storing document references in fields (not collection * references), so the name explicitly mirrors the SDK class `DocumentReference`. + * + * For the *parameterized* form (which narrows the generated TypeScript type to + * a specific target model), see `ParameterizedDocumentReference`. */ export type DocumentReference = 'document-reference'; export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference; +/** + * A Firestore document reference value with an optional `model` field that + * names the target document/alias model. When the target model is specified, + * generators that support generics (currently TypeScript and Zod) narrow the + * emitted type from `DocumentReference` to + * `DocumentReference`. Generators whose SDK class is not generic + * (Python, Swift) ignore the `model` field at the type level but still + * validate that it refers to a defined model. + */ +export interface ParameterizedDocumentReference { + type: 'document-reference'; + /** + * Name of the target model (alias or document). Must match a model defined + * in the same schema. When omitted, behaves identically to the bare + * `'document-reference'` string form. + */ + model?: string; +} + export interface StringLiteral { type: 'literal'; value: string; @@ -127,4 +149,14 @@ export type Union = DiscriminatedUnion | SimpleUnion; export type Alias = string; -export type Type = Primitive | Literal | Enum | Tuple | List | Map | Object | Union | Alias; +export type Type = + | Primitive + | ParameterizedDocumentReference + | Literal + | Enum + | Tuple + | List + | Map + | Object + | Union + | Alias; diff --git a/src/generators/python/_converters.ts b/src/generators/python/_converters.ts index 9542d6c6..12370b24 100644 --- a/src/generators/python/_converters.ts +++ b/src/generators/python/_converters.ts @@ -38,8 +38,8 @@ export function bytesTypeToPython(_t: schema.python.types.Bytes): python.Bytes { return { type: 'bytes' }; } -export function documentReferenceTypeToPython(_t: schema.python.types.DocumentReference): python.DocumentReference { - return { type: 'document-reference' }; +export function documentReferenceTypeToPython(t: schema.python.types.DocumentReference): python.DocumentReference { + return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' }; } export function literalTypeToPython(t: schema.python.types.Literal): python.Literal { diff --git a/src/generators/swift/_converters.ts b/src/generators/swift/_converters.ts index 6d6677eb..a05ad204 100644 --- a/src/generators/swift/_converters.ts +++ b/src/generators/swift/_converters.ts @@ -38,8 +38,8 @@ export function bytesTypeToSwift(_t: schema.swift.types.Bytes): swift.Data { return { type: 'data' }; } -export function documentReferenceTypeToSwift(_t: schema.swift.types.DocumentReference): swift.DocumentReference { - return { type: 'document-reference' }; +export function documentReferenceTypeToSwift(t: schema.swift.types.DocumentReference): swift.DocumentReference { + return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' }; } export function stringLiteralTypeToSwift(_t: schema.swift.types.StringLiteral): swift.String { diff --git a/src/generators/ts/__tests__/generator.test.ts b/src/generators/ts/__tests__/generator.test.ts index b9548015..05fc147a 100644 --- a/src/generators/ts/__tests__/generator.test.ts +++ b/src/generators/ts/__tests__/generator.test.ts @@ -294,6 +294,21 @@ describe('TSGeneratorImpl', () => { ]); }); + it('threads the parameterized `model` through document-reference into the TS platform type so the renderer can narrow it', () => { + const s = schema.createSchemaFromDefinition({ + User: { + model: 'document', + path: 'users/{userId}', + type: { type: 'object', fields: { name: { type: 'string' } } }, + }, + OwnerRef: { model: 'alias', type: { type: 'document-reference', model: 'User' } }, + }); + + const generation = createGenerator().generate(s); + const owner = generation.declarations.find(d => d.modelName === 'OwnerRef'); + expect(owner?.modelType).toEqual({ type: 'document-reference', model: 'User' }); + }); + it('does not mutate the input schema', () => { const s = schema.createSchemaFromDefinition({ Profile: { diff --git a/src/generators/ts/_converters.ts b/src/generators/ts/_converters.ts index 2fb60091..bae92d0f 100644 --- a/src/generators/ts/_converters.ts +++ b/src/generators/ts/_converters.ts @@ -38,8 +38,8 @@ export function bytesTypeToTS(_t: schema.ts.types.Bytes): ts.Bytes { return { type: 'bytes' }; } -export function documentReferenceTypeToTS(_t: schema.ts.types.DocumentReference): ts.DocumentReference { - return { type: 'document-reference' }; +export function documentReferenceTypeToTS(t: schema.ts.types.DocumentReference): ts.DocumentReference { + return t.model !== undefined ? { type: 'document-reference', model: t.model } : { type: 'document-reference' }; } export function stringLiteralTypeToTS(t: schema.ts.types.StringLiteral): ts.Literal { diff --git a/src/generators/zod/__tests__/generator.test.ts b/src/generators/zod/__tests__/generator.test.ts index 174956e4..9648f365 100644 --- a/src/generators/zod/__tests__/generator.test.ts +++ b/src/generators/zod/__tests__/generator.test.ts @@ -203,6 +203,38 @@ describe('ZodGeneratorImpl', () => { expect(generation.usesDocumentReference).toBe(true); }); + it('narrows the cast generic for a parameterized cross-model reference and suppresses it for a self-reference', () => { + const s = schema.createSchemaFromDefinition({ + Target: { + model: 'document', + path: 'targets/{targetId}', + type: { type: 'object', fields: { name: { type: 'string' } } }, + }, + NoteLink: { + model: 'document', + path: 'notes/{noteId}', + type: { + type: 'object', + fields: { + linked: { type: { type: 'document-reference', model: 'Target' } }, + next: { type: { type: 'document-reference', model: 'NoteLink' } }, + }, + }, + }, + }); + const generation = createGenerator({ variant: 'v4' }).generate(s); + const noteLink = generation.declarations.find(d => d.modelName === 'NoteLink'); + // Cross-model references narrow to the target model's name. + expect(noteLink?.expression).toContain( + 'linked: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + // Self-references fall back to the unnarrowed cast to avoid TS2456 on + // the `z.infer`-derived type alias. + expect(noteLink?.expression).toContain( + 'next: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference)' + ); + }); + it('does not mutate the input schema', () => { const s = schema.createSchemaFromDefinition({ Profile: { diff --git a/src/generators/zod/_impl.ts b/src/generators/zod/_impl.ts index d7720237..08d96384 100644 --- a/src/generators/zod/_impl.ts +++ b/src/generators/zod/_impl.ts @@ -42,7 +42,10 @@ class ZodGeneratorImpl implements ZodGenerator { modelKind: 'alias' | 'document', emitter: ReturnType ): ZodSchemaDeclaration { - const expression = this.attachModelDocs(buildZodFromType(model.type, emitter), model.docs); + const expression = this.attachModelDocs( + buildZodFromType(model.type, emitter, { currentModel: model.name }), + model.docs + ); return { type: 'schema', modelName: model.name, diff --git a/src/platforms/python/_types.ts b/src/platforms/python/_types.ts index 1720a9dc..c897ac31 100644 --- a/src/platforms/python/_types.ts +++ b/src/platforms/python/_types.ts @@ -39,9 +39,16 @@ export interface Bytes { * the generated Python output (i.e. `google.cloud.firestore.DocumentReference`, * which is what `firebase_admin.firestore.client()` hands back). Firestore * only allows storing document references in fields, hence the explicit name. + * + * The optional `model` field carries the target-model name through from the + * schema for completeness. The Python Firestore client's + * `DocumentReference` class is not generic, so the emitted type is always + * `firestore.DocumentReference` regardless of `model`; the schema-level + * validation that `model` resolves still runs. */ export interface DocumentReference { readonly type: 'document-reference'; + readonly model?: string; } export type Primitive = Undefined | Any | None | Str | Bool | Int | Float | Datetime | Bytes | DocumentReference; diff --git a/src/platforms/swift/_types.ts b/src/platforms/swift/_types.ts index 8df98517..b3233731 100644 --- a/src/platforms/swift/_types.ts +++ b/src/platforms/swift/_types.ts @@ -33,9 +33,15 @@ export interface Data { /** * A Firestore document reference. Maps to `DocumentReference` (from * `FirebaseFirestore`) in the generated Swift output. + * + * The optional `model` field carries the schema-level target-model name for + * completeness. The Firebase iOS SDK's `DocumentReference` class is not + * generic, so the emitted Swift type is always `DocumentReference`; the + * schema-level validation that `model` resolves still runs. */ export interface DocumentReference { readonly type: 'document-reference'; + readonly model?: string; } export type Primitive = Any | Nil | String | Bool | Int | Double | Date | Data | DocumentReference; diff --git a/src/platforms/ts/_expressions.ts b/src/platforms/ts/_expressions.ts index 50211042..7746f610 100644 --- a/src/platforms/ts/_expressions.ts +++ b/src/platforms/ts/_expressions.ts @@ -91,11 +91,15 @@ export function expressionForBytesType(_t: Bytes, options: ExpressionOptions): E /** * The Firestore SDKs all expose document references as `DocumentReference` * under the `firestore` import, so we emit the same expression for every - * target. The reference is parameterized by `firestore.DocumentData` so the - * type lines up with what the SDKs hand back when no per-collection converter - * is in play. + * target. When `model` is set, the reference is narrowed to + * `firestore.DocumentReference`; otherwise it falls back to + * `firestore.DocumentReference`, which lines up with + * what the SDKs hand back when no per-collection converter is in play. */ -export function expressionForDocumentReferenceType(_t: DocumentReference): Expression { +export function expressionForDocumentReferenceType(t: DocumentReference): Expression { + if (t.model !== undefined) { + return { content: `firestore.DocumentReference<${t.model}>` }; + } return { content: 'firestore.DocumentReference' }; } diff --git a/src/platforms/ts/_types.ts b/src/platforms/ts/_types.ts index 46e82218..99db8e8b 100644 --- a/src/platforms/ts/_types.ts +++ b/src/platforms/ts/_types.ts @@ -34,11 +34,18 @@ export interface Bytes { * A Firestore document reference (the `firestore.DocumentReference` runtime * class). Maps to `firestore.DocumentReference` in * the generated TypeScript output regardless of the active Firebase SDK - * target. Firestore only supports document references as stored values, so - * the type intentionally mirrors the SDK class name. + * target, except when `model` is set: then the emitted type is narrowed to + * `firestore.DocumentReference`. Firestore only supports + * document references as stored values, so the type intentionally mirrors + * the SDK class name. */ export interface DocumentReference { readonly type: 'document-reference'; + /** + * Optional name of the target model. When set, the TypeScript expression + * is narrowed to `firestore.DocumentReference`. + */ + readonly model?: string; } export type Primitive = Any | Unknown | Null | String | Boolean | Number | Timestamp | Bytes | DocumentReference; diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts index cbab4533..0f3473ac 100644 --- a/src/renderers/ts/__tests__/renderer.test.ts +++ b/src/renderers/ts/__tests__/renderer.test.ts @@ -201,4 +201,33 @@ describe('TSRendererImpl', () => { expectedSubstring ); }); + + it('narrows document-reference to firestore.DocumentReference when the parameterized form has a `model`', async () => { + const generation: TSGeneration = { + type: 'ts', + declarations: [ + { + type: 'alias', + modelName: 'OwnerRef', + modelType: { type: 'document-reference', model: 'User' }, + modelDocs: null, + }, + ], + }; + + const expectedSubstring = 'export type OwnerRef = firestore.DocumentReference;'; + + // The narrowing is independent of the SDK target: every Firestore SDK + // exposes `DocumentReference` as a generic class so the emitted + // expression is identical across families. + await expect((await createRenderer({ target: 'firebase-admin@13' }).render(generation)).content).toContain( + expectedSubstring + ); + await expect((await createRenderer({ target: 'firebase@10' }).render(generation)).content).toContain( + expectedSubstring + ); + await expect((await createRenderer({ target: 'react-native-firebase@21' }).render(generation)).content).toContain( + expectedSubstring + ); + }); }); diff --git a/src/schema/abstract.ts b/src/schema/abstract.ts index 645f108e..c6a7c1ea 100644 --- a/src/schema/abstract.ts +++ b/src/schema/abstract.ts @@ -95,6 +95,10 @@ export abstract class AbstractSchema, D extends Do return this.aliasModelsById.get(modelName); } + public getDocumentModel(modelName: string) { + return this.documentModelsById.get(modelName); + } + protected cloneModels>(toSchema: S) { const aliasModelClones = Array.from(this.aliasModelsById.values()).map(m => m.clone() as A); const documentModelClones = Array.from(this.documentModels.values()).map(m => m.clone() as D); diff --git a/src/schema/core/__tests__/validate-type.test.ts b/src/schema/core/__tests__/validate-type.test.ts index b04e14ca..ab3c298a 100644 --- a/src/schema/core/__tests__/validate-type.test.ts +++ b/src/schema/core/__tests__/validate-type.test.ts @@ -75,11 +75,36 @@ describe('schema.validateType()', () => { }); describe('document-reference', () => { - it('does not throw if the type is valid', () => { + it('does not throw for the bare (non-parameterized) form', () => { const schema = createSchema(); const t: types.DocumentReference = { type: 'document-reference' }; expect(() => schema.validateType(t)).not.toThrow(); }); + + it('does not throw when `model` refers to an existing alias model', () => { + const schema = createSchemaWithModels([ + createAliasModel({ + name: 'UserData', + docs: null, + value: { type: 'object', additionalFields: false, fields: [] }, + }), + ]); + const t: types.DocumentReference = { type: 'document-reference', model: 'UserData' }; + expect(() => schema.validateType(t)).not.toThrow(); + }); + + it('throws when `model` refers to a model that is not defined in the schema', () => { + const schema = createSchema(); + const t: types.DocumentReference = { type: 'document-reference', model: 'Ghost' }; + expect(() => schema.validateType(t)).toThrow(/Ghost/); + }); + + it('rejects an empty `model` string at parse time (forbidden by the zod schema)', () => { + const schema = createSchema(); + // Cast away the type-level constraint to test the runtime guard. + const t = { type: 'document-reference', model: '' } as unknown as types.DocumentReference; + expect(() => schema.validateType(t)).toThrow(); + }); }); describe('string-literal', () => { diff --git a/src/schema/core/_zod-schemas.ts b/src/schema/core/_zod-schemas.ts index 8cdbb57b..7c92983d 100644 --- a/src/schema/core/_zod-schemas.ts +++ b/src/schema/core/_zod-schemas.ts @@ -64,8 +64,25 @@ export function createZodSchemasForSchema(schema: Schema) { const documentReferenceType = z .object({ type: z.literal('document-reference'), + model: z.string().min(1).optional(), }) - .strict(); + .strict() + .superRefine((candidate, ctx) => { + if (candidate.model === undefined) return; + // The parameterized form must reference an existing model so that + // generators are guaranteed to find the target when emitting a + // narrowed `DocumentReference`. + const aliasModel = schema.getAliasModel(candidate.model); + const documentModel = schema.getDocumentModel(candidate.model); + if (aliasModel === undefined && documentModel === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The model name '${candidate.model}' referenced by a 'document-reference' type does not exist in this schema.`, + fatal: true, + }); + return z.NEVER; + } + }); const primitiveType = anyType .or(unknownType) diff --git a/src/schema/core/impl.ts b/src/schema/core/impl.ts index 136d3296..96c1ce8a 100644 --- a/src/schema/core/impl.ts +++ b/src/schema/core/impl.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { converters } from '../../converters/index.js'; import { definition } from '../../definition/index.js'; import { InvalidSchemaTypeError } from '../../errors/invalid-schema-type.js'; @@ -73,18 +75,61 @@ export class Schema extends SchemaClass< const parseRes = type.safeParse(t); if (!parseRes.success) { const { error } = parseRes; - const [issue] = error.issues; - if (issue) { - const { message } = issue; - throw new InvalidSchemaTypeError(message); - } else { - throw new InvalidSchemaTypeError('Cannot parse type due to an unexpected error.'); - } + const message = pickMostSpecificIssueMessage(error); + throw new InvalidSchemaTypeError(message ?? 'Cannot parse type due to an unexpected error.'); } return parseRes.data; } } +/** + * Picks the most informative issue message from a Zod error. + * + * Zod v4 surfaces union-branch failures as a single top-level + * `invalid_union` issue whose `message` is the generic "Invalid input" + * string; the per-branch issues (including `custom` issues raised by + * `superRefine`) live under `issue.errors[i][j]`. Naively returning + * `error.issues[0].message` therefore drops the user-friendly message the + * superRefines worked hard to produce (e.g. "The alias name 'Foo' does not + * refer to an existing model in this schema." or "The model name 'Ghost' + * referenced by a 'document-reference' type does not exist in this + * schema."). + * + * We walk every nested issue depth-first and prefer: + * 1. The first `code: 'custom'` issue we find (these are emitted by our + * `superRefine` hooks and are the most user-actionable). + * 2. Failing that, the first non-`invalid_union` issue we find (since + * `invalid_union` is the umbrella error we want to skip). + * 3. Failing that, the top-level message as a last resort. + * + * This keeps the existing top-level-message behavior for non-union failures + * and only changes what we surface when the top-level issue is the generic + * `invalid_union` wrapper. + */ +function pickMostSpecificIssueMessage(error: z.ZodError): string | undefined { + const seen: z.core.$ZodIssue[] = []; + const visit = (issue: z.core.$ZodIssue): void => { + seen.push(issue); + // `invalid_union` issues nest the per-branch failures in `.errors`. + const nested = (issue as { errors?: z.core.$ZodIssue[][] }).errors; + if (Array.isArray(nested)) { + for (const branch of nested) { + for (const childIssue of branch) { + visit(childIssue); + } + } + } + }; + for (const issue of error.issues) { + visit(issue); + } + const custom = seen.find(i => i.code === 'custom'); + if (custom !== undefined) return custom.message; + const nonUnion = seen.find(i => i.code !== 'invalid_union'); + if (nonUnion !== undefined) return nonUnion.message; + return error.issues[0]?.message; +} + export function createAliasModel(params: CreateAliasModelParams) { const { name, docs, value } = params; return new AliasModel(name, docs, value); diff --git a/src/schema/generic.ts b/src/schema/generic.ts index ece0adfc..acb89463 100644 --- a/src/schema/generic.ts +++ b/src/schema/generic.ts @@ -49,9 +49,21 @@ export interface Bytes { * Firestore only allows storing document references in fields (not collection * references), so the name mirrors the SDK class `DocumentReference` exactly * to make that constraint obvious at the schema-definition layer. + * + * When `model` is set, generators whose SDK class is generic (TypeScript and + * Zod) narrow the emitted type to `DocumentReference`; the + * generators whose SDK class is not generic (Python, Swift) ignore the model + * at the type level but the schema validator still rejects references to + * unknown models so the contract is checked everywhere. */ export interface DocumentReference { type: 'document-reference'; + /** + * Optional name of the target model (alias or document). Validated against + * the schema when present: a reference whose `model` doesn't resolve to a + * model in the same schema is rejected at schema-build time. + */ + model?: string; } export type Primitive = Any | Unknown | Nil | String | Boolean | Int | Double | Timestamp | Bytes | DocumentReference; @@ -231,5 +243,6 @@ export interface Schema { addModelGroup(models: (A | D)[]): void; addModel(model: A | D): void; getAliasModel(modelName: string): A | undefined; + getDocumentModel(modelName: string): D | undefined; validateType(type: unknown): void; } diff --git a/tests/integration/_fixtures/samples/references/note-link.json b/tests/integration/_fixtures/samples/references/note-link.json index fc2a5710..60c88b35 100644 --- a/tests/integration/_fixtures/samples/references/note-link.json +++ b/tests/integration/_fixtures/samples/references/note-link.json @@ -7,5 +7,7 @@ "secondary": "targets/secondary", "tertiary": "targets/tertiary" }, + "linked_target_path": "targets/canonical", + "next_note_path": "notes/sibling-a", "created_at": "2024-05-09T10:00:00.000Z" } diff --git a/tests/integration/_fixtures/schemas/references.yml b/tests/integration/_fixtures/schemas/references.yml index 6116a5c0..4a191f00 100644 --- a/tests/integration/_fixtures/schemas/references.yml +++ b/tests/integration/_fixtures/schemas/references.yml @@ -14,12 +14,23 @@ # plain string + timestamp sit alongside so we confirm references coexist # with non-Firestore-typed fields without accidental coercion. # +# It also exercises the **parameterized** form of `document-reference`: +# `target_note` and `next_note` reference the `NoteLink` model itself +# (self-reference), and `linked_target` references the sibling `Target` +# document. On platforms whose SDK class is generic (TypeScript + Zod) the +# emitted type is narrowed to `DocumentReference` / +# `DocumentReference`; on platforms where it is not (Python, Swift) +# the schema-level validation that the target model exists still runs. +# # Doc-level convention used by every per-platform test: # * `target` is a reference to a sibling `/targets/{id}` document that -# stores the canonical entity the note links to. -# * `related` is a list of references to other notes (siblings in the -# same collection). -# * `by_label` is a map (free-form keys) whose values are references. +# stores the canonical entity the note links to (bare form). +# * `related` is a list of references to other notes (bare form). +# * `by_label` is a map (free-form keys) whose values are references (bare form). +# * `linked_target` is a parameterized reference whose target model is +# `Target`. +# * `next_note` is a parameterized reference whose target model is the +# same `NoteLink` (self-reference). NoteLink: model: document @@ -44,5 +55,28 @@ NoteLink: type: map valueType: document-reference docs: A map of label -> reference to exercise references nested in a map. + linked_target: + type: + type: document-reference + model: Target + docs: A parameterized reference whose target model is `Target` (different model). + next_note: + type: + type: document-reference + model: NoteLink + docs: A parameterized reference whose target model is `NoteLink` (self-reference). + created_at: + type: timestamp + +Target: + model: document + path: targets/{targetId} + docs: A minimal sibling document used as the target of parameterized references. + type: + type: object + fields: + name: + type: string + docs: Display name for the target. created_at: type: timestamp diff --git a/tests/integration/python/tests/test_references.py b/tests/integration/python/tests/test_references.py index c32a8422..2053968a 100644 --- a/tests/integration/python/tests/test_references.py +++ b/tests/integration/python/tests/test_references.py @@ -46,9 +46,14 @@ def test_note_link_round_trips_document_references_via_firestore_emulator( target = firestore_client.document(sample["target_path"]) related = [firestore_client.document(p) for p in sample["related_paths"]] by_label = {k: firestore_client.document(v) for k, v in sample["by_label_paths"].items()} + # Parameterized references: the Python `firestore.DocumentReference` class + # is not generic, so the generated Pydantic field type is identical to the + # bare form. We still exercise the round-trip to confirm the schema-level + # `model` validation accepts the references and the SDK round-trips them + # the same way. + linked_target = firestore_client.document(sample["linked_target_path"]) + next_note = firestore_client.document(sample["next_note_path"]) - # Sanity-check the fixture itself: distinct paths so a buggy SDK that - # aliased every reference to the same value would still be caught. assert target.path == sample["target_path"] assert len({r.path for r in related}) == len(related) assert len(related) == 3 @@ -60,6 +65,8 @@ def test_note_link_round_trips_document_references_via_firestore_emulator( "target": target, "related": related, "by_label": by_label, + "linked_target": linked_target, + "next_note": next_note, "created_at": sample["created_at"], } ) @@ -70,6 +77,8 @@ def test_note_link_round_trips_document_references_via_firestore_emulator( assert isinstance(note_link_in.target, firestore.DocumentReference) assert all(isinstance(r, firestore.DocumentReference) for r in note_link_in.related) assert all(isinstance(v, firestore.DocumentReference) for v in note_link_in.by_label.values()) + assert isinstance(note_link_in.linked_target, firestore.DocumentReference) + assert isinstance(note_link_in.next_note, firestore.DocumentReference) doc_ref = isolated_collection.document(uuid.uuid4().hex) doc_ref.set(note_link_in.model_dump()) @@ -97,6 +106,8 @@ def test_note_link_round_trips_document_references_via_firestore_emulator( assert {k: v.path for k, v in note_link_out.by_label.items()} == { k: v.path for k, v in by_label.items() } + assert note_link_out.linked_target.path == linked_target.path + assert note_link_out.next_note.path == next_note.path def test_note_link_rejects_string_target(references_module, firestore_client: firestore.Client) -> None: @@ -106,6 +117,7 @@ def test_note_link_rejects_string_target(references_module, firestore_client: fi NoteLink = references_module.NoteLink + placeholder_ref = firestore_client.document("targets/canonical") with pytest.raises(Exception): NoteLink.model_validate( { @@ -113,6 +125,8 @@ def test_note_link_rejects_string_target(references_module, firestore_client: fi "target": "targets/canonical", "related": [], "by_label": {}, + "linked_target": placeholder_ref, + "next_note": placeholder_ref, "created_at": "2024-01-01T00:00:00.000Z", } ) diff --git a/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift index 18dddf19..35ee159d 100644 --- a/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift +++ b/tests/integration/swift/Tests/TypesyncIntegrationTests/ReferencesIntegrationTests.swift @@ -34,6 +34,8 @@ struct ReferencesIntegrationTests { let targetPath = try unwrap(sample["target_path"] as? String, "references/note-link.target_path") let relatedPaths = try unwrap(sample["related_paths"] as? [String], "references/note-link.related_paths") let byLabelPaths = try unwrap(sample["by_label_paths"] as? [String: String], "references/note-link.by_label_paths") + let linkedTargetPath = try unwrap(sample["linked_target_path"] as? String, "references/note-link.linked_target_path") + let nextNotePath = try unwrap(sample["next_note_path"] as? String, "references/note-link.next_note_path") let label = try unwrap(sample["label"] as? String, "references/note-link.label") let createdAtRaw = try unwrap(sample["created_at"] as? String, "references/note-link.created_at") @@ -43,10 +45,13 @@ struct ReferencesIntegrationTests { for (k, v) in byLabelPaths { byLabel[k] = firestore.document(v) } + // Parameterized references: the Swift `DocumentReference` is not + // generic, so the generated property type is identical to the bare + // form. We still round-trip them to confirm the schema-level model + // validation accepts the references and the iOS SDK preserves paths. + let linkedTarget = firestore.document(linkedTargetPath) + let nextNote = firestore.document(nextNotePath) - // Sanity-check the fixture: distinct paths so a buggy bridge that - // aliased every reference to the same value would still be - // caught. #expect(target.path == targetPath) #expect(Set(related.map { $0.path }).count == related.count) #expect(related.count == 3) @@ -57,6 +62,8 @@ struct ReferencesIntegrationTests { target: target, related: related, byLabel: byLabel, + linkedTarget: linkedTarget, + nextNote: nextNote, createdAt: try parseISO8601(createdAtRaw) ) @@ -69,12 +76,15 @@ struct ReferencesIntegrationTests { // Wire-level expectations: the iOS SDK exposes references through // `DocumentReference` on the snapshot for top-level fields, list - // entries, and map values alike. + // entries, and map values alike. Both bare-form and parameterized + // references share the same on-wire shape. #expect(raw["target"] is DocumentReference) let rawRelated = try #require(raw["related"] as? [DocumentReference], "related should decode as [DocumentReference]") #expect(rawRelated.count == related.count) let rawByLabel = try #require(raw["by_label"] as? [String: DocumentReference], "by_label should decode as [String: DocumentReference]") #expect(rawByLabel.count == byLabel.count) + #expect(raw["linked_target"] is DocumentReference) + #expect(raw["next_note"] is DocumentReference) let noteLinkOut = try snapshot.data(as: NoteLink.self) @@ -88,6 +98,8 @@ struct ReferencesIntegrationTests { for (k, expectedRef) in byLabel { #expect(noteLinkOut.byLabel[k]?.path == expectedRef.path) } + #expect(noteLinkOut.linkedTarget.path == linkedTarget.path) + #expect(noteLinkOut.nextNote.path == nextNote.path) #expect( abs(noteLinkOut.createdAt.timeIntervalSince1970 - noteLinkIn.createdAt.timeIntervalSince1970) < 0.001, "timestamp should round-trip through Firestore within ms precision" diff --git a/tests/integration/typescript/generated/web/references.ts b/tests/integration/typescript/generated/web/references.ts index 40389705..89e4c1ab 100644 --- a/tests/integration/typescript/generated/web/references.ts +++ b/tests/integration/typescript/generated/web/references.ts @@ -10,5 +10,16 @@ export interface NoteLink { related: firestore.DocumentReference[]; /** A map of label -> reference to exercise references nested in a map. */ by_label: Record>; + /** A parameterized reference whose target model is `Target` (different model). */ + linked_target: firestore.DocumentReference; + /** A parameterized reference whose target model is `NoteLink` (self-reference). */ + next_note: firestore.DocumentReference; + created_at: firestore.Timestamp; +} + +/** A minimal sibling document used as the target of parameterized references. */ +export interface Target { + /** Display name for the target. */ + name: string; created_at: firestore.Timestamp; } diff --git a/tests/integration/typescript/tests/references.admin.test.ts b/tests/integration/typescript/tests/references.admin.test.ts index 726ea6eb..85e577d2 100644 --- a/tests/integration/typescript/tests/references.admin.test.ts +++ b/tests/integration/typescript/tests/references.admin.test.ts @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { NoteLink } from '../generated/references.js'; +import type { NoteLink, Target } from '../generated/references.js'; // Round-trips a `document-reference`-typed document through the Firestore // emulator using the **firebase-admin@13** SDK. The admin SDK represents @@ -29,6 +29,8 @@ interface NoteLinkSample { target_path: string; related_paths: string[]; by_label_paths: Record; + linked_target_path: string; + next_note_path: string; created_at: string; } @@ -68,9 +70,14 @@ describe('References document-reference round-trip (firebase-admin@13)', () => { for (const [k, v] of Object.entries(sample.by_label_paths)) { byLabel[k] = firestore.doc(v); } + // Parameterized references: the generator narrows these to + // `DocumentReference` and `DocumentReference` respectively. + // Casting via `as unknown as` is required because the admin SDK hands back + // an untyped `DocumentReference` from `firestore.doc()`; the + // wire-level value is identical, only the TypeScript view differs. + const linkedTarget = firestore.doc(sample.linked_target_path) as unknown as DocumentReference; + const nextNote = firestore.doc(sample.next_note_path) as unknown as DocumentReference; - // Sanity-check the fixture: distinct paths so a buggy SDK that aliased - // every reference to the same value would still be caught. expect(target.path).toBe(sample.target_path); expect(new Set(related.map(r => r.path)).size).toBe(related.length); expect(related.length).toBe(3); @@ -81,6 +88,8 @@ describe('References document-reference round-trip (firebase-admin@13)', () => { target, related, by_label: byLabel, + linked_target: linkedTarget, + next_note: nextNote, created_at: Timestamp.fromDate(new Date(sample.created_at)), }; @@ -120,6 +129,24 @@ describe('References document-reference round-trip (firebase-admin@13)', () => { expect(got).toBeDefined(); expect(got!.path).toBe(expectedRef.path); } + // Parameterized references round-trip with paths preserved exactly, + // identical to the bare form on the wire. + expect(noteLinkOut.linked_target.path).toBe(linkedTarget.path); + expect(noteLinkOut.next_note.path).toBe(nextNote.path); expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); }); + + // Pure compile-time check: the generator narrows `linked_target` to + // `DocumentReference` and `next_note` to `DocumentReference`. + // Assigning these into variables typed by the *narrowed* generic parameter + // would fail to compile if the generator regressed to the bare + // `DocumentReference` shape. The test body is intentionally + // empty — tsc carries the assertion via the `tsc --noEmit` integration step. + it('emits narrowed DocumentReference types for parameterized references (compile-time check)', () => { + const _typeCheck = (noteLink: NoteLink): { t: DocumentReference; n: DocumentReference } => ({ + t: noteLink.linked_target, + n: noteLink.next_note, + }); + expect(typeof _typeCheck).toBe('function'); + }); }); diff --git a/tests/integration/typescript/tests/references.web.test.ts b/tests/integration/typescript/tests/references.web.test.ts index ffe5ce5a..59e70fce 100644 --- a/tests/integration/typescript/tests/references.web.test.ts +++ b/tests/integration/typescript/tests/references.web.test.ts @@ -14,7 +14,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { NoteLink } from '../generated/web/references.js'; +import type { NoteLink, Target } from '../generated/web/references.js'; // Round-trips a `document-reference`-typed document through the Firestore // emulator using the **firebase@10 web SDK**. The web SDK exposes @@ -38,6 +38,8 @@ interface NoteLinkSample { target_path: string; related_paths: string[]; by_label_paths: Record; + linked_target_path: string; + next_note_path: string; created_at: string; } @@ -94,6 +96,8 @@ describe('References document-reference round-trip (firebase@10 web SDK)', () => for (const [k, v] of Object.entries(sample.by_label_paths)) { byLabel[k] = doc(firestore, v); } + const linkedTarget = doc(firestore, sample.linked_target_path) as unknown as DocumentReference; + const nextNote = doc(firestore, sample.next_note_path) as unknown as DocumentReference; expect(target.path).toBe(sample.target_path); expect(new Set(related.map(r => r.path)).size).toBe(related.length); @@ -103,6 +107,8 @@ describe('References document-reference round-trip (firebase@10 web SDK)', () => target, related, by_label: byLabel, + linked_target: linkedTarget, + next_note: nextNote, created_at: Timestamp.fromDate(new Date(sample.created_at)), }; @@ -138,6 +144,16 @@ describe('References document-reference round-trip (firebase@10 web SDK)', () => expect(got).toBeDefined(); expect(got!.path).toBe(expectedRef.path); } + expect(noteLinkOut.linked_target.path).toBe(linkedTarget.path); + expect(noteLinkOut.next_note.path).toBe(nextNote.path); expect(noteLinkOut.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); }); + + it('emits narrowed DocumentReference types for parameterized references (compile-time check)', () => { + const _typeCheck = (noteLink: NoteLink): { t: DocumentReference; n: DocumentReference } => ({ + t: noteLink.linked_target, + n: noteLink.next_note, + }); + expect(typeof _typeCheck).toBe('function'); + }); }); diff --git a/tests/integration/zod/generated/v3/references.ts b/tests/integration/zod/generated/v3/references.ts index ad37fb0a..f0aa1079 100644 --- a/tests/integration/zod/generated/v3/references.ts +++ b/tests/integration/zod/generated/v3/references.ts @@ -18,8 +18,23 @@ export const NoteLinkSchema = z z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) ) .describe('A map of label -> reference to exercise references nested in a map.'), + linked_target: z + .instanceof( + firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference + ) + .describe('A parameterized reference whose target model is `Target` (different model).'), + next_note: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'), created_at: z.instanceof(firestore.Timestamp), }) .strict() .describe('A document that points to other documents via Firestore document references.'); export type NoteLink = z.infer; + +/** A minimal sibling document used as the target of parameterized references. */ +export const TargetSchema = z + .object({ name: z.string().describe('Display name for the target.'), created_at: z.instanceof(firestore.Timestamp) }) + .strict() + .describe('A minimal sibling document used as the target of parameterized references.'); +export type Target = z.infer; diff --git a/tests/integration/zod/generated/v4-web/references.ts b/tests/integration/zod/generated/v4-web/references.ts index ae2a0776..e5b34241 100644 --- a/tests/integration/zod/generated/v4-web/references.ts +++ b/tests/integration/zod/generated/v4-web/references.ts @@ -19,7 +19,24 @@ export const NoteLinkSchema = z z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) ) .describe('A map of label -> reference to exercise references nested in a map.'), + linked_target: z + .instanceof( + firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference + ) + .describe('A parameterized reference whose target model is `Target` (different model).'), + next_note: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'), created_at: z.instanceof(firestore.Timestamp), }) .describe('A document that points to other documents via Firestore document references.'); export type NoteLink = z.infer; + +/** A minimal sibling document used as the target of parameterized references. */ +export const TargetSchema = z + .strictObject({ + name: z.string().describe('Display name for the target.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A minimal sibling document used as the target of parameterized references.'); +export type Target = z.infer; diff --git a/tests/integration/zod/generated/v4/references.ts b/tests/integration/zod/generated/v4/references.ts index 978e661d..7fa517c7 100644 --- a/tests/integration/zod/generated/v4/references.ts +++ b/tests/integration/zod/generated/v4/references.ts @@ -19,7 +19,24 @@ export const NoteLinkSchema = z z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) ) .describe('A map of label -> reference to exercise references nested in a map.'), + linked_target: z + .instanceof( + firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference + ) + .describe('A parameterized reference whose target model is `Target` (different model).'), + next_note: z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A parameterized reference whose target model is `NoteLink` (self-reference).'), created_at: z.instanceof(firestore.Timestamp), }) .describe('A document that points to other documents via Firestore document references.'); export type NoteLink = z.infer; + +/** A minimal sibling document used as the target of parameterized references. */ +export const TargetSchema = z + .strictObject({ + name: z.string().describe('Display name for the target.'), + created_at: z.instanceof(firestore.Timestamp), + }) + .describe('A minimal sibling document used as the target of parameterized references.'); +export type Target = z.infer; diff --git a/tests/integration/zod/tests/references.admin.test.ts b/tests/integration/zod/tests/references.admin.test.ts index 6e2ded6e..6c367bd5 100644 --- a/tests/integration/zod/tests/references.admin.test.ts +++ b/tests/integration/zod/tests/references.admin.test.ts @@ -10,6 +10,8 @@ interface NoteLinkSample { target_path: string; related_paths: string[]; by_label_paths: Record; + linked_target_path: string; + next_note_path: string; created_at: string; } @@ -36,12 +38,16 @@ describe('references firebase-admin@13 round-trip (v4 zod schema)', () => { for (const [k, v] of Object.entries(sample.by_label_paths)) { byLabel[k] = firestore.doc(v); } + const linkedTarget = firestore.doc(sample.linked_target_path); + const nextNote = firestore.doc(sample.next_note_path); const noteLinkIn = { label: sample.label, target, related, by_label: byLabel, + linked_target: linkedTarget, + next_note: nextNote, created_at: Timestamp.fromDate(new Date(sample.created_at)), }; @@ -71,6 +77,14 @@ describe('references firebase-admin@13 round-trip (v4 zod schema)', () => { expect(got).toBeInstanceOf(DocumentReference); expect(got!.path).toBe(expectedRef.path); } + // Parameterized references survive the round-trip identically to the + // bare form. The codegen emitter narrows `linked_target` to + // `DocumentReference` at the type level, but the runtime + // `instanceof` check accepts any DocumentReference instance. + expect(parsed.data.linked_target).toBeInstanceOf(DocumentReference); + expect(parsed.data.linked_target.path).toBe(linkedTarget.path); + expect(parsed.data.next_note).toBeInstanceOf(DocumentReference); + expect(parsed.data.next_note.path).toBe(nextNote.path); expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); } }); diff --git a/tests/integration/zod/tests/references.parsing.test.ts b/tests/integration/zod/tests/references.parsing.test.ts index d33362ea..351f7f5e 100644 --- a/tests/integration/zod/tests/references.parsing.test.ts +++ b/tests/integration/zod/tests/references.parsing.test.ts @@ -36,6 +36,10 @@ const adminInput = () => ({ target: fakeAdminRef(), related: [fakeAdminRef(), fakeAdminRef()], by_label: { primary: fakeAdminRef(), secondary: fakeAdminRef() }, + // Parameterized fields share the same runtime shape; the codegen + // narrows them at the type level only. + linked_target: fakeAdminRef(), + next_note: fakeAdminRef(), created_at: ts, }); @@ -68,6 +72,12 @@ describe.each([ it('accepts empty `related` list and empty `by_label` map', () => { expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), related: [], by_label: {} }).success).toBe(true); }); + it('rejects a NoteLink whose parameterized `linked_target` is a bare string path', () => { + expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), linked_target: 'targets/canonical' }).success).toBe(false); + }); + it('rejects a NoteLink whose parameterized `next_note` is a bare string path', () => { + expect(mod.NoteLinkSchema.safeParse({ ...adminInput(), next_note: 'notes/sibling-a' }).success).toBe(false); + }); }); describe('references web (v4)', () => { diff --git a/tests/integration/zod/tests/references.web.test.ts b/tests/integration/zod/tests/references.web.test.ts index 3b704c0c..39eadee0 100644 --- a/tests/integration/zod/tests/references.web.test.ts +++ b/tests/integration/zod/tests/references.web.test.ts @@ -20,6 +20,8 @@ interface NoteLinkSample { target_path: string; related_paths: string[]; by_label_paths: Record; + linked_target_path: string; + next_note_path: string; created_at: string; } @@ -53,12 +55,16 @@ describe('references firebase web SDK round-trip (v4 zod schema)', () => { for (const [k, v] of Object.entries(sample.by_label_paths)) { byLabel[k] = doc(firestore, v); } + const linkedTarget = doc(firestore, sample.linked_target_path); + const nextNote = doc(firestore, sample.next_note_path); const noteLinkIn = { label: sample.label, target, related, by_label: byLabel, + linked_target: linkedTarget, + next_note: nextNote, created_at: Timestamp.fromDate(new Date(sample.created_at)), }; @@ -85,6 +91,10 @@ describe('references firebase web SDK round-trip (v4 zod schema)', () => { expect(got).toBeInstanceOf(DocumentReference); expect(got!.path).toBe(expectedRef.path); } + expect(parsed.data.linked_target).toBeInstanceOf(DocumentReference); + expect(parsed.data.linked_target.path).toBe(linkedTarget.path); + expect(parsed.data.next_note).toBeInstanceOf(DocumentReference); + expect(parsed.data.next_note.path).toBe(nextNote.path); expect(parsed.data.created_at.toMillis()).toBe(noteLinkIn.created_at.toMillis()); } }); From 0408b99f15d20637ac82f7d001da6fd5df0675d2 Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 22:21:45 +0300 Subject: [PATCH 4/6] Add changeset --- .changeset/modern-parrots-search.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/modern-parrots-search.md diff --git a/.changeset/modern-parrots-search.md b/.changeset/modern-parrots-search.md new file mode 100644 index 00000000..a4bef1a2 --- /dev/null +++ b/.changeset/modern-parrots-search.md @@ -0,0 +1,5 @@ +--- +"typesync-cli": minor +--- + +Add Firestore `document-reference` schema support, including optional target-model narrowing in generated TypeScript and Zod types, schema validation for referenced models, and integration coverage across TypeScript, Zod, Python, and Swift. From f256c2311e4d0f763823e9219d24a2b80e2ef2db Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 22:21:48 +0300 Subject: [PATCH 5/6] Gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 41ff335d..6dbe0658 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,6 @@ tests/integration/swift/Package.resolved ui-debug.log database-debug.log pubsub-debug.log -### INTEGRATION TESTS END \ No newline at end of file +### INTEGRATION TESTS END + +.cursor/ \ No newline at end of file From d51e4f4c1229af09598a2afbbffd9d235a5d00fa Mon Sep 17 00:00:00 2001 From: Anar Kafkas Date: Sat, 23 May 2026 22:35:09 +0300 Subject: [PATCH 6/6] Renderer tests --- .../__file_snapshots__/document-reference.py | 57 +++++++++++++++++++ .../python/__tests__/renderer.test.ts | 30 ++++++++++ .../document-reference-path.rules | 23 ++++++++ .../rules/__tests__/renderer.test.ts | 34 +++++++++++ .../document-reference.swift | 9 +++ .../swift/__tests__/renderer.test.ts | 27 +++++++++ .../__file_snapshots__/document-reference.ts | 11 ++++ src/renderers/ts/__tests__/renderer.test.ts | 39 +++++++++++++ .../__file_snapshots__/document-reference.ts | 19 +++++++ src/renderers/zod/__tests__/renderer.test.ts | 57 +++++++++++++++++++ 10 files changed, 306 insertions(+) create mode 100644 src/renderers/python/__tests__/__file_snapshots__/document-reference.py create mode 100644 src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules create mode 100644 src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift create mode 100644 src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts create mode 100644 src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts diff --git a/src/renderers/python/__tests__/__file_snapshots__/document-reference.py b/src/renderers/python/__tests__/__file_snapshots__/document-reference.py new file mode 100644 index 00000000..50541003 --- /dev/null +++ b/src/renderers/python/__tests__/__file_snapshots__/document-reference.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import typing +import datetime +import enum +import pydantic +from pydantic_core import core_schema +from typing_extensions import Annotated +from google.cloud import firestore + +class TypesyncUndefined: + """Do not use this class in your code. Use the `UNDEFINED` sentinel instead.""" + _instance = None + + def __init__(self): + if TypesyncUndefined._instance is not None: + raise RuntimeError("TypesyncUndefined instances cannot be created directly. Import and use the UNDEFINED sentinel instead.") + else: + TypesyncUndefined._instance = self + + @classmethod + def __get_pydantic_core_schema__(cls, source, handler) -> core_schema.CoreSchema: + return core_schema.with_info_plain_validator_function(cls.validate) + + @classmethod + def validate(cls, value: typing.Any, info) -> TypesyncUndefined: + if not isinstance(value, cls): + raise ValueError("Undefined field type is not valid") + return value + +UNDEFINED = TypesyncUndefined() +"""A sentinel value that can be used to indicate that a value should be undefined. During serialization all values that are marked as undefined will be removed. The difference between `UNDEFINED` and `None` is that values that are set to `None` will serialize to explicit null.""" + +class TypesyncModel(pydantic.BaseModel): + def model_dump(self, **kwargs) -> typing.Dict[str, typing.Any]: + processed = {} + for field_name, field_value in dict(self).items(): + if isinstance(field_value, pydantic.BaseModel): + processed[field_name] = field_value.model_dump(**kwargs) + elif isinstance(field_value, list): + processed[field_name] = [item.model_dump(**kwargs) if isinstance(item, pydantic.BaseModel) else item for item in field_value] + elif isinstance(field_value, dict): + processed[field_name] = {key: value.model_dump(**kwargs) if isinstance(value, pydantic.BaseModel) else value for key, value in field_value.items()} + elif field_value is UNDEFINED: + continue + else: + processed[field_name] = field_value + return processed + +# Model Definitions + +OwnerRef = firestore.DocumentReference +"""A bare reference.""" + +OwnerRefTyped = firestore.DocumentReference +"""A parameterized reference; Python emits the same shape as the bare form.""" + diff --git a/src/renderers/python/__tests__/renderer.test.ts b/src/renderers/python/__tests__/renderer.test.ts index a5caf06a..a01b71ad 100644 --- a/src/renderers/python/__tests__/renderer.test.ts +++ b/src/renderers/python/__tests__/renderer.test.ts @@ -153,6 +153,36 @@ describe('PythonRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/custom-undefined-sentinel.py'); }); + it('locks in the rendered Python output for both bare and parameterized document-references via a file snapshot', async () => { + // Python's `firestore.DocumentReference` isn't generic, so the + // parameterized form must emit the *same* type expression as the bare + // form. Locking the rendered file via a snapshot guards against an + // accidental change that would silently start emitting something like + // `firestore.DocumentReference[User]` (which would fail at import time + // because `DocumentReference` is not subscriptable in the SDK). + const generation: PythonGeneration = { + type: 'python', + usesDocumentReference: true, + declarations: [ + { + type: 'alias', + modelName: 'OwnerRef', + modelType: { type: 'document-reference' }, + modelDocs: 'A bare reference.', + }, + { + type: 'alias', + modelName: 'OwnerRefTyped', + modelType: { type: 'document-reference', model: 'User' }, + modelDocs: 'A parameterized reference; Python emits the same shape as the bare form.', + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.py'); + }); + it('imports `firestore` from google.cloud only when `usesDocumentReference` is true', async () => { const baseDeclaration = { type: 'alias' as const, diff --git a/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules b/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules new file mode 100644 index 00000000..05ca5f00 --- /dev/null +++ b/src/renderers/rules/__tests__/__file_snapshots__/document-reference-path.rules @@ -0,0 +1,23 @@ +rules_version = '2'; +service cloud.firestore { + // t-start + function isNoteLink(data) { + return ( + (data is map) && + (data.keys().hasOnly(['target', 'next'])) && + (data.target is path) && + (data.next is path) + ); + } + // t-end + + match /databases/{database}/documents { + function isSignedIn() { + return request.auth != null; + } + + match /users/{uid} { + allow read, write; + } + } +} \ No newline at end of file diff --git a/src/renderers/rules/__tests__/renderer.test.ts b/src/renderers/rules/__tests__/renderer.test.ts index dd72a442..1e95f1c7 100644 --- a/src/renderers/rules/__tests__/renderer.test.ts +++ b/src/renderers/rules/__tests__/renderer.test.ts @@ -69,6 +69,40 @@ describe('RulesRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/type-validator-with-all-predicates.rules'); }); + it('renders `data is path` for document-reference-typed fields (Firestore Rules `path` type) via a file snapshot', async () => { + // `document-reference` maps to the Rules `path` type — the rules + // language has no equivalent of TypeScript's generics so the + // parameterized form (with `model: User`) renders identically to the + // bare form. The snapshot locks in (a) that mapping, (b) that nested + // path references in lists/maps fold into the standard `data[i] is path` + // / `data[k] is path` predicates, and (c) that no model name leaks into + // the rendered output. + const generation: RulesGeneration = { + type: 'rules', + typeValidatorDeclarations: [ + { + type: 'type-validator', + validatorName: 'isNoteLink', + paramName: 'data', + predicate: { + type: 'and', + alignment: 'vertical', + innerPredicates: [ + { type: 'type-equality', varName: 'data', varType: { type: 'map' } }, + { type: 'map-has-only-keys', varName: 'data', keys: ['target', 'next'] }, + { type: 'type-equality', varName: 'data.target', varType: { type: 'path' } }, + { type: 'type-equality', varName: 'data.next', varType: { type: 'path' } }, + ], + }, + }, + ], + readonlyFieldValidatorDeclarations: [], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference-path.rules'); + }); + it('renders a readonly-field-validator declaration with prev/next data params and the affected-keys predicate', async () => { const generation: RulesGeneration = { type: 'rules', diff --git a/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift b/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift new file mode 100644 index 00000000..773c4cd6 --- /dev/null +++ b/src/renderers/swift/__tests__/__file_snapshots__/document-reference.swift @@ -0,0 +1,9 @@ +import Foundation +import FirebaseFirestore + +/// A bare reference. +typealias OwnerRef = DocumentReference + +/// A parameterized reference; Swift emits the same shape as the bare form. +typealias OwnerRefTyped = DocumentReference + diff --git a/src/renderers/swift/__tests__/renderer.test.ts b/src/renderers/swift/__tests__/renderer.test.ts index c7e2e616..86b65e9f 100644 --- a/src/renderers/swift/__tests__/renderer.test.ts +++ b/src/renderers/swift/__tests__/renderer.test.ts @@ -158,6 +158,33 @@ describe('SwiftRendererImpl', () => { await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-id-struct.swift'); }); + it('locks in the rendered Swift output for both bare and parameterized document-references via a file snapshot', async () => { + // Swift's `DocumentReference` (FirebaseFirestore) is not generic, so the + // parameterized form must emit the *same* typealias as the bare form + // and the `import FirebaseFirestore` must be present. A snapshot keeps + // both pieces visible in one file. + const generation: SwiftGeneration = { + type: 'swift', + declarations: [ + { + type: 'typealias', + modelName: 'OwnerRef', + modelType: { type: 'document-reference' }, + modelDocs: 'A bare reference.', + }, + { + type: 'typealias', + modelName: 'OwnerRefTyped', + modelType: { type: 'document-reference', model: 'User' }, + modelDocs: 'A parameterized reference; Swift emits the same shape as the bare form.', + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.swift'); + }); + it('imports FirebaseFirestore when any property resolves to DocumentReference (even nested in a list)', async () => { const baseGeneration = (modelType: SwiftGeneration['declarations'][number]): SwiftGeneration => ({ type: 'swift', diff --git a/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts b/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts new file mode 100644 index 00000000..14052d46 --- /dev/null +++ b/src/renderers/ts/__tests__/__file_snapshots__/document-reference.ts @@ -0,0 +1,11 @@ +import type * as firestore from 'firebase-admin/firestore'; + +/** A bare reference (no narrowing). */ +export type OwnerRef = firestore.DocumentReference; + +/** A parameterized reference narrowed to `User`. */ +export type OwnerRefTyped = firestore.DocumentReference; + +export interface User { + name: string; +} diff --git a/src/renderers/ts/__tests__/renderer.test.ts b/src/renderers/ts/__tests__/renderer.test.ts index 0f3473ac..83b15ee2 100644 --- a/src/renderers/ts/__tests__/renderer.test.ts +++ b/src/renderers/ts/__tests__/renderer.test.ts @@ -202,6 +202,45 @@ describe('TSRendererImpl', () => { ); }); + it('locks in the rendered TS output for both bare and parameterized document-references via a file snapshot', async () => { + // One focused snapshot per renderer makes the `document-reference` + // contract reviewable as a file diff: model docs, the firestore import, + // the bare `DocumentReference`, the narrowed + // `DocumentReference`, and the sibling target interface are + // all visible in one place. A regression in any of those touches a single + // small snapshot file rather than several inline `.toContain(...)` checks. + const generation: TSGeneration = { + type: 'ts', + declarations: [ + { + type: 'alias', + modelName: 'OwnerRef', + modelType: { type: 'document-reference' }, + modelDocs: 'A bare reference (no narrowing).', + }, + { + type: 'alias', + modelName: 'OwnerRefTyped', + modelType: { type: 'document-reference', model: 'User' }, + modelDocs: 'A parameterized reference narrowed to `User`.', + }, + { + type: 'interface', + modelName: 'User', + modelDocs: null, + modelType: { + type: 'object', + additionalProperties: false, + properties: [{ name: 'name', type: { type: 'string' }, optional: false, docs: null }], + }, + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.ts'); + }); + it('narrows document-reference to firestore.DocumentReference when the parameterized form has a `model`', async () => { const generation: TSGeneration = { type: 'ts', diff --git a/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts b/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts new file mode 100644 index 00000000..eaa3a28f --- /dev/null +++ b/src/renderers/zod/__tests__/__file_snapshots__/document-reference.ts @@ -0,0 +1,19 @@ +import * as firestore from 'firebase-admin/firestore'; +import { z } from 'zod'; + +/** A bare reference (no narrowing). */ +export const OwnerRefSchema = z + .instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference) + .describe('A bare reference (no narrowing).'); +export type OwnerRef = z.infer; + +export const NoteLinkSchema = z.strictObject({ + target: z.instanceof( + firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference + ), + next: z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => firestore.DocumentReference), +}); +export type NoteLink = z.infer; + +export const UserSchema = z.strictObject({ name: z.string() }); +export type User = z.infer; diff --git a/src/renderers/zod/__tests__/renderer.test.ts b/src/renderers/zod/__tests__/renderer.test.ts index c9ffdde8..787b9b13 100644 --- a/src/renderers/zod/__tests__/renderer.test.ts +++ b/src/renderers/zod/__tests__/renderer.test.ts @@ -184,6 +184,63 @@ describe('ZodRendererImpl', () => { } }); + it('locks in the rendered Zod output for bare, parameterized cross-model, and self-referencing document-references via a file snapshot', async () => { + // The Zod codegen emitter (a) narrows the cast generic for parameterized + // *cross-model* references so `z.infer` carries + // `DocumentReference`, and (b) intentionally suppresses + // narrowing for *self-references* to avoid TS2456 on the resulting type + // alias. A snapshot captures all three shapes (bare, cross-model + // narrowed, self-reference unnarrowed) alongside the `firestore` + + // `zod` imports in one file so a regression in either rule is reviewable + // as a one-line diff. + const documentReferenceCast = (generic?: string) => { + const narrowed = + generic !== undefined ? `firestore.DocumentReference<${generic}>` : 'firestore.DocumentReference'; + return `z.instanceof(firestore.DocumentReference as unknown as new (...args: never[]) => ${narrowed})`; + }; + + const generation: ZodGeneration = { + type: 'zod', + usesTimestamp: false, + usesBytes: false, + usesDocumentReference: true, + declarations: [ + { + type: 'schema', + modelName: 'OwnerRef', + schemaName: 'OwnerRefSchema', + inferredTypeName: 'OwnerRef', + modelDocs: 'A bare reference (no narrowing).', + modelKind: 'alias', + expression: `${documentReferenceCast()}.describe("A bare reference (no narrowing).")`, + }, + { + type: 'schema', + modelName: 'NoteLink', + schemaName: 'NoteLinkSchema', + inferredTypeName: 'NoteLink', + modelDocs: null, + modelKind: 'document', + // `target` narrows to `User` (cross-model); `next` is a + // self-reference and falls back to the unnarrowed cast. + expression: `z.strictObject({ target: ${documentReferenceCast('User')}, next: ${documentReferenceCast()} })`, + }, + { + type: 'schema', + modelName: 'User', + schemaName: 'UserSchema', + inferredTypeName: 'User', + modelDocs: null, + modelKind: 'document', + expression: 'z.strictObject({ name: z.string() })', + }, + ], + }; + + const result = await createRenderer().render(generation); + await expect(result.content).toMatchFileSnapshot('./__file_snapshots__/document-reference.ts'); + }); + it('respects the configured indentation', async () => { const generation: ZodGeneration = { type: 'zod',