This is some code golfing that got out of hand. Written in a playground and as such uses simplistic test helpers that just return output strings. Copy and paste into Swift Playgrounds on iOS or a playground in XCode to run.
Last active
June 27, 2018 18:37
-
-
Save stefanlindbohm/db032cd9e249eccf0f8e8ae91431d3f0 to your computer and use it in GitHub Desktop.
Swedish personal identity number validator & formatter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
enum SwedishPersonalIdentityNumberError: Error { | |
case invalidFormat, incorrectChecksum | |
} | |
class SwedishPersonalIdentityNumber: CustomStringConvertible { | |
var description: String { return personalIdentityNumberString } | |
private let personalIdentityNumberString: String | |
private static var dateFormatter: DateFormatter = { | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateFormat = "yyyyMMdd" | |
return dateFormatter | |
}() | |
init(string: String) throws { | |
var (centuryString, birthdateString, delimiter, birthNumberString, checksum) = | |
try SwedishPersonalIdentityNumber.extractComponents(string: string) | |
if (centuryString.count == 2) { | |
let yearsSinceBirthdate = SwedishPersonalIdentityNumber.yearsSince( | |
date: SwedishPersonalIdentityNumber.dateFormatter | |
.date(from: "\(centuryString)\(birthdateString)")! | |
) | |
if (yearsSinceBirthdate >= 100) { | |
delimiter = "+" | |
} else { | |
delimiter = "-" | |
} | |
} else if (delimiter.count == 0) { | |
delimiter = "-" | |
} | |
if (!SwedishPersonalIdentityNumber.luhnValidates(digits: "\(birthdateString)\(birthNumberString)\(checksum)")) { | |
throw SwedishPersonalIdentityNumberError.incorrectChecksum | |
} | |
personalIdentityNumberString = "\(birthdateString)\(delimiter)\(birthNumberString)\(checksum)" | |
} | |
// MARK: - Date handling | |
private static func yearsSince(date: Date) -> Int { | |
return Calendar.current.dateComponents( | |
[.year], | |
from: Calendar.current.startOfDay(for: date), | |
to: Calendar.current.startOfDay(for: Date()) | |
).year! | |
} | |
// MARK: - Extracting personal identity number components | |
private static let personalIdentityNumberRegex = try! NSRegularExpression( | |
pattern: "^((?:(?:\\d{2})(?=\\d{6}\\D|\\d{10}))?)(\\d{6})([-+]?)(\\d{3})(\\d{1})$", options: [] | |
) | |
private static func extractComponents(string: String) throws -> (centuryString: String, birthdateString: String, delimiter: String, birthNumberString: String, checksum: Int) { | |
guard | |
let result = SwedishPersonalIdentityNumber.personalIdentityNumberRegex | |
.firstMatch(in: string, options: [], range: NSMakeRange(0, string.count)) | |
else { | |
throw SwedishPersonalIdentityNumberError.invalidFormat | |
} | |
return ( | |
centuryString: String(string[Range(result.range(at: 1), in: string)!]), | |
birthdateString: String(string[Range(result.range(at: 2), in: string)!]), | |
delimiter: String(string[Range(result.range(at: 3), in: string)!]), | |
birthNumberString: String(string[Range(result.range(at: 4), in: string)!]), | |
checksum: Int(String(string[Range(result.range(at: 5), in: string)!]))! | |
) | |
} | |
// MARK: - Luhn check | |
private static func luhnValidates(digits: String) -> Bool { | |
return digits.flatMap { Int(String($0)) } | |
.reversed() | |
.enumerated() | |
.reduce(0) { sum, tuple in | |
let (i, digit) = tuple | |
if (i % 2 == 0) { | |
return sum + digit | |
} else { | |
return sum + (digit == 9 ? 9 : digit * 2 % 9) | |
} | |
} % 10 == 0 | |
} | |
} | |
// MARK: - Test helpers | |
func expectToEqual<T: Equatable>(_ lhs: T, _ rhs: T) -> String { | |
return lhs == rhs ? "Pass" : "Fail - expected \(lhs) to equal \(rhs)" | |
} | |
func expectToThrow(error: Error, block: () throws -> ()) -> String { | |
var caughtError: Error? | |
do { try block() } catch { caughtError = error } | |
if let caughtError = caughtError { | |
return caughtError.localizedDescription == error.localizedDescription ? | |
"Pass" : "Fail - expected to throw \(error), got \(caughtError)" | |
} else { | |
return "Fail - expected to throw \(error), nothing was thrown" | |
} | |
} | |
// MARK: - Tests | |
// it initializes with correct personal number string | |
let pin1 = try? SwedishPersonalIdentityNumber(string: "811218-9876") | |
expectToEqual(pin1?.description, "811218-9876") | |
// it assumes "-" delimiter when no century nor delimiter is given | |
let pin2 = try? SwedishPersonalIdentityNumber(string: "8112189876") | |
expectToEqual(pin2?.description, "811218-9876") | |
// it uses "-" delimiter when given a 12 digit number and birthdate is less than 100 years ago | |
let pin3 = try? SwedishPersonalIdentityNumber(string: "198112189876") | |
expectToEqual(pin3?.description, "811218-9876") | |
// it uses "+" delimiter when given a 12 digit number and birthdate is more than 100 years ago | |
let pin4 = try? SwedishPersonalIdentityNumber(string: "19171218-9875") | |
expectToEqual(pin4?.description, "171218+9875") | |
// it throws .invalidFormat error when given an incorrectly formatted string | |
expectToThrow(error: SwedishPersonalIdentityNumberError.invalidFormat) { | |
try SwedishPersonalIdentityNumber(string: "11218-9876") | |
} | |
// it throws .incorrectChecksum error when given a number with incorrect checksum | |
expectToThrow(error: SwedishPersonalIdentityNumberError.incorrectChecksum) { | |
try SwedishPersonalIdentityNumber(string: "811218-9875") | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment