feat(schema): XSD simple-type datatype library with facets
Add the PureXML.Schema namespace validating XSD Part 2 built-in datatypes and constraining facets. Built-ins span string/boolean/decimal, the bounded integer family with intrinsic bounds, float/double, duration, the eight date/time types, hexBinary, base64Binary, anyURI, QName, and the Name/NCName/NMTOKEN/language family. Facets: length/minLength/maxLength, pattern (via the regex engine), enumeration (value-space comparison), whiteSpace, min/maxInclusive/Exclusive, totalDigits, fractionDigits. Decimals compare exactly with no floating-point loss; date/time fields are range-validated with leap years, 24:00:00, and timezone bounds. Closes #8
This commit is contained in:
parent
2bc7dd2410
commit
e862f364aa
11
CHANGELOG.md
11
CHANGELOG.md
@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- XSD simple-type datatype library (toward #2 schema validation). The new
|
||||
`PureXML.Schema` namespace validates a lexical value against the XSD Part 2
|
||||
built-in datatypes (`string`, `boolean`, `decimal`, the bounded integer family,
|
||||
`float`/`double`, `duration`, the eight date/time types, `hexBinary`,
|
||||
`base64Binary`, `anyURI`, `QName`, and the `Name`/`NCName`/`NMTOKEN`/`language`
|
||||
family) and the constraining facets (`length`/`minLength`/`maxLength`,
|
||||
`pattern` via the regex engine, `enumeration`, `whiteSpace`,
|
||||
`min`/`maxInclusive`/`Exclusive`, `totalDigits`, `fractionDigits`). Decimals are
|
||||
compared exactly with no floating-point loss, date/time fields are
|
||||
range-validated (leap years, `24:00:00`, timezone bounds), and the integer
|
||||
types enforce their intrinsic bounds.
|
||||
- Regular-expression engine (#30, the XML Schema regex flavor, gating XSD). The
|
||||
new `PureXML.Regex` namespace compiles a pattern (literals, `.`, character
|
||||
classes with ranges and negation, the escapes `\d \D \w \W \s \S \i \I \c \C`,
|
||||
|
||||
@ -42,4 +42,7 @@ public enum PureXML {
|
||||
|
||||
/// Regular-expression types (the XML Schema regex flavor).
|
||||
public enum Regex {}
|
||||
|
||||
/// XML Schema (XSD) and RELAX NG validation types.
|
||||
public enum Schema {}
|
||||
}
|
||||
|
||||
127
Sources/Schema/BuiltinType.swift
Normal file
127
Sources/Schema/BuiltinType.swift
Normal file
@ -0,0 +1,127 @@
|
||||
public extension PureXML.Schema {
|
||||
/// Whitespace processing applied before a value is validated.
|
||||
enum WhiteSpace: Sendable {
|
||||
case preserve
|
||||
case replace
|
||||
case collapse
|
||||
}
|
||||
|
||||
/// A built-in XSD datatype. Each carries its primitive value space, its
|
||||
/// intrinsic `whiteSpace`, and, for the bounded integer types, its inclusive
|
||||
/// bounds, exactly as XSD Part 2 derives them.
|
||||
enum BuiltinType: String, Sendable, CaseIterable {
|
||||
case string
|
||||
case normalizedString
|
||||
case token
|
||||
case language
|
||||
case name = "Name"
|
||||
case ncName = "NCName"
|
||||
case nmtoken = "NMTOKEN"
|
||||
case id = "ID"
|
||||
case idref = "IDREF"
|
||||
case entity = "ENTITY"
|
||||
case boolean
|
||||
case decimal
|
||||
case integer
|
||||
case long
|
||||
case int
|
||||
case short
|
||||
case byte
|
||||
case nonNegativeInteger
|
||||
case positiveInteger
|
||||
case nonPositiveInteger
|
||||
case negativeInteger
|
||||
case unsignedLong
|
||||
case unsignedInt
|
||||
case unsignedShort
|
||||
case unsignedByte
|
||||
case float
|
||||
case double
|
||||
case duration
|
||||
case dateTime
|
||||
case date
|
||||
case time
|
||||
case gYearMonth
|
||||
case gYear
|
||||
case gMonthDay
|
||||
case gDay
|
||||
case gMonth
|
||||
case hexBinary
|
||||
case base64Binary
|
||||
case anyURI
|
||||
case qName = "QName"
|
||||
|
||||
var whiteSpace: WhiteSpace {
|
||||
switch self {
|
||||
case .string: .preserve
|
||||
case .normalizedString: .replace
|
||||
default: .collapse
|
||||
}
|
||||
}
|
||||
|
||||
var primitive: Primitive {
|
||||
if let primitive = Self.stringLike[self] { return primitive }
|
||||
if Self.integerLike.contains(self) { return .integer }
|
||||
return computedPrimitive
|
||||
}
|
||||
|
||||
private var computedPrimitive: Primitive {
|
||||
switch self {
|
||||
case .boolean: .boolean
|
||||
case .decimal: .decimal
|
||||
case .float: .float
|
||||
case .double: .double
|
||||
case .duration: .duration
|
||||
case .dateTime: .dateKind(.dateTime)
|
||||
case .date: .dateKind(.date)
|
||||
case .time: .dateKind(.time)
|
||||
case .gYearMonth: .dateKind(.gYearMonth)
|
||||
case .gYear: .dateKind(.gYear)
|
||||
case .gMonthDay: .dateKind(.gMonthDay)
|
||||
case .gDay: .dateKind(.gDay)
|
||||
case .gMonth: .dateKind(.gMonth)
|
||||
case .hexBinary: .hexBinary
|
||||
case .base64Binary: .base64Binary
|
||||
case .anyURI: .anyURI
|
||||
case .qName: .qName
|
||||
default: .string
|
||||
}
|
||||
}
|
||||
|
||||
private static let stringLike: [BuiltinType: Primitive] = [
|
||||
.string: .string, .normalizedString: .string, .token: .string,
|
||||
.language: .language, .name: .name,
|
||||
.ncName: .ncName, .id: .ncName, .idref: .ncName, .entity: .ncName,
|
||||
.nmtoken: .nmtoken,
|
||||
]
|
||||
|
||||
private static let integerLike: Set<BuiltinType> = [
|
||||
.integer, .long, .int, .short, .byte,
|
||||
.nonNegativeInteger, .positiveInteger, .nonPositiveInteger, .negativeInteger,
|
||||
.unsignedLong, .unsignedInt, .unsignedShort, .unsignedByte,
|
||||
]
|
||||
|
||||
/// The intrinsic inclusive bounds of the bounded integer types.
|
||||
var bounds: (lower: DecimalValue?, upper: DecimalValue?) {
|
||||
switch self {
|
||||
case .long: (Self.bound("-9223372036854775808"), Self.bound("9223372036854775807"))
|
||||
case .int: (Self.bound("-2147483648"), Self.bound("2147483647"))
|
||||
case .short: (Self.bound("-32768"), Self.bound("32767"))
|
||||
case .byte: (Self.bound("-128"), Self.bound("127"))
|
||||
case .unsignedLong: (Self.bound("0"), Self.bound("18446744073709551615"))
|
||||
case .unsignedInt: (Self.bound("0"), Self.bound("4294967295"))
|
||||
case .unsignedShort: (Self.bound("0"), Self.bound("65535"))
|
||||
case .unsignedByte: (Self.bound("0"), Self.bound("255"))
|
||||
case .nonNegativeInteger: (Self.bound("0"), nil)
|
||||
case .positiveInteger: (Self.bound("1"), nil)
|
||||
case .nonPositiveInteger: (nil, Self.bound("0"))
|
||||
case .negativeInteger: (nil, Self.bound("-1"))
|
||||
default: (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func bound(_ lexical: String) -> DecimalValue? {
|
||||
DecimalValue(lexical, allowFraction: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
Sources/Schema/DateTimeParser.swift
Normal file
174
Sources/Schema/DateTimeParser.swift
Normal file
@ -0,0 +1,174 @@
|
||||
extension PureXML.Schema {
|
||||
/// Parses an XSD date/time lexical form for a given ``DateKind``, validating
|
||||
/// field ranges (including leap years and the `24:00:00` midnight form) and
|
||||
/// the timezone. Returns nil when the form is invalid.
|
||||
struct DateTimeParser {
|
||||
private let chars: [Character]
|
||||
private var index = 0
|
||||
|
||||
private init(_ lexical: String) {
|
||||
chars = Array(lexical)
|
||||
}
|
||||
|
||||
static func parse(_ lexical: String, kind: DateKind) -> DateTimeValue? {
|
||||
var parser = DateTimeParser(lexical)
|
||||
guard var value = parser.parseFields(kind) else { return nil }
|
||||
guard parser.parseTimezone(into: &value) else { return nil }
|
||||
return parser.isAtEnd ? value : nil
|
||||
}
|
||||
|
||||
private mutating func parseFields(_ kind: DateKind) -> DateTimeValue? {
|
||||
switch kind {
|
||||
case .dateTime: parseDateTime()
|
||||
case .date: parseDate()
|
||||
case .time: parseTimeOnly()
|
||||
case .gYearMonth: parseYearMonth()
|
||||
case .gYear: parseYearOnly()
|
||||
case .gMonthDay: parseMonthDay()
|
||||
case .gDay: parseDayOnly()
|
||||
case .gMonth: parseMonthOnly()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Composite forms
|
||||
|
||||
private mutating func parseDateTime() -> DateTimeValue? {
|
||||
guard var value = parseDate(), consume("T") else { return nil }
|
||||
return parseTime(into: &value) ? value : nil
|
||||
}
|
||||
|
||||
private mutating func parseDate() -> DateTimeValue? {
|
||||
guard var value = parseYearMonth(), consume("-"), let day = readFixed(2) else { return nil }
|
||||
value.day = day
|
||||
return dayInRange(value) ? value : nil
|
||||
}
|
||||
|
||||
private mutating func parseYearMonth() -> DateTimeValue? {
|
||||
guard var value = parseYearOnly(), consume("-"), let month = readFixed(2), (1 ... 12).contains(month) else {
|
||||
return nil
|
||||
}
|
||||
value.month = month
|
||||
return value
|
||||
}
|
||||
|
||||
private mutating func parseYearOnly() -> DateTimeValue? {
|
||||
var value = DateTimeValue()
|
||||
let negative = consume("-")
|
||||
guard let digits = readDigits(minimum: 4) else { return nil }
|
||||
value.year = (negative ? -1 : 1) * digits
|
||||
return value
|
||||
}
|
||||
|
||||
private mutating func parseTimeOnly() -> DateTimeValue? {
|
||||
var value = DateTimeValue()
|
||||
return parseTime(into: &value) ? value : nil
|
||||
}
|
||||
|
||||
// MARK: g* forms
|
||||
|
||||
private mutating func parseMonthDay() -> DateTimeValue? {
|
||||
guard consume("--"), let month = readFixed(2), (1 ... 12).contains(month), consume("-"),
|
||||
let day = readFixed(2) else { return nil }
|
||||
var value = DateTimeValue(year: 2000, month: month, day: day)
|
||||
return dayInRange(value) ? value : nil
|
||||
}
|
||||
|
||||
private mutating func parseMonthOnly() -> DateTimeValue? {
|
||||
guard consume("--"), let month = readFixed(2), (1 ... 12).contains(month) else { return nil }
|
||||
return DateTimeValue(month: month)
|
||||
}
|
||||
|
||||
private mutating func parseDayOnly() -> DateTimeValue? {
|
||||
guard consume("---"), let day = readFixed(2), (1 ... 31).contains(day) else { return nil }
|
||||
return DateTimeValue(day: day)
|
||||
}
|
||||
|
||||
// MARK: Time and timezone
|
||||
|
||||
private mutating func parseTime(into value: inout DateTimeValue) -> Bool {
|
||||
guard let hour = readFixed(2), consume(":"), let minute = readFixed(2), consume(":"),
|
||||
let second = readFixed(2) else { return false }
|
||||
guard (0 ... 24).contains(hour), (0 ... 59).contains(minute), (0 ... 59).contains(second) else { return false }
|
||||
if hour == 24, minute != 0 || second != 0 { return false }
|
||||
value.hour = hour
|
||||
value.minute = minute
|
||||
value.second = second
|
||||
if consume(".") {
|
||||
guard let fraction = readFraction() else { return false }
|
||||
value.fraction = fraction
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private mutating func parseTimezone(into value: inout DateTimeValue) -> Bool {
|
||||
if consume("Z") { value.offsetMinutes = 0
|
||||
return true
|
||||
}
|
||||
guard peek() == "+" || peek() == "-" else { return true }
|
||||
let negative = consume("-")
|
||||
if !negative { advance() }
|
||||
guard let hour = readFixed(2), consume(":"), let minute = readFixed(2) else { return false }
|
||||
guard (0 ... 14).contains(hour), (0 ... 59).contains(minute), hour * 60 + minute <= 14 * 60 else {
|
||||
return false
|
||||
}
|
||||
value.offsetMinutes = (negative ? -1 : 1) * (hour * 60 + minute)
|
||||
return true
|
||||
}
|
||||
|
||||
private func dayInRange(_ value: DateTimeValue) -> Bool {
|
||||
(1 ... DateTimeValue.daysInMonth(year: value.year, month: value.month)).contains(value.day)
|
||||
}
|
||||
|
||||
// MARK: Cursor
|
||||
|
||||
private var isAtEnd: Bool {
|
||||
index >= chars.count
|
||||
}
|
||||
|
||||
private func peek() -> Character? {
|
||||
index < chars.count ? chars[index] : nil
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
|
||||
private mutating func consume(_ literal: String) -> Bool {
|
||||
let target = Array(literal)
|
||||
guard index + target.count <= chars.count, Array(chars[index ..< index + target.count]) == target else {
|
||||
return false
|
||||
}
|
||||
index += target.count
|
||||
return true
|
||||
}
|
||||
|
||||
/// Reads exactly `count` digits as an integer, or nil.
|
||||
private mutating func readFixed(_ count: Int) -> Int? {
|
||||
guard index + count <= chars.count else { return nil }
|
||||
let slice = chars[index ..< index + count]
|
||||
guard slice.allSatisfy(\.isNumber) else { return nil }
|
||||
index += count
|
||||
return Int(String(slice))
|
||||
}
|
||||
|
||||
/// Reads at least `minimum` digits as an integer.
|
||||
private mutating func readDigits(minimum: Int) -> Int? {
|
||||
let start = index
|
||||
while let character = peek(), character.isNumber {
|
||||
advance()
|
||||
}
|
||||
guard index - start >= minimum else { return nil }
|
||||
return Int(String(chars[start ..< index]))
|
||||
}
|
||||
|
||||
/// Reads the fractional seconds after the decimal point.
|
||||
private mutating func readFraction() -> Double? {
|
||||
let start = index
|
||||
while let character = peek(), character.isNumber {
|
||||
advance()
|
||||
}
|
||||
guard index > start else { return nil }
|
||||
return Double("0." + String(chars[start ..< index]))
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Sources/Schema/DateTimeValue.swift
Normal file
62
Sources/Schema/DateTimeValue.swift
Normal file
@ -0,0 +1,62 @@
|
||||
extension PureXML.Schema {
|
||||
/// The eight XSD date/time datatypes, distinguished by which fields their
|
||||
/// lexical form carries.
|
||||
enum DateKind: Sendable {
|
||||
case dateTime
|
||||
case date
|
||||
case time
|
||||
case gYearMonth
|
||||
case gYear
|
||||
case gMonthDay
|
||||
case gDay
|
||||
case gMonth
|
||||
}
|
||||
|
||||
/// A parsed and range-validated XSD date/time value, with a UTC-normalized key
|
||||
/// for ordering facet bounds. Values without a timezone are ordered as if in
|
||||
/// UTC, which gives the total order facet checking needs.
|
||||
struct DateTimeValue: Equatable, Comparable, Sendable {
|
||||
var year = 1
|
||||
var month = 1
|
||||
var day = 1
|
||||
var hour = 0
|
||||
var minute = 0
|
||||
var second = 0
|
||||
var fraction = 0.0
|
||||
var offsetMinutes = 0
|
||||
|
||||
static func < (lhs: DateTimeValue, rhs: DateTimeValue) -> Bool {
|
||||
lhs.key < rhs.key
|
||||
}
|
||||
|
||||
/// Seconds from a fixed epoch in UTC, the ordering key.
|
||||
private var key: Double {
|
||||
let days = Self.daysFromCivil(year: year, month: month, day: day)
|
||||
let seconds = Double(days * 86400 + hour * 3600 + minute * 60 + second)
|
||||
return seconds + fraction - Double(offsetMinutes * 60)
|
||||
}
|
||||
|
||||
/// The civil-date-to-day-count algorithm (proleptic Gregorian), valid for
|
||||
/// any year. Day 0 is 1970-01-01.
|
||||
static func daysFromCivil(year: Int, month: Int, day: Int) -> Int {
|
||||
let shifted = month <= 2 ? year - 1 : year
|
||||
let era = (shifted >= 0 ? shifted : shifted - 399) / 400
|
||||
let yearOfEra = shifted - era * 400
|
||||
let dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1
|
||||
let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear
|
||||
return era * 146_097 + dayOfEra - 719_468
|
||||
}
|
||||
|
||||
static func isLeapYear(_ year: Int) -> Bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
static func daysInMonth(year: Int, month: Int) -> Int {
|
||||
switch month {
|
||||
case 1, 3, 5, 7, 8, 10, 12: 31
|
||||
case 4, 6, 9, 11: 30
|
||||
default: isLeapYear(year) ? 29 : 28
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
Sources/Schema/DecimalValue.swift
Normal file
92
Sources/Schema/DecimalValue.swift
Normal file
@ -0,0 +1,92 @@
|
||||
extension PureXML.Schema {
|
||||
/// An exact decimal value, parsed from an XSD `decimal`/`integer` lexical form
|
||||
/// and compared without floating-point loss. Holds the sign and the
|
||||
/// significant integer and fraction digits (leading and trailing zeros
|
||||
/// removed), which is also what `totalDigits` and `fractionDigits` count.
|
||||
struct DecimalValue: Equatable, Comparable, Sendable {
|
||||
let negative: Bool
|
||||
let integerDigits: [Character]
|
||||
let fractionDigits: [Character]
|
||||
|
||||
/// Parses a decimal lexical form. With `allowFraction` false the value must
|
||||
/// be an integer (no decimal point).
|
||||
init?(_ lexical: String, allowFraction: Bool) {
|
||||
var characters = Array(lexical)
|
||||
guard !characters.isEmpty else { return nil }
|
||||
|
||||
var negative = false
|
||||
if characters.first == "+" || characters.first == "-" {
|
||||
negative = characters.first == "-"
|
||||
characters.removeFirst()
|
||||
}
|
||||
guard !characters.isEmpty else { return nil }
|
||||
|
||||
let parts = Self.split(characters)
|
||||
guard let (whole, fraction) = parts, allowFraction || fraction == nil else { return nil }
|
||||
guard whole.allSatisfy(\.isNumber), (fraction ?? []).allSatisfy(\.isNumber) else { return nil }
|
||||
guard !whole.isEmpty || !(fraction ?? []).isEmpty else { return nil }
|
||||
|
||||
let integerDigits = Self.dropLeadingZeros(whole)
|
||||
let fractionDigits = Self.dropTrailingZeros(fraction ?? [])
|
||||
self.integerDigits = integerDigits
|
||||
self.fractionDigits = fractionDigits
|
||||
// Negative zero is the same value as zero.
|
||||
self.negative = (integerDigits.isEmpty && fractionDigits.isEmpty) ? false : negative
|
||||
}
|
||||
|
||||
/// Splits the digits on a single decimal point, rejecting a second point.
|
||||
/// Returns nil when malformed.
|
||||
private static func split(_ characters: [Character]) -> (whole: [Character], fraction: [Character]?)? {
|
||||
guard let dot = characters.firstIndex(of: ".") else { return (characters, nil) }
|
||||
let rest = characters[(dot + 1)...]
|
||||
guard !rest.contains(".") else { return nil }
|
||||
return (Array(characters[..<dot]), Array(rest))
|
||||
}
|
||||
|
||||
private static func dropLeadingZeros(_ digits: [Character]) -> [Character] {
|
||||
Array(digits.drop { $0 == "0" })
|
||||
}
|
||||
|
||||
private static func dropTrailingZeros(_ digits: [Character]) -> [Character] {
|
||||
var digits = digits
|
||||
while digits.last == "0" {
|
||||
digits.removeLast()
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
var isZero: Bool {
|
||||
integerDigits.isEmpty && fractionDigits.isEmpty
|
||||
}
|
||||
|
||||
/// The XSD `totalDigits` of the value: every significant digit, at least one.
|
||||
var totalDigits: Int {
|
||||
Swift.max(1, integerDigits.count + fractionDigits.count)
|
||||
}
|
||||
|
||||
static func < (lhs: DecimalValue, rhs: DecimalValue) -> Bool {
|
||||
if lhs.negative != rhs.negative { return lhs.negative }
|
||||
let magnitude = compareMagnitude(lhs, rhs)
|
||||
return lhs.negative ? magnitude > 0 : magnitude < 0
|
||||
}
|
||||
|
||||
/// Negative when `lhs` has the smaller magnitude, positive when larger.
|
||||
private static func compareMagnitude(_ lhs: DecimalValue, _ rhs: DecimalValue) -> Int {
|
||||
if lhs.integerDigits.count != rhs.integerDigits.count {
|
||||
return lhs.integerDigits.count < rhs.integerDigits.count ? -1 : 1
|
||||
}
|
||||
if let order = lexicographic(lhs.integerDigits, rhs.integerDigits) { return order }
|
||||
return lexicographic(lhs.fractionDigits, rhs.fractionDigits) ?? 0
|
||||
}
|
||||
|
||||
/// Digit-by-digit comparison; a proper prefix is the smaller value. Returns
|
||||
/// nil when the sequences are identical.
|
||||
private static func lexicographic(_ lhs: [Character], _ rhs: [Character]) -> Int? {
|
||||
for (left, right) in zip(lhs, rhs) where left != right {
|
||||
return left < right ? -1 : 1
|
||||
}
|
||||
if lhs.count == rhs.count { return nil }
|
||||
return lhs.count < rhs.count ? -1 : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
130
Sources/Schema/Lexical.swift
Normal file
130
Sources/Schema/Lexical.swift
Normal file
@ -0,0 +1,130 @@
|
||||
extension PureXML.Schema {
|
||||
/// Lexical recognizers for the XSD primitive value spaces that are not date or
|
||||
/// decimal. Each takes an already whiteSpace-processed string.
|
||||
enum Lexical {
|
||||
static func isBoolean(_ value: String) -> Bool {
|
||||
value == "true" || value == "false" || value == "0" || value == "1"
|
||||
}
|
||||
|
||||
/// `float`/`double`: an optional-sign mantissa with an optional exponent,
|
||||
/// or the special values `INF`, `-INF`, `NaN`.
|
||||
static func isFloating(_ value: String) -> Bool {
|
||||
if value == "INF" || value == "-INF" || value == "NaN" { return true }
|
||||
var characters = Substring(value)
|
||||
if characters.first == "+" || characters.first == "-" { characters = characters.dropFirst() }
|
||||
let exponentSplit = characters.split(separator: "e", omittingEmptySubsequences: false)
|
||||
let altSplit = characters.split(separator: "E", omittingEmptySubsequences: false)
|
||||
let parts = exponentSplit.count == 2 ? exponentSplit : altSplit
|
||||
guard parts.count <= 2 else { return false }
|
||||
guard isDecimalMantissa(parts[0]) else { return false }
|
||||
return parts.count == 1 || isSignedInteger(parts[1])
|
||||
}
|
||||
|
||||
private static func isDecimalMantissa(_ value: Substring) -> Bool {
|
||||
let segments = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard segments.count <= 2 else { return false }
|
||||
let whole = segments[0]
|
||||
let fraction = segments.count == 2 ? segments[1] : Substring("")
|
||||
guard whole.allSatisfy(\.isNumber), fraction.allSatisfy(\.isNumber) else { return false }
|
||||
return !whole.isEmpty || !fraction.isEmpty
|
||||
}
|
||||
|
||||
private static func isSignedInteger(_ value: Substring) -> Bool {
|
||||
var digits = value
|
||||
if digits.first == "+" || digits.first == "-" { digits = digits.dropFirst() }
|
||||
return !digits.isEmpty && digits.allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
/// `duration`: `-?P(nY)?(nM)?(nD)?(T(nH)?(nM)?(nS, with fraction)?)?` with at
|
||||
/// least one component and no empty `T`.
|
||||
static func isDuration(_ value: String) -> Bool {
|
||||
var rest = Substring(value)
|
||||
if rest.first == "-" { rest = rest.dropFirst() }
|
||||
guard rest.first == "P" else { return false }
|
||||
rest = rest.dropFirst()
|
||||
let timeSplit = rest.split(separator: "T", omittingEmptySubsequences: false)
|
||||
guard timeSplit.count <= 2 else { return false }
|
||||
let datePart = timeSplit[0]
|
||||
let timePart = timeSplit.count == 2 ? timeSplit[1] : nil
|
||||
let dateOK = matchDuration(datePart, designators: "YMD", allowFraction: false)
|
||||
guard let dateCount = dateOK else { return false }
|
||||
guard let timePart else { return dateCount > 0 }
|
||||
guard let timeCount = matchDuration(timePart, designators: "HMS", allowFraction: true), timeCount > 0 else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Matches a run of `<number><designator>` groups in the given designator
|
||||
/// order, returning the group count or nil on a malformed run.
|
||||
private static func matchDuration(_ value: Substring, designators: String, allowFraction: Bool) -> Int? {
|
||||
var rest = value
|
||||
var remaining = Array(designators)
|
||||
var count = 0
|
||||
while !rest.isEmpty {
|
||||
let digits = rest.prefix { $0.isNumber || (allowFraction && $0 == ".") }
|
||||
guard !digits.isEmpty, isUnsignedNumber(digits, allowFraction: allowFraction) else { return nil }
|
||||
rest = rest.dropFirst(digits.count)
|
||||
guard let designator = rest.first, let position = remaining.firstIndex(of: designator) else { return nil }
|
||||
remaining.removeFirst(position + 1)
|
||||
rest = rest.dropFirst()
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private static func isUnsignedNumber(_ value: Substring, allowFraction: Bool) -> Bool {
|
||||
guard allowFraction else { return value.allSatisfy(\.isNumber) }
|
||||
let parts = value.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count <= 2, parts[0].allSatisfy(\.isNumber) else { return false }
|
||||
return parts.count == 1 || parts[1].allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
static func isHexBinary(_ value: String) -> Bool {
|
||||
value.count.isMultiple(of: 2) && value.allSatisfy(\.isHexDigit)
|
||||
}
|
||||
|
||||
static func isBase64Binary(_ value: String) -> Bool {
|
||||
guard value.count.isMultiple(of: 4) else { return value.isEmpty }
|
||||
let alphabet = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
|
||||
let body = value.prefix { alphabet.contains($0) }
|
||||
let padding = value.dropFirst(body.count)
|
||||
return padding.allSatisfy { $0 == "=" } && padding.count <= 2
|
||||
}
|
||||
|
||||
static func isLanguage(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: "-", omittingEmptySubsequences: false)
|
||||
guard let first = parts.first, (1 ... 8).contains(first.count), first.allSatisfy(\.isLetter) else {
|
||||
return false
|
||||
}
|
||||
return parts.dropFirst().allSatisfy { (1 ... 8).contains($0.count) && $0.allSatisfy(\.isLetterOrDigit) }
|
||||
}
|
||||
|
||||
static func isNCName(_ value: String) -> Bool {
|
||||
!value.contains(":") && isName(value)
|
||||
}
|
||||
|
||||
static func isQName(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: ":", omittingEmptySubsequences: false)
|
||||
switch parts.count {
|
||||
case 1: return isName(value)
|
||||
case 2: return isNCName(String(parts[0])) && isNCName(String(parts[1]))
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
static func isName(_ value: String) -> Bool {
|
||||
PureXML.Parsing.XMLCharacter.isValidName(value)
|
||||
}
|
||||
|
||||
static func isNMToken(_ value: String) -> Bool {
|
||||
!value.isEmpty && value.unicodeScalars.allSatisfy(PureXML.Parsing.XMLCharacter.isNameChar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Character {
|
||||
var isLetterOrDigit: Bool {
|
||||
isLetter || isNumber
|
||||
}
|
||||
}
|
||||
106
Sources/Schema/Primitive.swift
Normal file
106
Sources/Schema/Primitive.swift
Normal file
@ -0,0 +1,106 @@
|
||||
extension PureXML.Schema {
|
||||
/// A value compared for the ordering facets (`minInclusive` and friends).
|
||||
enum OrderedValue: Equatable, Comparable {
|
||||
case decimal(DecimalValue)
|
||||
case double(Double)
|
||||
case dateTime(DateTimeValue)
|
||||
|
||||
static func < (lhs: OrderedValue, rhs: OrderedValue) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.decimal(left), .decimal(right)): left < right
|
||||
case let (.double(left), .double(right)): left < right
|
||||
case let (.dateTime(left), .dateTime(right)): left < right
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An XSD primitive value space: the lexical recognizer, and for the ordered
|
||||
/// spaces a value usable by the ordering facets.
|
||||
enum Primitive: Sendable {
|
||||
case string
|
||||
case boolean
|
||||
case decimal
|
||||
case integer
|
||||
case double
|
||||
case float
|
||||
case duration
|
||||
case dateKind(DateKind)
|
||||
case hexBinary
|
||||
case base64Binary
|
||||
case anyURI
|
||||
case qName
|
||||
case name
|
||||
case ncName
|
||||
case nmtoken
|
||||
case language
|
||||
|
||||
func isValid(_ value: String) -> Bool {
|
||||
stringValidity(value) ?? numericValidity(value)
|
||||
}
|
||||
|
||||
/// Validity for the string-like and boolean spaces; nil when this primitive
|
||||
/// is handled by ``numericValidity(_:)``.
|
||||
private func stringValidity(_ value: String) -> Bool? {
|
||||
switch self {
|
||||
case .string, .anyURI: true
|
||||
case .boolean: Lexical.isBoolean(value)
|
||||
case .qName: Lexical.isQName(value)
|
||||
case .name: Lexical.isName(value)
|
||||
case .ncName: Lexical.isNCName(value)
|
||||
case .nmtoken: Lexical.isNMToken(value)
|
||||
case .language: Lexical.isLanguage(value)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
private func numericValidity(_ value: String) -> Bool {
|
||||
switch self {
|
||||
case .decimal: DecimalValue(value, allowFraction: true) != nil
|
||||
case .integer: DecimalValue(value, allowFraction: false) != nil
|
||||
case .double, .float: Lexical.isFloating(value)
|
||||
case .duration: Lexical.isDuration(value)
|
||||
case let .dateKind(kind): DateTimeParser.parse(value, kind: kind) != nil
|
||||
case .hexBinary: Lexical.isHexBinary(value)
|
||||
case .base64Binary: Lexical.isBase64Binary(value)
|
||||
default: false
|
||||
}
|
||||
}
|
||||
|
||||
/// The ordering value for a lexically valid string, or nil for the
|
||||
/// unordered spaces.
|
||||
func ordered(_ value: String) -> OrderedValue? {
|
||||
switch self {
|
||||
case .decimal: DecimalValue(value, allowFraction: true).map(OrderedValue.decimal)
|
||||
case .integer: DecimalValue(value, allowFraction: false).map(OrderedValue.decimal)
|
||||
case .double, .float: Self.floatingValue(value).map(OrderedValue.double)
|
||||
case let .dateKind(kind): DateTimeParser.parse(value, kind: kind).map(OrderedValue.dateTime)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func floatingValue(_ value: String) -> Double? {
|
||||
switch value {
|
||||
case "INF": .infinity
|
||||
case "-INF": -.infinity
|
||||
case "NaN": .nan
|
||||
default: Double(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit `length`, `minLength`, and `maxLength` count: octets for binary
|
||||
/// types, characters otherwise.
|
||||
func measuredLength(_ value: String) -> Int {
|
||||
switch self {
|
||||
case .hexBinary: value.count / 2
|
||||
case .base64Binary: Self.base64Octets(value)
|
||||
default: value.count
|
||||
}
|
||||
}
|
||||
|
||||
private static func base64Octets(_ value: String) -> Int {
|
||||
let padding = value.reversed().prefix { $0 == "=" }.count
|
||||
return value.count / 4 * 3 - padding
|
||||
}
|
||||
}
|
||||
}
|
||||
166
Sources/Schema/SimpleType.swift
Normal file
166
Sources/Schema/SimpleType.swift
Normal file
@ -0,0 +1,166 @@
|
||||
public extension PureXML.Schema {
|
||||
/// The constraining facets of a simple type. Every facet is optional; an unset
|
||||
/// facet imposes no constraint. `patterns` are combined with AND.
|
||||
struct Facets: Sendable {
|
||||
public var length: Int?
|
||||
public var minLength: Int?
|
||||
public var maxLength: Int?
|
||||
public var patterns: [String]
|
||||
public var enumeration: [String]?
|
||||
public var whiteSpace: WhiteSpace?
|
||||
public var minInclusive: String?
|
||||
public var maxInclusive: String?
|
||||
public var minExclusive: String?
|
||||
public var maxExclusive: String?
|
||||
public var totalDigits: Int?
|
||||
public var fractionDigits: Int?
|
||||
|
||||
public init(
|
||||
length: Int? = nil,
|
||||
minLength: Int? = nil,
|
||||
maxLength: Int? = nil,
|
||||
patterns: [String] = [],
|
||||
enumeration: [String]? = nil,
|
||||
whiteSpace: WhiteSpace? = nil,
|
||||
minInclusive: String? = nil,
|
||||
maxInclusive: String? = nil,
|
||||
minExclusive: String? = nil,
|
||||
maxExclusive: String? = nil,
|
||||
totalDigits: Int? = nil,
|
||||
fractionDigits: Int? = nil,
|
||||
) {
|
||||
self.length = length
|
||||
self.minLength = minLength
|
||||
self.maxLength = maxLength
|
||||
self.patterns = patterns
|
||||
self.enumeration = enumeration
|
||||
self.whiteSpace = whiteSpace
|
||||
self.minInclusive = minInclusive
|
||||
self.maxInclusive = maxInclusive
|
||||
self.minExclusive = minExclusive
|
||||
self.maxExclusive = maxExclusive
|
||||
self.totalDigits = totalDigits
|
||||
self.fractionDigits = fractionDigits
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple type: a built-in base restricted by facets. Validates a lexical
|
||||
/// value against the XSD Part 2 rules: whitespace processing, the base value
|
||||
/// space, the base's intrinsic bounds, then the facets.
|
||||
struct SimpleType: Sendable {
|
||||
public var base: BuiltinType
|
||||
public var facets: Facets
|
||||
|
||||
public init(base: BuiltinType, facets: Facets = Facets()) {
|
||||
self.base = base
|
||||
self.facets = facets
|
||||
}
|
||||
|
||||
/// Whether `lexical` is a valid value of this type.
|
||||
public func isValid(_ lexical: String) -> Bool {
|
||||
validate(lexical) == nil
|
||||
}
|
||||
|
||||
/// A description of the first constraint `lexical` violates, or nil when it
|
||||
/// is valid.
|
||||
public func validate(_ lexical: String) -> String? {
|
||||
let value = Self.process(lexical, whiteSpace: facets.whiteSpace ?? base.whiteSpace)
|
||||
let primitive = base.primitive
|
||||
guard primitive.isValid(value) else {
|
||||
return "'\(lexical)' is not a valid \(base.rawValue)"
|
||||
}
|
||||
return boundsError(value, primitive)
|
||||
?? lengthError(value, primitive)
|
||||
?? digitsError(value)
|
||||
?? patternError(value)
|
||||
?? enumerationError(value, primitive)
|
||||
?? rangeError(value, primitive)
|
||||
}
|
||||
|
||||
// MARK: Whitespace
|
||||
|
||||
static func process(_ value: String, whiteSpace: WhiteSpace) -> String {
|
||||
switch whiteSpace {
|
||||
case .preserve:
|
||||
return value
|
||||
case .replace:
|
||||
return String(value.map { ($0 == "\t" || $0 == "\n" || $0 == "\r") ? " " : $0 })
|
||||
case .collapse:
|
||||
let replaced = value.map { ($0 == "\t" || $0 == "\n" || $0 == "\r") ? " " : $0 }
|
||||
return String(replaced).split(separator: " ").joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Facet checks
|
||||
|
||||
private func boundsError(_ value: String, _: Primitive) -> String? {
|
||||
let (lower, upper) = base.bounds
|
||||
guard lower != nil || upper != nil, let decimal = DecimalValue(value, allowFraction: false) else {
|
||||
return nil
|
||||
}
|
||||
if let lower, decimal < lower { return "\(value) is below the minimum for \(base.rawValue)" }
|
||||
if let upper, decimal > upper { return "\(value) is above the maximum for \(base.rawValue)" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func lengthError(_ value: String, _ primitive: Primitive) -> String? {
|
||||
let length = primitive.measuredLength(value)
|
||||
if let exact = facets.length, length != exact { return "length \(length) is not \(exact)" }
|
||||
if let minimum = facets.minLength, length < minimum { return "length \(length) is below \(minimum)" }
|
||||
if let maximum = facets.maxLength, length > maximum { return "length \(length) is above \(maximum)" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func digitsError(_ value: String) -> String? {
|
||||
guard facets.totalDigits != nil || facets.fractionDigits != nil,
|
||||
let decimal = DecimalValue(value, allowFraction: true) else { return nil }
|
||||
if let total = facets.totalDigits, decimal.totalDigits > total { return "more than \(total) total digits" }
|
||||
if let fraction = facets.fractionDigits, decimal.fractionDigits.count > fraction {
|
||||
return "more than \(fraction) fraction digits"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func patternError(_ value: String) -> String? {
|
||||
for pattern in facets.patterns {
|
||||
guard let regex = try? PureXML.Regex.Pattern(pattern), regex.matches(value) else {
|
||||
return "'\(value)' does not match pattern '\(pattern)'"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func enumerationError(_ value: String, _ primitive: Primitive) -> String? {
|
||||
guard let enumeration = facets.enumeration else { return nil }
|
||||
let whiteSpace = facets.whiteSpace ?? base.whiteSpace
|
||||
let matched = enumeration.contains { candidate in
|
||||
let normalized = Self.process(candidate, whiteSpace: whiteSpace)
|
||||
if let lhs = primitive.ordered(value), let rhs = primitive.ordered(normalized) { return lhs == rhs }
|
||||
return normalized == value
|
||||
}
|
||||
return matched ? nil : "'\(value)' is not in the enumeration"
|
||||
}
|
||||
|
||||
private func rangeError(_ value: String, _ primitive: Primitive) -> String? {
|
||||
guard let actual = primitive.ordered(value) else { return nil }
|
||||
if let bound = facets.minInclusive, let limit = primitive.ordered(bound), actual < limit {
|
||||
return "\(value) is below the inclusive minimum"
|
||||
}
|
||||
if let bound = facets.maxInclusive, let limit = primitive.ordered(bound), actual > limit {
|
||||
return "\(value) is above the inclusive maximum"
|
||||
}
|
||||
if let bound = facets.minExclusive, let limit = primitive.ordered(bound), !(limit < actual) {
|
||||
return "\(value) is not above the exclusive minimum"
|
||||
}
|
||||
if let bound = facets.maxExclusive, let limit = primitive.ordered(bound), !(actual < limit) {
|
||||
return "\(value) is not below the exclusive maximum"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `value` against a built-in type with no extra facets.
|
||||
static func isValid(_ value: String, type: BuiltinType) -> Bool {
|
||||
SimpleType(base: type).isValid(value)
|
||||
}
|
||||
}
|
||||
124
Tests/XSDDatatypeTests.swift
Normal file
124
Tests/XSDDatatypeTests.swift
Normal file
@ -0,0 +1,124 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XSD datatypes")
|
||||
struct XSDDatatypeTests {
|
||||
private typealias Schema = PureXML.Schema
|
||||
|
||||
private func valid(_ value: String, _ type: Schema.BuiltinType) -> Bool {
|
||||
Schema.isValid(value, type: type)
|
||||
}
|
||||
|
||||
// MARK: Primitives
|
||||
|
||||
@Test("boolean accepts the four lexical forms")
|
||||
func test_boolean() {
|
||||
#expect(valid("true", .boolean) && valid("false", .boolean))
|
||||
#expect(valid("0", .boolean) && valid("1", .boolean))
|
||||
#expect(!valid("True", .boolean) && !valid("yes", .boolean))
|
||||
}
|
||||
|
||||
@Test("decimal and integer lexical spaces")
|
||||
func test_decimalInteger() {
|
||||
#expect(valid("-12.345", .decimal) && valid("+.5", .decimal) && valid("100", .decimal))
|
||||
#expect(!valid("1.2.3", .decimal) && !valid("1e3", .decimal))
|
||||
#expect(valid("-42", .integer) && !valid("4.0", .integer))
|
||||
}
|
||||
|
||||
@Test("double accepts exponents and the special values")
|
||||
func test_double() {
|
||||
#expect(valid("1.5e-9", .double) && valid("INF", .double) && valid("-INF", .double) && valid("NaN", .double))
|
||||
#expect(!valid("1.5e", .double) && !valid("inf", .double))
|
||||
}
|
||||
|
||||
@Test("bounded integer types enforce their ranges")
|
||||
func test_integerBounds() {
|
||||
#expect(valid("127", .byte) && !valid("128", .byte))
|
||||
#expect(valid("-128", .byte) && !valid("-129", .byte))
|
||||
#expect(valid("255", .unsignedByte) && !valid("-1", .unsignedByte))
|
||||
#expect(valid("1", .positiveInteger) && !valid("0", .positiveInteger))
|
||||
#expect(valid("0", .nonNegativeInteger) && !valid("-1", .nonNegativeInteger))
|
||||
}
|
||||
|
||||
// MARK: Dates
|
||||
|
||||
@Test("date validates field ranges and leap years")
|
||||
func test_date() {
|
||||
#expect(valid("2024-02-29", .date) && !valid("2023-02-29", .date))
|
||||
#expect(!valid("2024-02-30", .date) && !valid("2024-13-01", .date))
|
||||
#expect(valid("-0044-03-15", .date))
|
||||
}
|
||||
|
||||
@Test("dateTime accepts 24:00:00 but not 24:00:01, and timezones")
|
||||
func test_dateTime() {
|
||||
#expect(valid("2026-06-08T24:00:00", .dateTime))
|
||||
#expect(!valid("2026-06-08T24:00:01", .dateTime))
|
||||
#expect(valid("2026-06-08T12:30:00Z", .dateTime) && valid("2026-06-08T12:30:00+05:30", .dateTime))
|
||||
#expect(!valid("2026-06-08T12:30:00+15:00", .dateTime))
|
||||
}
|
||||
|
||||
@Test("the g* date types parse their reduced forms")
|
||||
func test_gTypes() {
|
||||
#expect(valid("--02-29", .gMonthDay) && !valid("--02-30", .gMonthDay))
|
||||
#expect(valid("---31", .gDay) && valid("--12", .gMonth) && valid("2026", .gYear))
|
||||
}
|
||||
|
||||
// MARK: Other primitives
|
||||
|
||||
@Test("hexBinary, base64Binary, and names")
|
||||
func test_binaryAndNames() {
|
||||
#expect(valid("0fB7", .hexBinary) && !valid("0fB", .hexBinary))
|
||||
#expect(valid("YW55", .base64Binary) && valid("YW4=", .base64Binary))
|
||||
#expect(valid("ns:local", .qName) && !valid("ns:1bad", .qName))
|
||||
#expect(valid("Hello", .ncName) && !valid("a:b", .ncName))
|
||||
#expect(valid("en-US", .language) && !valid("english-", .language))
|
||||
#expect(valid("P1Y2M3DT4H", .duration) && !valid("P", .duration) && !valid("PT", .duration))
|
||||
}
|
||||
|
||||
// MARK: Facets
|
||||
|
||||
@Test("whiteSpace collapse is applied before validation")
|
||||
func test_whiteSpace() {
|
||||
#expect(Schema.SimpleType(base: .token).isValid(" a b "))
|
||||
#expect(Schema.SimpleType(base: .int).isValid(" 42 "))
|
||||
}
|
||||
|
||||
@Test("length, minLength, and maxLength count characters")
|
||||
func test_lengthFacets() {
|
||||
let type = Schema.SimpleType(base: .string, facets: .init(minLength: 2, maxLength: 4))
|
||||
#expect(type.isValid("abc") && !type.isValid("a") && !type.isValid("abcde"))
|
||||
}
|
||||
|
||||
@Test("pattern facets use the regex engine")
|
||||
func test_patternFacet() {
|
||||
let type = Schema.SimpleType(base: .string, facets: .init(patterns: ["[A-Z]{3}-\\d{2}"]))
|
||||
#expect(type.isValid("XYZ-42") && !type.isValid("xyz-42"))
|
||||
}
|
||||
|
||||
@Test("enumeration compares in value space")
|
||||
func test_enumerationFacet() {
|
||||
let type = Schema.SimpleType(base: .decimal, facets: .init(enumeration: ["1.0", "2.5"]))
|
||||
#expect(type.isValid("1.00") && type.isValid("2.5") && !type.isValid("3"))
|
||||
}
|
||||
|
||||
@Test("inclusive and exclusive ranges")
|
||||
func test_rangeFacets() {
|
||||
let inclusive = Schema.SimpleType(base: .integer, facets: .init(minInclusive: "1", maxInclusive: "10"))
|
||||
#expect(inclusive.isValid("1") && inclusive.isValid("10") && !inclusive.isValid("11"))
|
||||
let exclusive = Schema.SimpleType(base: .integer, facets: .init(minExclusive: "1", maxExclusive: "10"))
|
||||
#expect(!exclusive.isValid("1") && exclusive.isValid("2") && !exclusive.isValid("10"))
|
||||
}
|
||||
|
||||
@Test("totalDigits and fractionDigits")
|
||||
func test_digitFacets() {
|
||||
let type = Schema.SimpleType(base: .decimal, facets: .init(totalDigits: 4, fractionDigits: 2))
|
||||
#expect(type.isValid("12.34") && type.isValid("12") && !type.isValid("123.45") && !type.isValid("1.234"))
|
||||
}
|
||||
|
||||
@Test("exact decimal comparison avoids floating-point error")
|
||||
func test_exactDecimal() {
|
||||
// 0.1 + 0.2 != 0.3 in binary; exact comparison handles boundary values.
|
||||
let type = Schema.SimpleType(base: .decimal, facets: .init(maxInclusive: "0.3"))
|
||||
#expect(type.isValid("0.3") && type.isValid("0.30") && !type.isValid("0.30000000001"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user