diff --git a/CHANGELOG.md b/CHANGELOG.md index 153382a..4e220ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,4 +25,5 @@ - Implement `xsd:unsignedLong` data type codec - Implement `xsd:gDay` codec and data type - Implement `xsd:gMonthDay` codec and data type -- Implement `xsd:duration` codec and data type \ No newline at end of file +- Implement `xsd:duration` codec and data type +- Implement `xsd:datetime` codec and data type diff --git a/README.md b/README.md index debb153..57e17e5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ This library aims to support the following XSD 1.1 built-in datatypes that are c | `xsd:duration` | ✅ | ✅ | `XsdDuration`[1] | `package:xsd` | | `xsd:yearMonthDuration` | ✅ | ❌ | ??? | ??? | | `xsd:dayTimeDuration` | ✅ | ❌ | ??? | ??? | -| `xsd:dateTime` | ✅ | ❌ | ??? | ??? | +| `xsd:dateTime` | ✅ | ✅ | `XsdDateTime`[2] | `package:xsd` | | `xsd:dateTimeStamp` | ✅ | ❌ | ??? | ??? | | `xsd:date` | ✅ | ❌ | ??? | ??? | | `xsd:time` | ✅ | ❌ | ??? | ??? | @@ -70,7 +70,8 @@ This library aims to support the following XSD 1.1 built-in datatypes that are c | `xsd:ENTITIES` | ❌ | ❌ | ❌ | ❌ | | `xsd:NMTOKENS` | ❌ | ❌ | ❌ | ❌ | -[1] **Note:** Although Dart already has a `Duration` class it actually represents an entirely different concept to the ISO8600 / XSD idea of a duration which `XsdDuration` implements. +[1] **Note:** Although Dart already has a `Duration` class it actually represents an entirely different concept to the ISO8600 / XSD idea of a duration which `XsdDuration` implements. +[2] **Note:** Dart's native `DateTime` class doesn't preserve the original timezone offset from a parsed string, which is important for round-tripping `xsd:dateTime` values. This wrapper class stores the original offset to address this. ## Limitations diff --git a/lib/src/codecs/codecs.dart b/lib/src/codecs/codecs.dart index cc4a192..33d8a37 100644 --- a/lib/src/codecs/codecs.dart +++ b/lib/src/codecs/codecs.dart @@ -23,3 +23,4 @@ export 'unsigned_long/unsigned_long_codec.dart'; export 'g_day/g_day_codec.dart'; export 'g_month_day/gregorian_month_day_codec.dart'; export 'duration/xsd_duration_codec.dart'; +export 'datetime/xsd_datetime_codec.dart'; diff --git a/lib/src/codecs/datetime/xsd_datetime_codec.dart b/lib/src/codecs/datetime/xsd_datetime_codec.dart new file mode 100644 index 0000000..a0341e3 --- /dev/null +++ b/lib/src/codecs/datetime/xsd_datetime_codec.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import '../../types/xsd_datetime.dart'; + +/// A [Codec] that converts between [XsdDateTime] objects and their XSD string representations. +class XsdDateTimeCodec extends Codec { + const XsdDateTimeCodec(); + + @override + Converter get encoder => const XsdDateTimeEncoder(); + + @override + Converter get decoder => const XsdDateTimeDecoder(); +} + +/// Encoder for [XsdDateTimeCodec]. +class XsdDateTimeEncoder extends Converter { + const XsdDateTimeEncoder(); + + @override + String convert(XsdDateTime input) { + return input.toString(); + } +} + +/// Decoder for [XsdDateTimeCodec]. +class XsdDateTimeDecoder extends Converter { + const XsdDateTimeDecoder(); + + @override + XsdDateTime convert(String input) { + return XsdDateTime.parse(input); + } +} diff --git a/lib/src/types/types.dart b/lib/src/types/types.dart index 5df6444..2356613 100644 --- a/lib/src/types/types.dart +++ b/lib/src/types/types.dart @@ -4,3 +4,4 @@ export 'gregorian_month.dart'; export 'gregorian_day.dart'; export 'gregorian_month_day.dart'; export 'xsd_duration.dart'; +export 'xsd_datetime.dart'; diff --git a/lib/src/types/xsd_datetime.dart b/lib/src/types/xsd_datetime.dart new file mode 100644 index 0000000..5cb8962 --- /dev/null +++ b/lib/src/types/xsd_datetime.dart @@ -0,0 +1,151 @@ +import 'package:meta/meta.dart'; + +/// A wrapper around [DateTime] that provides XSD-compliant handling of +/// `xsd:dateTime` values, specifically regarding "floating" (timezone-less) +/// values. +/// +/// In XSD, a `dateTime` without a timezone is considered "floating" and is +/// partially ordered (incomparable) with zoned times. However, for pragmatic +/// usage in Dart, this class treats floating times as if they were in UTC +/// for comparison purposes (`compareTo`). +/// +/// This class preserves the original timezone offset if one was present in the +/// input string, allowing for round-tripping. +@immutable +class XsdDateTime implements Comparable { + /// The underlying [DateTime] value, always in UTC. + final DateTime value; + + /// Whether the original input had no timezone (floating). + final bool isFloating; + + /// The original timezone offset, if one was present. + /// + /// If [isFloating] is true, this will be null. + final Duration? originalOffset; + + XsdDateTime(DateTime value, {this.isFloating = false, this.originalOffset}) + : value = value.toUtc(); + + // Regex to detect timezone presence. + // Matches Z or +/-HH:MM at the end. + static final _timezoneRegex = RegExp(r'(Z|[+-]\d{2}:\d{2})$'); + + // Format is +/-HH:MM + static final _offsetRegex = RegExp(r'([+-])(\d{2}):(\d{2})$'); + + /// Parses an XSD dateTime string. + /// + /// Throws a [FormatException] if the [input] is not a valid representation. + /// + /// Handles formats like: + /// - `2002-10-10T12:00:00` (Floating) + /// - `2002-10-10T12:00:00Z` (UTC) + /// - `2002-10-10T12:00:00-05:00` (Offset) + static XsdDateTime parse(String input) { + final hasTimezone = _timezoneRegex.hasMatch(input); + + if (!hasTimezone) { + // Floating time. Parse as if it were UTC to avoid local timezone interference. + // We append 'Z' to force DateTime.parse to treat it as UTC, then strip it conceptually. + // Actually, DateTime.parse handles '2002-10-10T12:00:00' as local. + // To ensure we store the exact values provided without local conversion, + // we can append 'Z' to the input string before parsing. + final utcInput = '${input}Z'; + final dt = DateTime.parse(utcInput); + return XsdDateTime(dt, isFloating: true); + } else { + final dt = DateTime.parse(input); + Duration? offset; + + if (input.endsWith('Z')) { + offset = Duration.zero; + } else { + // Extract offset manually because DateTime doesn't expose the original offset + // if it converts to UTC/Local. + final match = _offsetRegex.firstMatch(input); + if (match != null) { + final sign = match.group(1) == '+' ? 1 : -1; + final hours = int.parse(match.group(2)!); + final minutes = int.parse(match.group(3)!); + offset = Duration(hours: hours, minutes: minutes) * sign; + } + } + + return XsdDateTime(dt, isFloating: false, originalOffset: offset); + } + } + + /// Formats the [DateTime] into a string, omitting fractional seconds if they are zero. + String _formatDateTime(DateTime dt) { + // Manually build the string to control fractional seconds. + final y = dt.year.toString().padLeft(4, '0'); + final m = dt.month.toString().padLeft(2, '0'); + final d = dt.day.toString().padLeft(2, '0'); + final h = dt.hour.toString().padLeft(2, '0'); + final min = dt.minute.toString().padLeft(2, '0'); + final s = dt.second.toString().padLeft(2, '0'); + + var result = '$y-$m-${d}T$h:$min:$s'; + + // Add fractional seconds only if they are not zero. + if (dt.millisecond > 0 || dt.microsecond > 0) { + final ms = dt.millisecond.toString().padLeft(3, '0'); + final us = (dt.microsecond > 0) + ? dt.microsecond.toString().padLeft(3, '0') + : ''; + result = '$result.$ms$us'; + } + + return result; + } + + /// Returns the ISO 8601 string representation. + /// + /// If [isFloating] is true, the result will have no timezone. + /// If [originalOffset] is present, the result will use that offset. + /// Otherwise, it returns the UTC representation ('Z'). + @override + String toString() { + if (isFloating) { + return _formatDateTime(value); + } + + if (originalOffset != null && originalOffset != Duration.zero) { + final localTime = value.add(originalOffset!); + final bareIso = _formatDateTime(localTime); + + final sign = originalOffset!.isNegative ? '-' : '+'; + final absOffset = originalOffset!.abs(); + final hours = absOffset.inHours.toString().padLeft(2, '0'); + final minutes = (absOffset.inMinutes % 60).toString().padLeft(2, '0'); + + return '$bareIso$sign$hours:$minutes'; + } + + return '${_formatDateTime(value)}Z'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is XsdDateTime && + other.value == value && + other.isFloating == isFloating && + other.originalOffset == originalOffset; + } + + @override + int get hashCode => Object.hash(value, isFloating, originalOffset); + + /// Compares this [XsdDateTime] to [other]. + /// + /// **Note**: This comparison treats "floating" (timezone-less) values as if + /// they were in UTC. This deviates from the strict XSD specification which + /// defines a partial ordering for floating vs zoned times. This choice was + /// made to provide a consistent total ordering for Dart collections. + @override + int compareTo(XsdDateTime other) { + return value.compareTo(other.value); + } +} diff --git a/test/types/xsd_datetime_test.dart b/test/types/xsd_datetime_test.dart new file mode 100644 index 0000000..2e7949b --- /dev/null +++ b/test/types/xsd_datetime_test.dart @@ -0,0 +1,81 @@ +import 'package:test/test.dart'; +import 'package:xsd/src/types/xsd_datetime.dart'; + +void main() { + group('XsdDateTime', () { + test('parses floating time', () { + final dt = XsdDateTime.parse('2002-10-10T12:00:00'); + expect(dt.isFloating, isTrue); + expect(dt.value.isUtc, isTrue); + expect(dt.value.year, 2002); + expect(dt.value.month, 10); + expect(dt.value.day, 10); + expect(dt.value.hour, 12); + expect(dt.value.minute, 0); + expect(dt.value.second, 0); + expect(dt.originalOffset, isNull); + expect(dt.toString(), '2002-10-10T12:00:00'); + }); + + test('parses UTC time', () { + final dt = XsdDateTime.parse('2002-10-10T12:00:00Z'); + expect(dt.isFloating, isFalse); + expect(dt.value.isUtc, isTrue); + expect(dt.originalOffset, Duration.zero); + expect(dt.toString(), '2002-10-10T12:00:00Z'); + }); + + test('parses offset time', () { + final dt = XsdDateTime.parse('2002-10-10T12:00:00-05:00'); + expect(dt.isFloating, isFalse); + expect(dt.value.isUtc, isTrue); + // 12:00 -05:00 is 17:00 UTC + expect(dt.value.hour, 17); + expect(dt.originalOffset, const Duration(hours: -5)); + expect(dt.toString(), '2002-10-10T12:00:00-05:00'); + }); + + test('parses positive offset time', () { + final dt = XsdDateTime.parse('2002-10-10T12:00:00+05:30'); + expect(dt.isFloating, isFalse); + expect(dt.value.isUtc, isTrue); + // 12:00 +05:30 is 06:30 UTC + expect(dt.value.hour, 6); + expect(dt.value.minute, 30); + expect(dt.originalOffset, const Duration(hours: 5, minutes: 30)); + expect(dt.toString(), '2002-10-10T12:00:00+05:30'); + }); + + test('correctly handles fractional seconds', () { + final dt = XsdDateTime.parse('2002-10-10T12:00:00.450-05:00'); + expect(dt.isFloating, isFalse); + expect(dt.value.isUtc, isTrue); + expect(dt.originalOffset, const Duration(hours: -5)); + expect(dt.toString(), '2002-10-10T12:00:00.450-05:00'); + }); + + test('throws a FormatException on invalid input', () { + expect(() => XsdDateTime.parse('2002-10-10-'), throwsFormatException); + }); + + test('equality', () { + final dt1 = XsdDateTime.parse('2002-10-10T12:00:00'); + final dt2 = XsdDateTime.parse('2002-10-10T12:00:00'); + final dt3 = XsdDateTime.parse('2002-10-10T12:00:00Z'); + + expect(dt1, equals(dt2)); + expect(dt1.hashCode, equals(dt2.hashCode)); + expect(dt1, isNot(equals(dt3))); // Floating != Zoned + expect(dt1.hashCode, isNot(equals(dt3.hashCode))); + }); + + test('compareTo', () { + final dt1 = XsdDateTime.parse( + '2002-10-10T12:00:00', + ); // Floating (treated as UTC 12:00) + final dt2 = XsdDateTime.parse('2002-10-10T13:00:00Z'); // UTC 13:00 + + expect(dt1.compareTo(dt2), lessThan(0)); + }); + }); +}