Skip to content

Commit 7a969d6

Browse files
committed
Support validate exact for non-optional raw defaults
1 parent 752fe6c commit 7a969d6

7 files changed

Lines changed: 330 additions & 6 deletions

File tree

Sources/CoreDataEvolutionToolingCore/Config/ToolingConfigValidation.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,13 @@ private func validateResolvedToolingSchemaConfig(
8989
context: String,
9090
entitiesByName: [String: NSEntityDescription]
9191
) throws {
92+
let allowNonOptionalRawStorageWithModelDefault = context == "validate"
9293
try validateToolingModelConstraints(
9394
entitiesByName: entitiesByName,
9495
attributeRules: config.attributeRules,
9596
relationshipRules: config.relationshipRules,
96-
context: context
97+
context: context,
98+
allowNonOptionalRawStorageWithModelDefault: allowNonOptionalRawStorageWithModelDefault
9799
)
98100
try validateAttributeRules(
99101
config.attributeRules,
@@ -125,7 +127,8 @@ private func validateToolingModelConstraints(
125127
entitiesByName: [String: NSEntityDescription],
126128
attributeRules: ToolingAttributeRules,
127129
relationshipRules: ToolingRelationshipRules,
128-
context: String
130+
context: String,
131+
allowNonOptionalRawStorageWithModelDefault: Bool
129132
) throws {
130133
for (entityName, entity) in entitiesByName {
131134
let rules = attributeRules[entity: entityName]
@@ -148,6 +151,12 @@ private func validateToolingModelConstraints(
148151
)
149152
}
150153
if storageMethod != .default, attribute.isOptional == false {
154+
if allowNonOptionalRawStorageWithModelDefault,
155+
storageMethod == .raw,
156+
attribute.defaultValue != nil
157+
{
158+
continue
159+
}
151160
throw configValidationFailure(
152161
"""
153162
\(context) does not support non-optional custom storage for '\(entityName).\(fieldName)'. \

Sources/CoreDataEvolutionToolingCore/Generate/ToolingSourceRenderer.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,8 @@ public enum ToolingSourceRenderer {
512512
}
513513

514514
// V1 generation follows model defaults exactly. It does not invent code-side defaults for
515-
// required fields, and it does not convert persistent defaults into custom storage values such
516-
// as enums, codable payloads, or compositions.
515+
// required fields. For `.raw` storage, validate exact mode can still render a stable source
516+
// default by wrapping the persistent literal in `RawRepresentable(rawValue:)!`.
517517
private static func renderDefaultValue(
518518
for attribute: ToolingAttributeIR,
519519
storageMethod: ToolingAttributeStorageRule,
@@ -526,7 +526,25 @@ public enum ToolingSourceRenderer {
526526
switch storageMethod {
527527
case .default:
528528
return attribute.modelDefaultValueLiteral
529-
case .raw, .codable, .composition, .transformed:
529+
case .raw:
530+
guard let rawType = attribute.storage.nonOptionalSwiftType else {
531+
throw ToolingFailure.user(
532+
.configInvalid,
533+
"generate could not resolve a Swift type for non-optional raw attribute '\(attribute.swiftName)'."
534+
)
535+
}
536+
guard let rawValueLiteral = attribute.modelDefaultValueLiteral else {
537+
throw ToolingFailure.user(
538+
.configInvalid,
539+
"""
540+
generate cannot derive a non-optional default for raw storage '\(storageMethod.rawValue)' \
541+
on '\(attribute.swiftName)' without a model default. Make the field optional or keep \
542+
default storage for required fields.
543+
"""
544+
)
545+
}
546+
return "\(rawType)(rawValue: \(rawValueLiteral))!"
547+
case .codable, .composition, .transformed:
530548
throw ToolingFailure.user(
531549
.configInvalid,
532550
"""

Sources/CoreDataEvolutionToolingCore/Validation/ToolingValidateComparator.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,27 @@ public enum ToolingValidateComparator {
417417
)
418418
)
419419
}
420-
case .raw, .codable, .composition, .transformed:
420+
case .raw:
421+
guard attribute.hasModelDefaultValue else {
422+
diagnostics.append(
423+
error(
424+
"validate does not support non-optional raw storage without a model default for '\(entityName).\(expectedPropertyName)'."
425+
)
426+
)
427+
return
428+
}
429+
430+
guard let actualDefault = sourceProperty.defaultValueLiteral,
431+
normalizeLiteral(actualDefault) != "nil"
432+
else {
433+
diagnostics.append(
434+
error(
435+
"validate requires non-optional raw storage for '\(entityName).\(expectedPropertyName)' to declare an explicit non-nil Swift default."
436+
)
437+
)
438+
return
439+
}
440+
case .codable, .composition, .transformed:
421441
diagnostics.append(
422442
error(
423443
"validate does not support non-optional \(attribute.storage.method.rawValue) storage for '\(entityName).\(expectedPropertyName)'."

Tests/CoreDataEvolutionToolingCoreTests/ConfigValidationTests.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,25 @@ struct ConfigValidationTests {
624624
}
625625
}
626626

627+
@Test("validate accepts non-optional raw storage when model provides default")
628+
func validateAcceptsNonOptionalRawStorageWithModelDefault() throws {
629+
let model = makeModelWithCustomStorageCandidate()
630+
let template = makeValidateValidationTemplate(
631+
attributeRules: .init(
632+
entities: [
633+
"Item": [
634+
"status_raw": .init(
635+
swiftType: "ItemStatus",
636+
storageMethod: .raw
637+
)
638+
]
639+
]
640+
)
641+
)
642+
643+
try validateToolingConfigTemplate(template, against: model)
644+
}
645+
627646
@Test("generate rejects transient attribute with custom storage")
628647
func generateRejectsTransientAttributeWithCustomStorage() throws {
629648
let model = makeModelWithTransientAttribute()
@@ -727,6 +746,38 @@ struct ConfigValidationTests {
727746
)
728747
}
729748

749+
private func makeValidateValidationTemplate(
750+
attributeRules: ToolingAttributeRules? = nil
751+
) -> ToolingConfigTemplate {
752+
ToolingConfigTemplate(
753+
schemaVersion: toolingSupportedSchemaVersion,
754+
generate: nil,
755+
validate: .init(
756+
modelPath: "Models/AppModel.xcdatamodeld",
757+
modelVersion: nil,
758+
momcBin: nil,
759+
sourceDir: "Sources/AppModels",
760+
moduleName: "AppModels",
761+
typeMappings: nil,
762+
attributeRules: attributeRules,
763+
relationshipRules: nil,
764+
compositionRules: nil,
765+
accessLevel: .internal,
766+
singleFile: false,
767+
splitByEntity: true,
768+
headerTemplate: nil,
769+
generateInit: false,
770+
defaultDecodeFailurePolicy: .fallbackToDefaultValue,
771+
include: [],
772+
exclude: [],
773+
level: .conformance,
774+
report: .text,
775+
failOnWarning: false,
776+
maxIssues: 200
777+
)
778+
)
779+
}
780+
730781
private func makeModel() -> NSManagedObjectModel {
731782
let nameAttribute = NSAttributeDescription()
732783
nameAttribute.name = "name"

Tests/CoreDataEvolutionToolingCoreTests/ToolingSourceRendererTests.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,58 @@ struct ToolingSourceRendererTests {
308308
}
309309
}
310310

311+
@Test("renderer emits non-optional raw storage using model default literal")
312+
func rendererEmitsNonOptionalRawStorageDefault() throws {
313+
let modelIR = ToolingModelIR(
314+
source: .init(
315+
originalPath: "/virtual/AppModel.xcdatamodeld",
316+
selectedSourcePath: "/virtual/AppModel.xcdatamodeld/V1.xcdatamodel",
317+
compiledModelPath: "/virtual/AppModel.momd",
318+
inputKind: .xcdatamodeld,
319+
selectedVersionName: "V1.xcdatamodel"
320+
),
321+
generationPolicy: .init(
322+
accessLevel: .internal,
323+
singleFile: false,
324+
splitByEntity: true,
325+
generateInit: false,
326+
defaultDecodeFailurePolicy: .fallbackToDefaultValue
327+
),
328+
entities: [
329+
.init(
330+
name: "Item",
331+
managedObjectClassName: "NSManagedObject",
332+
representedClassName: "Item",
333+
attributes: [
334+
.init(
335+
persistentName: "status_raw",
336+
swiftName: "status",
337+
coreDataAttributeType: "Integer 32",
338+
coreDataPrimitiveType: "Int32",
339+
isOptional: false,
340+
hasModelDefaultValue: true,
341+
modelDefaultValueLiteral: "0",
342+
storage: .init(
343+
method: .raw,
344+
swiftType: "ItemStatus",
345+
nonOptionalSwiftType: "ItemStatus",
346+
transformerName: nil,
347+
decodeFailurePolicy: .fallbackToDefaultValue,
348+
isResolved: true
349+
)
350+
)
351+
],
352+
relationships: [],
353+
compositions: []
354+
)
355+
]
356+
)
357+
358+
let source = try #require(ToolingSourceRenderer.renderSources(from: modelIR).first?.contents)
359+
360+
#expect(source.contains(#"var status: ItemStatus = ItemStatus(rawValue: 0)!"#))
361+
}
362+
311363
@Test(
312364
"renderer emits required default storage without an initializer when model default is absent")
313365
func rendererEmitsRequiredDefaultStorageWithoutInitializer() throws {

Tests/CoreDataEvolutionToolingCoreTests/ToolingValidateComparatorTests.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,53 @@ struct ToolingValidateComparatorTests {
549549
)
550550
#expect(diagnosticsWithIgnore.isEmpty)
551551
}
552+
553+
@Test("comparator accepts non-optional raw storage when model default exists")
554+
func comparatorAcceptsNonOptionalRawStorageWithModelDefault() {
555+
let diagnostics = ToolingValidateComparator.compareQuick(
556+
expected: requiredRawStorageModelIR(),
557+
actual: .init(
558+
sourceDirectory: "/virtual/Sources",
559+
entities: [
560+
.init(
561+
filePath: "/virtual/Sources/Item.swift",
562+
className: "Item",
563+
objcEntityName: "Item",
564+
persistentModelArguments: .init(generateInit: false),
565+
properties: [
566+
.init(
567+
filePath: "/virtual/Sources/Item.swift",
568+
name: "status",
569+
typeName: "ItemStatus",
570+
nonOptionalTypeName: "ItemStatus",
571+
declarationRange: dummyRange(0, 0),
572+
declarationIndent: " ",
573+
isOptional: false,
574+
defaultValueLiteral: ".draft",
575+
defaultValueRange: dummyRange(0, 0),
576+
isStored: true,
577+
isStatic: false,
578+
hasIgnore: false,
579+
attribute: .init(
580+
range: dummyRange(0, 0),
581+
persistentName: "status_raw",
582+
storageMethod: .raw,
583+
transformerName: nil,
584+
transformerTypeName: nil,
585+
decodeFailurePolicy: nil
586+
),
587+
relationshipShape: nil
588+
)
589+
],
590+
customMembers: []
591+
)
592+
]
593+
),
594+
level: .conformance
595+
)
596+
597+
#expect(diagnostics.isEmpty)
598+
}
552599
}
553600

554601
private func ambiguousRelationshipModelIR() -> ToolingModelIR {
@@ -650,6 +697,55 @@ private func requiredDefaultStorageModelIR() -> ToolingModelIR {
650697
)
651698
}
652699

700+
private func requiredRawStorageModelIR() -> ToolingModelIR {
701+
.init(
702+
source: .init(
703+
originalPath: "/virtual/AppModel.xcdatamodeld",
704+
selectedSourcePath: "/virtual/AppModel.xcdatamodeld/V1.xcdatamodel",
705+
compiledModelPath: "/virtual/AppModel.momd",
706+
inputKind: .xcdatamodeld,
707+
selectedVersionName: "V1.xcdatamodel"
708+
),
709+
generationPolicy: .init(
710+
accessLevel: .internal,
711+
singleFile: false,
712+
splitByEntity: true,
713+
generateInit: false,
714+
defaultDecodeFailurePolicy: .fallbackToDefaultValue
715+
),
716+
entities: [
717+
.init(
718+
name: "Item",
719+
managedObjectClassName: "NSManagedObject",
720+
representedClassName: "Item",
721+
attributes: [
722+
.init(
723+
persistentName: "status_raw",
724+
swiftName: "status",
725+
coreDataAttributeType: "Integer 32",
726+
coreDataPrimitiveType: "Int32",
727+
isUnique: false,
728+
isTransient: false,
729+
isOptional: false,
730+
hasModelDefaultValue: true,
731+
modelDefaultValueLiteral: "0",
732+
storage: .init(
733+
method: .raw,
734+
swiftType: "ItemStatus",
735+
nonOptionalSwiftType: "ItemStatus",
736+
transformerName: nil,
737+
decodeFailurePolicy: .fallbackToDefaultValue,
738+
isResolved: true
739+
)
740+
)
741+
],
742+
relationships: [],
743+
compositions: []
744+
)
745+
]
746+
)
747+
}
748+
653749
private func dummyRange(_ start: Int, _ end: Int) -> ToolingTextRange {
654750
.init(startUTF8Offset: start, endUTF8Offset: end)
655751
}

0 commit comments

Comments
 (0)