Skip to content

Commit 74dd3a3

Browse files
committed
Fix validate default literal equivalence
1 parent 7a969d6 commit 74dd3a3

2 files changed

Lines changed: 345 additions & 4 deletions

File tree

Sources/CoreDataEvolutionToolingCore/Validation/ToolingValidateComparator.swift

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,10 @@ public enum ToolingValidateComparator {
400400

401401
switch attribute.storage.method {
402402
case .default:
403-
let expectedDefault = normalizeLiteral(attribute.modelDefaultValueLiteral)
404-
let actualDefault = normalizeLiteral(sourceProperty.defaultValueLiteral)
405-
if actualDefault != expectedDefault {
403+
if defaultLiteralsMatch(
404+
expected: attribute.modelDefaultValueLiteral,
405+
actual: sourceProperty.defaultValueLiteral
406+
) == false {
406407
diagnostics.append(
407408
error(
408409
"validate found default value mismatch for '\(entityName).\(expectedPropertyName)'. Expected '\(attribute.modelDefaultValueLiteral ?? "<missing>")', found '\(sourceProperty.defaultValueLiteral ?? "<missing>")'.",
@@ -547,14 +548,80 @@ public enum ToolingValidateComparator {
547548
}
548549
}
549550

551+
private static func defaultLiteralsMatch(expected: String?, actual: String?) -> Bool {
552+
let normalizedExpected = normalizeLiteral(expected)
553+
let normalizedActual = normalizeLiteral(actual)
554+
if normalizedExpected == normalizedActual {
555+
return true
556+
}
557+
558+
guard
559+
let semanticExpected = semanticLiteral(from: normalizedExpected),
560+
let semanticActual = semanticLiteral(from: normalizedActual)
561+
else {
562+
return false
563+
}
564+
return semanticExpected == semanticActual
565+
}
566+
550567
private static func normalizeLiteral(_ literal: String?) -> String? {
551-
literal?.replacingOccurrences(of: " ", with: "")
568+
literal?.filter { $0.isWhitespace == false }
552569
}
553570

554571
private static func normalizeTypeName(_ typeName: String?) -> String? {
555572
typeName?.replacingOccurrences(of: " ", with: "")
556573
}
557574

575+
/// Supports lightweight semantic comparison for common model defaults without full constant
576+
/// evaluation.
577+
private static func semanticLiteral(from literal: String?) -> ComparableDefaultLiteral? {
578+
guard let literal else {
579+
return nil
580+
}
581+
if let referenceDate = referenceDateIntervalLiteral(from: literal) {
582+
return .referenceDate(referenceDate)
583+
}
584+
if let numeric = decimalLiteral(from: literal) {
585+
return .number(numeric)
586+
}
587+
return nil
588+
}
589+
590+
private static func referenceDateIntervalLiteral(from literal: String) -> Decimal? {
591+
guard
592+
let range = literal.range(
593+
of: #"^(?:Foundation\.)?Date(?:\.init)?\(timeIntervalSinceReferenceDate:(.+)\)$"#,
594+
options: .regularExpression
595+
)
596+
else {
597+
return nil
598+
}
599+
600+
let intervalLiteral = String(literal[range])
601+
.replacingOccurrences(
602+
of: #"^(?:Foundation\.)?Date(?:\.init)?\(timeIntervalSinceReferenceDate:"#,
603+
with: "",
604+
options: .regularExpression
605+
)
606+
.dropLast()
607+
608+
return decimalLiteral(from: String(intervalLiteral))
609+
}
610+
611+
private static func decimalLiteral(from literal: String) -> Decimal? {
612+
let sanitized = literal.replacingOccurrences(of: "_", with: "")
613+
guard
614+
sanitized.range(
615+
of: #"^[+-]?(?:(?:\d+(?:\.\d*)?)|(?:\.\d+))(?:[eE][+-]?\d+)?$"#,
616+
options: .regularExpression
617+
) != nil
618+
else {
619+
return nil
620+
}
621+
622+
return Decimal(string: sanitized, locale: Locale(identifier: "en_US_POSIX"))
623+
}
624+
558625
private static func compareRelationshipAnnotation(
559626
entityName: String,
560627
relationship: ToolingRelationshipIR,
@@ -871,3 +938,8 @@ public enum ToolingValidateComparator {
871938
return sourceIR.transformerRegistrations[transformerTypeName]
872939
}
873940
}
941+
942+
private enum ComparableDefaultLiteral: Equatable {
943+
case number(Decimal)
944+
case referenceDate(Decimal)
945+
}

Tests/CoreDataEvolutionToolingCoreTests/ToolingValidateComparatorTests.swift

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,188 @@ struct ToolingValidateComparatorTests {
457457
)
458458
}
459459

460+
@Test("comparator accepts semantically equivalent numeric defaults")
461+
func comparatorAcceptsSemanticallyEquivalentNumericDefaults() {
462+
let diagnostics = ToolingValidateComparator.compareQuick(
463+
expected: semanticDefaultComparisonModelIR(),
464+
actual: .init(
465+
sourceDirectory: "/virtual/Sources",
466+
entities: [
467+
.init(
468+
filePath: "/virtual/Sources/Item.swift",
469+
className: "Item",
470+
objcEntityName: "Item",
471+
persistentModelArguments: .init(generateInit: false),
472+
properties: [
473+
.init(
474+
filePath: "/virtual/Sources/Item.swift",
475+
name: "valueMax",
476+
typeName: "Double",
477+
nonOptionalTypeName: "Double",
478+
declarationRange: dummyRange(0, 0),
479+
declarationIndent: " ",
480+
isOptional: false,
481+
defaultValueLiteral: "0",
482+
defaultValueRange: dummyRange(0, 1),
483+
isStored: true,
484+
isStatic: false,
485+
hasIgnore: false,
486+
attribute: .init(
487+
range: dummyRange(0, 0),
488+
persistentName: "value_max",
489+
storageMethod: nil,
490+
transformerName: nil,
491+
transformerTypeName: nil,
492+
decodeFailurePolicy: nil
493+
),
494+
relationshipShape: nil
495+
),
496+
.init(
497+
filePath: "/virtual/Sources/Item.swift",
498+
name: "valueRefmax",
499+
typeName: "Double",
500+
nonOptionalTypeName: "Double",
501+
declarationRange: dummyRange(2, 2),
502+
declarationIndent: " ",
503+
isOptional: false,
504+
defaultValueLiteral: "3_000",
505+
defaultValueRange: dummyRange(2, 7),
506+
isStored: true,
507+
isStatic: false,
508+
hasIgnore: false,
509+
attribute: .init(
510+
range: dummyRange(0, 0),
511+
persistentName: "value_refmax",
512+
storageMethod: nil,
513+
transformerName: nil,
514+
transformerTypeName: nil,
515+
decodeFailurePolicy: nil
516+
),
517+
relationshipShape: nil
518+
),
519+
.init(
520+
filePath: "/virtual/Sources/Item.swift",
521+
name: "createDate",
522+
typeName: "Date",
523+
nonOptionalTypeName: "Date",
524+
declarationRange: dummyRange(8, 8),
525+
declarationIndent: " ",
526+
isOptional: false,
527+
defaultValueLiteral: "Date(timeIntervalSinceReferenceDate: 623_726_820)",
528+
defaultValueRange: dummyRange(8, 61),
529+
isStored: true,
530+
isStatic: false,
531+
hasIgnore: false,
532+
attribute: nil,
533+
relationshipShape: nil
534+
),
535+
],
536+
customMembers: []
537+
)
538+
]
539+
),
540+
level: .conformance
541+
)
542+
543+
#expect(diagnostics.isEmpty)
544+
}
545+
546+
@Test("comparator still rejects non-equivalent semantic defaults")
547+
func comparatorRejectsNonEquivalentSemanticDefaults() {
548+
let diagnostics = ToolingValidateComparator.compareQuick(
549+
expected: semanticDefaultComparisonModelIR(),
550+
actual: .init(
551+
sourceDirectory: "/virtual/Sources",
552+
entities: [
553+
.init(
554+
filePath: "/virtual/Sources/Item.swift",
555+
className: "Item",
556+
objcEntityName: "Item",
557+
persistentModelArguments: .init(generateInit: false),
558+
properties: [
559+
.init(
560+
filePath: "/virtual/Sources/Item.swift",
561+
name: "valueMax",
562+
typeName: "Double",
563+
nonOptionalTypeName: "Double",
564+
declarationRange: dummyRange(0, 0),
565+
declarationIndent: " ",
566+
isOptional: false,
567+
defaultValueLiteral: "1",
568+
defaultValueRange: dummyRange(0, 1),
569+
isStored: true,
570+
isStatic: false,
571+
hasIgnore: false,
572+
attribute: .init(
573+
range: dummyRange(0, 0),
574+
persistentName: "value_max",
575+
storageMethod: nil,
576+
transformerName: nil,
577+
transformerTypeName: nil,
578+
decodeFailurePolicy: nil
579+
),
580+
relationshipShape: nil
581+
),
582+
.init(
583+
filePath: "/virtual/Sources/Item.swift",
584+
name: "valueRefmax",
585+
typeName: "Double",
586+
nonOptionalTypeName: "Double",
587+
declarationRange: dummyRange(2, 2),
588+
declarationIndent: " ",
589+
isOptional: false,
590+
defaultValueLiteral: "3000.0",
591+
defaultValueRange: dummyRange(2, 8),
592+
isStored: true,
593+
isStatic: false,
594+
hasIgnore: false,
595+
attribute: .init(
596+
range: dummyRange(0, 0),
597+
persistentName: "value_refmax",
598+
storageMethod: nil,
599+
transformerName: nil,
600+
transformerTypeName: nil,
601+
decodeFailurePolicy: nil
602+
),
603+
relationshipShape: nil
604+
),
605+
.init(
606+
filePath: "/virtual/Sources/Item.swift",
607+
name: "createDate",
608+
typeName: "Date",
609+
nonOptionalTypeName: "Date",
610+
declarationRange: dummyRange(8, 8),
611+
declarationIndent: " ",
612+
isOptional: false,
613+
defaultValueLiteral: "Date(timeIntervalSinceReferenceDate: 623726821.0)",
614+
defaultValueRange: dummyRange(8, 63),
615+
isStored: true,
616+
isStatic: false,
617+
hasIgnore: false,
618+
attribute: nil,
619+
relationshipShape: nil
620+
),
621+
],
622+
customMembers: []
623+
)
624+
]
625+
),
626+
level: .conformance
627+
)
628+
629+
#expect(diagnostics.count == 2)
630+
#expect(
631+
diagnostics.contains {
632+
$0.message.contains("default value mismatch for 'Item.valueMax'")
633+
}
634+
)
635+
#expect(
636+
diagnostics.contains {
637+
$0.message.contains("default value mismatch for 'Item.createDate'")
638+
}
639+
)
640+
}
641+
460642
@Test("comparator can ignore optionality mismatch for optional model attribute")
461643
func comparatorCanIgnoreOptionalityMismatchForOptionalModelAttribute() {
462644
let model = ToolingModelIR(
@@ -746,6 +928,93 @@ private func requiredRawStorageModelIR() -> ToolingModelIR {
746928
)
747929
}
748930

931+
private func semanticDefaultComparisonModelIR() -> ToolingModelIR {
932+
.init(
933+
source: .init(
934+
originalPath: "/virtual/AppModel.xcdatamodeld",
935+
selectedSourcePath: "/virtual/AppModel.xcdatamodeld/V1.xcdatamodel",
936+
compiledModelPath: "/virtual/AppModel.momd",
937+
inputKind: .xcdatamodeld,
938+
selectedVersionName: "V1.xcdatamodel"
939+
),
940+
generationPolicy: .init(
941+
accessLevel: .internal,
942+
singleFile: false,
943+
splitByEntity: true,
944+
generateInit: false,
945+
defaultDecodeFailurePolicy: .fallbackToDefaultValue
946+
),
947+
entities: [
948+
.init(
949+
name: "Item",
950+
managedObjectClassName: "NSManagedObject",
951+
representedClassName: "Item",
952+
attributes: [
953+
.init(
954+
persistentName: "value_max",
955+
swiftName: "valueMax",
956+
coreDataAttributeType: "Double",
957+
coreDataPrimitiveType: "Double",
958+
isUnique: false,
959+
isTransient: false,
960+
isOptional: false,
961+
hasModelDefaultValue: true,
962+
modelDefaultValueLiteral: "0.0",
963+
storage: .init(
964+
method: .default,
965+
swiftType: "Double",
966+
nonOptionalSwiftType: "Double",
967+
transformerName: nil,
968+
decodeFailurePolicy: nil,
969+
isResolved: true
970+
)
971+
),
972+
.init(
973+
persistentName: "value_refmax",
974+
swiftName: "valueRefmax",
975+
coreDataAttributeType: "Double",
976+
coreDataPrimitiveType: "Double",
977+
isUnique: false,
978+
isTransient: false,
979+
isOptional: false,
980+
hasModelDefaultValue: true,
981+
modelDefaultValueLiteral: "3000.0",
982+
storage: .init(
983+
method: .default,
984+
swiftType: "Double",
985+
nonOptionalSwiftType: "Double",
986+
transformerName: nil,
987+
decodeFailurePolicy: nil,
988+
isResolved: true
989+
)
990+
),
991+
.init(
992+
persistentName: "createDate",
993+
swiftName: "createDate",
994+
coreDataAttributeType: "Date",
995+
coreDataPrimitiveType: "Date",
996+
isUnique: false,
997+
isTransient: false,
998+
isOptional: false,
999+
hasModelDefaultValue: true,
1000+
modelDefaultValueLiteral: "Date(timeIntervalSinceReferenceDate: 623726820.0)",
1001+
storage: .init(
1002+
method: .default,
1003+
swiftType: "Date",
1004+
nonOptionalSwiftType: "Date",
1005+
transformerName: nil,
1006+
decodeFailurePolicy: nil,
1007+
isResolved: true
1008+
)
1009+
),
1010+
],
1011+
relationships: [],
1012+
compositions: []
1013+
)
1014+
]
1015+
)
1016+
}
1017+
7491018
private func dummyRange(_ start: Int, _ end: Int) -> ToolingTextRange {
7501019
.init(startUTF8Offset: start, endUTF8Offset: end)
7511020
}

0 commit comments

Comments
 (0)