diff --git a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md index 7e96cd60..55a249de 100644 --- a/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md +++ b/Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md @@ -510,3 +510,46 @@ struct Customer { } } ``` + +#### Optional properties and null values + +By default, optional properties (`Int?`, `String?`, etc.) accept both **missing** fields and **explicit `null` values**. To forbid `null` values (allowing only omission), use `optionalNulls: false`: + +**Default behavior:** +```swift +@Schemable // Default: allows null for optional properties +struct User { + let name: String + let age: Int? // Accepts null (default behavior) + let email: String? // Accepts null (default behavior) +} +``` + +**Opt-out (forbid null):** +```swift +@Schemable(optionalNulls: false) +struct User { + let name: String + let age: Int? // Does NOT accept null (omission only) + let email: String? // Does NOT accept null (omission only) +} +``` + +**Per-property override:** +```swift +@Schemable(optionalNulls: false) +struct User { + let name: String + + @SchemaOptions(.orNull(style: .type)) + let age: Int? // Accepts null (overrides global setting) + + let email: String? // Does NOT accept null (follows global setting) +} +``` + +The `.orNull()` modifier supports two styles: +- `.type`: Uses type array `["integer", "null"]` - best for scalar primitives, produces clearer validation errors +- `.union`: Uses oneOf composition - required for complex types (objects, arrays) + +When `optionalNulls: true` (the default), the appropriate style is automatically selected based on the property type. diff --git a/Sources/JSONSchemaBuilder/JSONComponent/Modifier/OrNullModifier.swift b/Sources/JSONSchemaBuilder/JSONComponent/Modifier/OrNullModifier.swift new file mode 100644 index 00000000..9d062bb5 --- /dev/null +++ b/Sources/JSONSchemaBuilder/JSONComponent/Modifier/OrNullModifier.swift @@ -0,0 +1,119 @@ +import JSONSchema + +/// Style for handling null values in schemas +public enum OrNullStyle { + /// Uses type array: {"type": ["integer", "null"]} + /// Best for scalar primitives - produces clearer validation errors + case type + + /// Uses oneOf composition: {"oneOf": [{"type": "integer"}, {"type": "null"}]} + /// Required for complex types (objects, arrays, refs) + case union +} + +extension JSONSchemaComponent { + /// Makes this component accept null values in addition to the component's type. + /// Returns nil when null is encountered. + /// + /// - Parameter style: The style to use for null acceptance + /// - `.type`: Uses type array `["integer", "null"]` - best for primitives + /// - `.union`: Uses oneOf composition - required for complex types + /// + /// - Returns: A component that accepts either the original type or null, returning an optional value + /// + /// Example: + /// ```swift + /// JSONInteger() + /// .orNull(style: .type) // Accepts integers or null, returns Int? + /// ``` + public func orNull(style: OrNullStyle) -> JSONComponents.AnySchemaComponent { + switch style { + case .type: + return OrNullTypeComponent(wrapped: self).eraseToAnySchemaComponent() + case .union: + return OrNullUnionComponent(wrapped: self).eraseToAnySchemaComponent() + } + } +} + +/// Implementation using type array +private struct OrNullTypeComponent: JSONSchemaComponent +where Wrapped.Output == WrappedValue { + typealias Output = WrappedValue? + + var wrapped: Wrapped + + public var schemaValue: SchemaValue { + get { + var schema = wrapped.schemaValue + + // If there's already a type keyword, convert it to an array with null + if case .object(var obj) = schema, + let typeValue = obj[Keywords.TypeKeyword.name] + { + + // Convert single type to array with null + switch typeValue { + case .string(let typeStr): + obj[Keywords.TypeKeyword.name] = .array([ + .string(typeStr), .string(JSONType.null.rawValue), + ]) + case .array(var types): + // Add null if not already present + let nullValue = JSONValue.string(JSONType.null.rawValue) + if !types.contains(nullValue) { + types.append(nullValue) + } + obj[Keywords.TypeKeyword.name] = .array(types) + default: + break + } + + schema = .object(obj) + } + + return schema + } + set { + // Not implemented - this modifier doesn't support schema value mutation + } + } + + public func parse(_ value: JSONValue) -> Parsed { + // Accept null - return nil for the optional type + if case .null = value { + return .valid(nil) + } + return wrapped.parse(value).map(Optional.some) + } +} + +/// Implementation using oneOf composition +private struct OrNullUnionComponent: JSONSchemaComponent +where Wrapped.Output == WrappedValue { + typealias Output = WrappedValue? + + var wrapped: Wrapped + + public var schemaValue: SchemaValue { + get { + .object([ + Keywords.OneOf.name: .array([ + wrapped.schemaValue.value, + JSONNull().schemaValue.value, + ]) + ]) + } + set { + // Not implemented - this modifier doesn't support schema value mutation + } + } + + public func parse(_ value: JSONValue) -> Parsed { + // Accept null - return nil for the optional type + if case .null = value { + return .valid(nil) + } + return wrapped.parse(value).map(Optional.some) + } +} diff --git a/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyCompactMap.swift b/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyCompactMap.swift index 2b2162ff..35fbd00d 100644 --- a/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyCompactMap.swift +++ b/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyCompactMap.swift @@ -5,7 +5,7 @@ extension JSONPropertyComponent { /// This effectively transforms the validation output to be non-optional and therefore marks the property as required. /// - Parameter transform: The transform to apply to the output. /// - Returns: A new component that applies the transform. - func compactMap( + public func compactMap( _ transform: @Sendable @escaping (Output) -> NewOutput? ) -> JSONPropertyComponents.CompactMap { .init(upstream: self, transform: transform) diff --git a/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyFlatMap.swift b/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyFlatMap.swift new file mode 100644 index 00000000..22166942 --- /dev/null +++ b/Sources/JSONSchemaBuilder/JSONPropertyComponent/Modifier/PropertyFlatMap.swift @@ -0,0 +1,38 @@ +import JSONSchema + +extension JSONPropertyComponent { + /// Flattens a double-optional output to a single optional. + /// This is specifically useful for optional properties that use `.orNull()`, + /// which creates a double-optional (T??) that needs to be flattened to T?. + /// - Returns: A new component that flattens the double-optional output. + public func flatMapOptional() + -> JSONPropertyComponents.FlatMapOptional + where Output == Wrapped?? { + .init(upstream: self) + } +} + +extension JSONPropertyComponents { + public struct FlatMapOptional: JSONPropertyComponent + where Upstream.Output == Wrapped?? { + let upstream: Upstream + + public var key: String { upstream.key } + + public var isRequired: Bool { upstream.isRequired } + + public var value: Upstream.Value { upstream.value } + + public func parse(_ input: [String: JSONValue]) -> Parsed { + switch upstream.parse(input) { + case .valid(let output): + // Flatten T?? to T? + // If output is nil (property missing), return nil + // If output is .some(nil) (property present but null), return nil + // If output is .some(.some(value)), return value + return .valid(output.flatMap { $0 }) + case .invalid(let error): return .invalid(error) + } + } + } +} diff --git a/Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift b/Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift index e1bd6215..e6e2f1a3 100644 --- a/Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift +++ b/Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift @@ -54,4 +54,8 @@ extension SchemaTrait where Self == SchemaOptionsTrait { public static func customSchema(_ conversion: S.Type) -> SchemaOptionsTrait { fatalError(SchemaOptionsTrait.errorMessage) } + + public static func orNull(style: OrNullStyle) -> SchemaOptionsTrait { + fatalError(SchemaOptionsTrait.errorMessage) + } } diff --git a/Sources/JSONSchemaBuilder/Macros/Schemable.swift b/Sources/JSONSchemaBuilder/Macros/Schemable.swift index 60ff69f1..2321fb9b 100644 --- a/Sources/JSONSchemaBuilder/Macros/Schemable.swift +++ b/Sources/JSONSchemaBuilder/Macros/Schemable.swift @@ -1,7 +1,8 @@ @attached(extension, conformances: Schemable) @attached(member, names: named(schema), named(keyEncodingStrategy)) public macro Schemable( - keyStrategy: KeyEncodingStrategies? = nil + keyStrategy: KeyEncodingStrategies? = nil, + optionalNulls: Bool = true ) = #externalMacro(module: "JSONSchemaMacro", type: "SchemableMacro") public protocol Schemable { diff --git a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift index 5b1435ab..c8ea246a 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift @@ -104,11 +104,13 @@ struct SchemaGenerator { let members: MemberBlockItemListSyntax let attributes: AttributeListSyntax let keyStrategy: ExprSyntax? + let optionalNulls: Bool let context: (any MacroExpansionContext)? init( fromClass classDecl: ClassDeclSyntax, keyStrategy: ExprSyntax?, + optionalNulls: Bool = true, accessLevel: String? = nil, context: (any MacroExpansionContext)? = nil ) { @@ -126,12 +128,14 @@ struct SchemaGenerator { members = classDecl.memberBlock.members attributes = classDecl.attributes self.keyStrategy = keyStrategy + self.optionalNulls = optionalNulls self.context = context } init( fromStruct structDecl: StructDeclSyntax, keyStrategy: ExprSyntax?, + optionalNulls: Bool = true, accessLevel: String? = nil, context: (any MacroExpansionContext)? = nil ) { @@ -149,6 +153,7 @@ struct SchemaGenerator { members = structDecl.memberBlock.members attributes = structDecl.attributes self.keyStrategy = keyStrategy + self.optionalNulls = optionalNulls self.context = context } @@ -175,6 +180,7 @@ struct SchemaGenerator { $0.generateSchema( keyStrategy: keyStrategy, typeName: name.text, + globalOptionalNulls: optionalNulls, codingKeys: codingKeys, context: context ) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift index d2e11e62..637ff8ed 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift @@ -90,13 +90,21 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro { let accessModifier = accessLevel.map { "\($0) " } ?? "" if let structDecl = declaration.as(StructDeclSyntax.self) { - let strategyArg = node.arguments? - .as(LabeledExprListSyntax.self)? - .first(where: { $0.label?.text == "keyStrategy" })? + let arguments = node.arguments?.as(LabeledExprListSyntax.self) + let strategyArg = arguments?.first(where: { $0.label?.text == "keyStrategy" })?.expression + let optionalNullsArg = arguments?.first(where: { $0.label?.text == "optionalNulls" })? .expression + // Default to true if not specified, otherwise parse the boolean literal + let optionalNulls: Bool + if let boolLiteral = optionalNullsArg?.as(BooleanLiteralExprSyntax.self) { + optionalNulls = boolLiteral.literal.text == "true" + } else { + optionalNulls = true // default + } let generator = SchemaGenerator( fromStruct: structDecl, keyStrategy: strategyArg, + optionalNulls: optionalNulls, accessLevel: accessLevel, context: context ) @@ -112,13 +120,21 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro { } return decls } else if let classDecl = declaration.as(ClassDeclSyntax.self) { - let strategyArg = node.arguments? - .as(LabeledExprListSyntax.self)? - .first(where: { $0.label?.text == "keyStrategy" })? + let arguments = node.arguments?.as(LabeledExprListSyntax.self) + let strategyArg = arguments?.first(where: { $0.label?.text == "keyStrategy" })?.expression + let optionalNullsArg = arguments?.first(where: { $0.label?.text == "optionalNulls" })? .expression + // Default to true if not specified, otherwise parse the boolean literal + let optionalNulls: Bool + if let boolLiteral = optionalNullsArg?.as(BooleanLiteralExprSyntax.self) { + optionalNulls = boolLiteral.literal.text == "true" + } else { + optionalNulls = true // default + } let generator = SchemaGenerator( fromClass: classDecl, keyStrategy: strategyArg, + optionalNulls: optionalNulls, accessLevel: accessLevel, context: context ) diff --git a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift index e7d80e51..7d14755a 100644 --- a/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift +++ b/Sources/JSONSchemaMacro/Schemable/SchemableMember.swift @@ -78,6 +78,7 @@ struct SchemableMember { func generateSchema( keyStrategy: ExprSyntax?, typeName: String, + globalOptionalNulls: Bool = true, codingKeys: [String: String]? = nil, context: (any MacroExpansionContext)? = nil ) -> CodeBlockItemSyntax? { @@ -111,6 +112,7 @@ struct SchemableMember { } var customKey: ExprSyntax? + var orNullStyle: ExprSyntax? let options: LabeledExprListSyntax? = annotationArguments.flatMap { args in let filtered = args.filter { argument in guard let functionCall = argument.expression.as(FunctionCallExprSyntax.self), @@ -122,6 +124,11 @@ struct SchemableMember { return false } + if memberAccess.declName.baseName.text == "orNull" { + orNullStyle = functionCall.arguments.first?.expression + return false + } + return true } return filtered.isEmpty ? nil : LabeledExprListSyntax(filtered) @@ -155,6 +162,39 @@ struct SchemableMember { """ } + // Apply .orNull() if this is an optional property and: + // 1. It has an explicit @SchemaOptions(.orNull(style:)) annotation, OR + // 2. The global optionalNulls flag is true (default behavior) + let hasOrNull = type.isOptional && (orNullStyle != nil || globalOptionalNulls) + if hasOrNull { + if let orNullStyle { + // Explicit per-property annotation + codeBlock = """ + \(codeBlock) + .orNull(style: \(orNullStyle)) + """ + } else if globalOptionalNulls { + // Global flag is true - use .type for primitives, .union for complex types + let typeInfo = type.typeInformation() + let style: String + switch typeInfo { + case .primitive(let primitive, _): + // Use .type for scalar primitives, .union for arrays/dictionaries + style = primitive.isScalar ? ".type" : ".union" + case .schemable: + // Use .union for schemable types (objects) + style = ".union" + case .notSupported: + // Shouldn't reach here, but default to .union + style = ".union" + } + codeBlock = """ + \(codeBlock) + .orNull(style: \(raw: style)) + """ + } + } + let keyExpr: ExprSyntax if let customKey { // Custom key from @SchemaOptions(.key(...)) takes highest priority @@ -174,6 +214,16 @@ struct SchemableMember { JSONProperty(key: \(keyExpr)) { \(codeBlock) } """ + // When a property is optional AND has .orNull() applied, the output type becomes T?? + // because JSONProperty wraps the output in Optional, and .orNull() already returns Optional. + // We need to flatten the double-optional with .flatMapOptional() to get back to T? + if hasOrNull { + block = """ + \(block) + .flatMapOptional() + """ + } + if !type.isOptional { block = """ \(block) diff --git a/Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift b/Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift index 54687af9..ea252596 100644 --- a/Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift +++ b/Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift @@ -17,4 +17,14 @@ enum SupportedPrimitive: String, CaseIterable { case .dictionary: "JSONObject" } } + + /// Returns true if this is a scalar primitive (not array or dictionary) + var isScalar: Bool { + switch self { + case .double, .float, .string, .int, .bool: + return true + case .array, .dictionary: + return false + } + } } diff --git a/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift b/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift index cccdc551..9bf002f8 100644 --- a/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift +++ b/Tests/JSONSchemaBuilderTests/DocumentationExampleTests.swift @@ -97,7 +97,14 @@ struct DocumentationExampleTests { @Schemable struct Library { let name: String - var books: [Book] = [] + + @SchemaOptions(.default([])) + var books: [Book] + + init(name: String, books: [Book] = []) { + self.name = name + self.books = books + } } @Test func doccExample2() { diff --git a/Tests/JSONSchemaBuilderTests/OrNullModifierTests.swift b/Tests/JSONSchemaBuilderTests/OrNullModifierTests.swift new file mode 100644 index 00000000..42e21630 --- /dev/null +++ b/Tests/JSONSchemaBuilderTests/OrNullModifierTests.swift @@ -0,0 +1,405 @@ +import JSONSchema +import JSONSchemaBuilder +import Testing + +/// Tests for the .orNull() modifier +struct OrNullModifierTests { + + // MARK: - Schema generation tests + + @Test func typeStyleWithInteger() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func typeStyleWithString() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONString() + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["string", "null"] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func typeStyleWithNumber() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONNumber() + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["number", "null"] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func typeStyleWithBoolean() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONBoolean() + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["boolean", "null"] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func unionStyleWithInteger() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .union) + } + + let expected: [String: JSONValue] = [ + "oneOf": [ + ["type": "integer"], + ["type": "null"], + ] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func unionStyleWithString() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONString() + .orNull(style: .union) + } + + let expected: [String: JSONValue] = [ + "oneOf": [ + ["type": "string"], + ["type": "null"], + ] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func unionStyleWithArray() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONArray { + JSONString() + } + .orNull(style: .union) + } + + let expected: [String: JSONValue] = [ + "oneOf": [ + [ + "type": "array", + "items": ["type": "string"], + ], + ["type": "null"], + ] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func unionStyleWithObject() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + } + .orNull(style: .union) + } + + let expected: [String: JSONValue] = [ + "oneOf": [ + [ + "type": "object", + "properties": [ + "name": ["type": "string"] + ], + ], + ["type": "null"], + ] + ] + + #expect(schema.schemaValue == .object(expected)) + } + + // MARK: - Modifier chaining tests + + @Test func typeStyleWithOtherModifiers() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .type) + .title("Age") + .description("User's age in years") + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"], + "title": "Age", + "description": "User's age in years", + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func unionStyleWithOtherModifiers() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONString() + .orNull(style: .union) + .title("Name") + .description("User's name") + } + + let expected: [String: JSONValue] = [ + "oneOf": [ + ["type": "string"], + ["type": "null"], + ], + "title": "Name", + "description": "User's name", + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func typeStyleWithConstraints() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .minimum(0) + .maximum(100) + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"], + "minimum": 0, + "maximum": 100, + ] + + #expect(schema.schemaValue == .object(expected)) + } + + // MARK: - Parsing tests + + @Test func typeStyleParseNull() throws { + let schema = JSONInteger() + .orNull(style: .type) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is Int?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional (Int?) is nil + #expect(result.errors == nil) + } + + @Test func typeStyleParseValidValue() throws { + let schema = JSONInteger() + .orNull(style: .type) + + let result = schema.parse(.integer(42)) + + #expect(result.value == 42) + #expect(result.errors == nil) + } + + @Test func typeStyleParseInvalidValue() throws { + let schema = JSONInteger() + .orNull(style: .type) + + let result = schema.parse(.string("not an integer")) + + #expect(result.value == nil) + #expect(result.errors != nil) + } + + @Test func unionStyleParseNull() throws { + let schema = JSONInteger() + .orNull(style: .union) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is Int?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional (Int?) is nil + #expect(result.errors == nil) + } + + @Test func unionStyleParseValidValue() throws { + let schema = JSONInteger() + .orNull(style: .union) + + let result = schema.parse(.integer(42)) + + #expect(result.value == 42) + #expect(result.errors == nil) + } + + @Test func unionStyleParseInvalidValue() throws { + let schema = JSONInteger() + .orNull(style: .union) + + let result = schema.parse(.string("not an integer")) + + #expect(result.value == nil) + #expect(result.errors != nil) + } + + @Test func typeStyleStringParseNull() throws { + let schema = JSONString() + .orNull(style: .type) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is String?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional (String?) is nil + #expect(result.errors == nil) + } + + @Test func typeStyleStringParseValidValue() throws { + let schema = JSONString() + .orNull(style: .type) + + let result = schema.parse(.string("hello")) + + #expect(result.value == "hello") + #expect(result.errors == nil) + } + + @Test func typeStyleNumberParseNull() throws { + let schema = JSONNumber() + .orNull(style: .type) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is Double?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional (Double?) is nil + #expect(result.errors == nil) + } + + @Test func typeStyleNumberParseValidValue() throws { + let schema = JSONNumber() + .orNull(style: .type) + + let result = schema.parse(.number(3.14)) + + #expect(result.value == 3.14) + #expect(result.errors == nil) + } + + @Test func typeStyleBooleanParseNull() throws { + let schema = JSONBoolean() + .orNull(style: .type) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is Bool?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional (Bool?) is nil + #expect(result.errors == nil) + } + + @Test func typeStyleBooleanParseValidValue() throws { + let schema = JSONBoolean() + .orNull(style: .type) + + let result = schema.parse(.boolean(true)) + + #expect(result.value == true) + #expect(result.errors == nil) + } + + @Test func unionStyleArrayParseNull() throws { + let schema = JSONArray { + JSONString() + } + .orNull(style: .union) + + let result = schema.parse(.null) + + #expect(result.value != nil) // result.value is [String]?? - the outer optional is some(.none) + #expect(result.value! == nil) // The inner optional ([String]?) is nil + #expect(result.errors == nil) + } + + @Test func unionStyleArrayParseValidValue() throws { + let schema = JSONArray { + JSONString() + } + .orNull(style: .union) + + let result = schema.parse(.array([.string("a"), .string("b")])) + + #expect(result.value == ["a", "b"]) + #expect(result.errors == nil) + } + + @Test func unionStyleArrayParseEmptyArray() throws { + let schema = JSONArray { + JSONString() + } + .orNull(style: .union) + + let result = schema.parse(.array([])) + + #expect(result.value == []) + #expect(result.errors == nil) + } + + // MARK: - Complex scenarios + + @Test func typeStyleWithDefaultValue() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .type) + .default(nil) + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"], + "default": .null, + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func typeStyleWithExamples() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .type) + .examples([.number(42), .null, .number(100)]) + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"], + "examples": [42, .null, 100], + ] + + #expect(schema.schemaValue == .object(expected)) + } + + @Test func multipleNullsInTypeArrayPreventsDuplicates() { + @JSONSchemaBuilder var schema: some JSONSchemaComponent { + JSONInteger() + .orNull(style: .type) + .orNull(style: .type) + } + + let expected: [String: JSONValue] = [ + "type": ["integer", "null"] + ] + + #expect(schema.schemaValue == .object(expected)) + } +} diff --git a/Tests/JSONSchemaIntegrationTests/OptionalNullsIntegrationTests.swift b/Tests/JSONSchemaIntegrationTests/OptionalNullsIntegrationTests.swift new file mode 100644 index 00000000..a285ccdb --- /dev/null +++ b/Tests/JSONSchemaIntegrationTests/OptionalNullsIntegrationTests.swift @@ -0,0 +1,35 @@ +import JSONSchema +import JSONSchemaBuilder +import Testing + +@Schemable +struct TestStructWithOptionalInt: Codable { + let required: String + let optionalInt: Int? + let optionalString: String? +} + +struct OptionalNullsIntegrationTests { + struct TestError: Error { + let message: String + init(_ message: String) { self.message = message } + } + + @Test func optionalIntWithOrNull() throws { + // Test with null for optional Int + let jsonWithNull = """ + { + "required": "test", + "optionalInt": null, + "optionalString": null + } + """ + + let result = try TestStructWithOptionalInt.schema.parse(instance: jsonWithNull) + #expect(result.value != nil, "Expected successful parsing") + guard let parsed = result.value else { throw TestError("Failed to parse") } + #expect(parsed.required == "test") + #expect(parsed.optionalInt == nil) + #expect(parsed.optionalString == nil) + } +} diff --git a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift index 3272e8c4..89bde40e 100644 --- a/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift +++ b/Tests/JSONSchemaIntegrationTests/PollExampleTests.swift @@ -28,8 +28,8 @@ struct Poll { @StringOptions(.format("date-time")) let expiresAt: String? - @SchemaOptions(.description("Whether the poll is currently active")) - var isActive: Bool = true + @SchemaOptions(.description("Whether the poll is currently active"), .default(true)) + var isActive: Bool @SchemaOptions(.description("List of options available in the poll")) @ArrayOptions(.minItems(2), .uniqueItems(true)) @@ -40,6 +40,28 @@ struct Poll { let settings: Settings? + init( + id: Int, + title: String, + description: String?, + createdAt: String, + expiresAt: String?, + isActive: Bool = true, + options: [Option], + category: Category, + settings: Settings? + ) { + self.id = id + self.title = title + self.description = description + self.createdAt = createdAt + self.expiresAt = expiresAt + self.isActive = isActive + self.options = options + self.category = category + self.settings = settings + } + @Schemable @ObjectOptions(.additionalProperties { false }) struct Option { @@ -51,9 +73,15 @@ struct Poll { @StringOptions(.minLength(1), .maxLength(100)) let text: String - @SchemaOptions(.description("Number of votes received")) + @SchemaOptions(.description("Number of votes received"), .default(0)) @NumberOptions(.minimum(0)) - var voteCount: Int = 0 + var voteCount: Int + + init(id: Int, text: String, voteCount: Int = 0) { + self.id = id + self.text = text + self.voteCount = voteCount + } } @Schemable @@ -73,8 +101,16 @@ struct Poll { @Schemable struct Settings { - var allowMultipleVotes: Bool = true - var requireAuthentication: Bool = false + @SchemaOptions(.default(true)) + var allowMultipleVotes: Bool + + @SchemaOptions(.default(false)) + var requireAuthentication: Bool + + init(allowMultipleVotes: Bool = true, requireAuthentication: Bool = false) { + self.allowMultipleVotes = allowMultipleVotes + self.requireAuthentication = requireAuthentication + } } } diff --git a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json index e12dd13d..81d3bf03 100644 --- a/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json +++ b/Tests/JSONSchemaIntegrationTests/__Snapshots__/PollExampleTests/defintion.1.json @@ -134,12 +134,18 @@ "description" : { "description" : "Optional description of the poll", "maxLength" : 500, - "type" : "string" + "type" : [ + "string", + "null" + ] }, "expiresAt" : { "description" : "Optional expiration timestamp for the poll", "format" : "date-time", - "type" : "string" + "type" : [ + "string", + "null" + ] }, "id" : { "description" : "Unique identifier for the poll", @@ -186,21 +192,28 @@ "uniqueItems" : true }, "settings" : { - "properties" : { - "allowMultipleVotes" : { - "default" : true, - "type" : "boolean" + "oneOf" : [ + { + "properties" : { + "allowMultipleVotes" : { + "default" : true, + "type" : "boolean" + }, + "requireAuthentication" : { + "default" : false, + "type" : "boolean" + } + }, + "required" : [ + "allowMultipleVotes", + "requireAuthentication" + ], + "type" : "object" }, - "requireAuthentication" : { - "default" : false, - "type" : "boolean" + { + "type" : "null" } - }, - "required" : [ - "allowMultipleVotes", - "requireAuthentication" - ], - "type" : "object" + ] }, "title" : { "description" : "The title of the poll", diff --git a/Tests/JSONSchemaMacroTests/OptionalNullsExpansionTests.swift b/Tests/JSONSchemaMacroTests/OptionalNullsExpansionTests.swift new file mode 100644 index 00000000..95d2973d --- /dev/null +++ b/Tests/JSONSchemaMacroTests/OptionalNullsExpansionTests.swift @@ -0,0 +1,701 @@ +import JSONSchemaMacro +import SwiftSyntaxMacros +import Testing + +/// Tests for optional null handling in the @Schemable macro +struct OptionalNullsExpansionTests { + let testMacros: [String: Macro.Type] = [ + "Schemable": SchemableMacro.self, + "SchemaOptions": SchemaOptionsMacro.self, + ] + + // MARK: - Per-property opt-in tests + + @Test func perPropertyOrNullTypeStyle() { + assertMacroExpansion( + """ + @Schemable + struct User { + let name: String + + @SchemaOptions(.orNull(style: .type)) + let age: Int? + + let email: String? + } + """, + expandedSource: """ + struct User { + let name: String + let age: Int? + + let email: String? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(User.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "age") { + JSONInteger() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "email") { + JSONString() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + extension User: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func perPropertyOrNullUnionStyle() { + assertMacroExpansion( + """ + @Schemable + struct Product { + @SchemaOptions(.orNull(style: .union)) + let tags: [String]? + + @SchemaOptions(.orNull(style: .union)) + let metadata: [String: String]? + } + """, + expandedSource: """ + struct Product { + let tags: [String]? + let metadata: [String: String]? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(Product.init) { + JSONObject { + JSONProperty(key: "tags") { + JSONArray { + JSONString() + } + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "metadata") { + JSONObject() + .additionalProperties { + JSONString() + } + .map(\\.1) + .map(\\.matches) + .orNull(style: .union) + } + .flatMapOptional() + } + } + } + } + + extension Product: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func perPropertyMixedStyles() { + assertMacroExpansion( + """ + @Schemable + struct MixedOptionals { + @SchemaOptions(.orNull(style: .type)) + let count: Int? + + @SchemaOptions(.orNull(style: .union)) + let items: [String]? + + let description: String? + } + """, + expandedSource: """ + struct MixedOptionals { + let count: Int? + let items: [String]? + + let description: String? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(MixedOptionals.init) { + JSONObject { + JSONProperty(key: "count") { + JSONInteger() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "items") { + JSONArray { + JSONString() + } + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "description") { + JSONString() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + extension MixedOptionals: Schemable { + } + """, + macros: testMacros + ) + } + + // MARK: - Default behavior (null allowed by default) + + @Test func defaultBehaviorAllowsNull() { + assertMacroExpansion( + """ + @Schemable + struct Weather { + let location: String + let temperature: Double? + let humidity: Int? + let isRaining: Bool? + let windSpeed: Float? + } + """, + expandedSource: """ + struct Weather { + let location: String + let temperature: Double? + let humidity: Int? + let isRaining: Bool? + let windSpeed: Float? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(Weather.init) { + JSONObject { + JSONProperty(key: "location") { + JSONString() + } + .required() + JSONProperty(key: "temperature") { + JSONNumber() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "humidity") { + JSONInteger() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "isRaining") { + JSONBoolean() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "windSpeed") { + JSONNumber() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + extension Weather: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func defaultAllowComplexTypes() { + assertMacroExpansion( + """ + @Schemable + struct Product { + let name: String + let tags: [String]? + let metadata: [String: Int]? + let relatedProducts: [Product]? + } + """, + expandedSource: """ + struct Product { + let name: String + let tags: [String]? + let metadata: [String: Int]? + let relatedProducts: [Product]? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(Product.init) { + JSONObject { + JSONProperty(key: "name") { + JSONString() + } + .required() + JSONProperty(key: "tags") { + JSONArray { + JSONString() + } + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "metadata") { + JSONObject() + .additionalProperties { + JSONInteger() + } + .map(\\.1) + .map(\\.matches) + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "relatedProducts") { + JSONArray { + Product.schema + } + .orNull(style: .union) + } + .flatMapOptional() + } + } + } + } + + extension Product: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func defaultAllowMixedTypes() { + assertMacroExpansion( + """ + @Schemable + struct MixedTypes { + let id: Int + let count: Int? + let tags: [String]? + let score: Double? + let metadata: [String: String]? + let active: Bool? + } + """, + expandedSource: """ + struct MixedTypes { + let id: Int + let count: Int? + let tags: [String]? + let score: Double? + let metadata: [String: String]? + let active: Bool? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(MixedTypes.init) { + JSONObject { + JSONProperty(key: "id") { + JSONInteger() + } + .required() + JSONProperty(key: "count") { + JSONInteger() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "tags") { + JSONArray { + JSONString() + } + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "score") { + JSONNumber() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "metadata") { + JSONObject() + .additionalProperties { + JSONString() + } + .map(\\.1) + .map(\\.matches) + .orNull(style: .union) + } + .flatMapOptional() + JSONProperty(key: "active") { + JSONBoolean() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + extension MixedTypes: Schemable { + } + """, + macros: testMacros + ) + } + + // MARK: - Explicit false (opt-out of null acceptance) + + @Test func explicitFalsePolicy() { + assertMacroExpansion( + """ + @Schemable(optionalNulls: false) + struct ForbidNull { + let required: String + let optional: Int? + } + """, + expandedSource: """ + struct ForbidNull { + let required: String + let optional: Int? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(ForbidNull.init) { + JSONObject { + JSONProperty(key: "required") { + JSONString() + } + .required() + JSONProperty(key: "optional") { + JSONInteger() + } + } + } + } + } + + extension ForbidNull: Schemable { + } + """, + macros: testMacros + ) + } + + // MARK: - Interaction with other SchemaOptions + + @Test func orNullWithOtherSchemaOptions() { + assertMacroExpansion( + """ + @Schemable + struct UserProfile { + @SchemaOptions( + .orNull(style: .type), + .description("User's age in years"), + .default(nil) + ) + let age: Int? + + @SchemaOptions( + .orNull(style: .union), + .title("Tags"), + .description("User tags") + ) + let tags: [String]? + } + """, + expandedSource: """ + struct UserProfile { + let age: Int? + let tags: [String]? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(UserProfile.init) { + JSONObject { + JSONProperty(key: "age") { + JSONInteger() + .description("User's age in years") + .default(nil) + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "tags") { + JSONArray { + JSONString() + } + .title("Tags") + .description("User tags") + .orNull(style: .union) + } + .flatMapOptional() + } + } + } + } + + extension UserProfile: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func defaultAllowWithPropertyOverride() { + assertMacroExpansion( + """ + @Schemable + struct OverrideTest { + let count: Int? + + @SchemaOptions(.description("Explicitly styled")) + let score: Double? + } + """, + expandedSource: """ + struct OverrideTest { + let count: Int? + let score: Double? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(OverrideTest.init) { + JSONObject { + JSONProperty(key: "count") { + JSONInteger() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: "score") { + JSONNumber() + .description("Explicitly styled") + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + extension OverrideTest: Schemable { + } + """, + macros: testMacros + ) + } + + // MARK: - Edge cases + + @Test func defaultAllowWithNoOptionalProperties() { + assertMacroExpansion( + """ + @Schemable + struct NoOptionals { + let id: Int + let name: String + } + """, + expandedSource: """ + struct NoOptionals { + let id: Int + let name: String + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(NoOptionals.init) { + JSONObject { + JSONProperty(key: "id") { + JSONInteger() + } + .required() + JSONProperty(key: "name") { + JSONString() + } + .required() + } + } + } + } + + extension NoOptionals: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func defaultAllowWithKeyStrategy() { + assertMacroExpansion( + """ + @Schemable(keyStrategy: .snakeCase) + struct SnakeCaseOptionals { + let userId: Int + let firstName: String? + let lastName: String? + } + """, + expandedSource: """ + struct SnakeCaseOptionals { + let userId: Int + let firstName: String? + let lastName: String? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(SnakeCaseOptionals.init) { + JSONObject { + JSONProperty(key: SnakeCaseOptionals.keyEncodingStrategy.encode("userId")) { + JSONInteger() + } + .required() + JSONProperty(key: SnakeCaseOptionals.keyEncodingStrategy.encode("firstName")) { + JSONString() + .orNull(style: .type) + } + .flatMapOptional() + JSONProperty(key: SnakeCaseOptionals.keyEncodingStrategy.encode("lastName")) { + JSONString() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var keyEncodingStrategy: KeyEncodingStrategies { + .snakeCase + } + } + + extension SnakeCaseOptionals: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func optionalCustomType() { + assertMacroExpansion( + """ + @Schemable + struct Container { + let data: CustomData? + } + """, + expandedSource: """ + struct Container { + let data: CustomData? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(Container.init) { + JSONObject { + JSONProperty(key: "data") { + CustomData.schema + .orNull(style: .union) + } + .flatMapOptional() + } + } + } + } + + extension Container: Schemable { + } + """, + macros: testMacros + ) + } + + @Test func nestedOptionalArrays() { + assertMacroExpansion( + """ + @Schemable + struct NestedArrays { + let matrix: [[Int]]? + } + """, + expandedSource: """ + struct NestedArrays { + let matrix: [[Int]]? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + static var schema: some JSONSchemaComponent { + JSONSchema(NestedArrays.init) { + JSONObject { + JSONProperty(key: "matrix") { + JSONArray { + JSONArray { + JSONInteger() + } + } + .orNull(style: .union) + } + .flatMapOptional() + } + } + } + } + + extension NestedArrays: Schemable { + } + """, + macros: testMacros + ) + } + + @Test(arguments: ["public", "internal", "fileprivate", "package", "private"]) + func accessModifiersWithOptionalNulls(_ modifier: String) { + assertMacroExpansion( + """ + @Schemable + \(modifier) struct Weather { + let temperature: Double? + } + """, + expandedSource: """ + \(modifier) struct Weather { + let temperature: Double? + + @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) + \(modifier) static var schema: some JSONSchemaComponent { + JSONSchema(Weather.init) { + JSONObject { + JSONProperty(key: "temperature") { + JSONNumber() + .orNull(style: .type) + } + .flatMapOptional() + } + } + } + } + + \(modifier == "private" || modifier == "fileprivate" ? "\(modifier) " : "")extension Weather: Schemable { + } + """, + macros: testMacros + ) + } +} diff --git a/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift b/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift index 29c64a8f..5a368805 100644 --- a/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift +++ b/Tests/JSONSchemaMacroTests/SchemableExpansionTests.swift @@ -51,7 +51,9 @@ struct SchemableExpansionTests { .required() JSONProperty(key: "precipitationAmount") { JSONNumber() + .orNull(style: .type) } + .flatMapOptional() JSONProperty(key: "humidity") { JSONNumber() } @@ -189,10 +191,14 @@ struct SchemableExpansionTests { JSONObject { JSONProperty(key: "isRaining") { JSONBoolean() + .orNull(style: .type) } + .flatMapOptional() JSONProperty(key: "temperature") { JSONInteger() + .orNull(style: .type) } + .flatMapOptional() JSONProperty(key: "location") { JSONString() } @@ -316,7 +322,9 @@ struct SchemableExpansionTests { JSONProperty(key: "precipitationAmount") { JSONNumber() .default(nil) + .orNull(style: .type) } + .flatMapOptional() JSONProperty(key: "humidity") { JSONNumber() .default(0.30) @@ -610,7 +618,9 @@ struct SchemableExpansionTests { .description(#\"\"\" The amount of precipitation in inches \"\"\"#) + .orNull(style: .type) } + .flatMapOptional() JSONProperty(key: "humidity") { JSONNumber() .description(#\"\"\"