Repository navigation
feat: add xsd:datetime codec and data type #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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
This file contains hidden or 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
This file contains hidden or 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
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<XsdDateTime, String> { | ||
| const XsdDateTimeCodec(); | ||
|
|
||
| @override | ||
| Converter<XsdDateTime, String> get encoder => const XsdDateTimeEncoder(); | ||
|
|
||
| @override | ||
| Converter<String, XsdDateTime> get decoder => const XsdDateTimeDecoder(); | ||
| } | ||
|
|
||
| /// Encoder for [XsdDateTimeCodec]. | ||
| class XsdDateTimeEncoder extends Converter<XsdDateTime, String> { | ||
| const XsdDateTimeEncoder(); | ||
|
|
||
| @override | ||
| String convert(XsdDateTime input) { | ||
| return input.toString(); | ||
| } | ||
| } | ||
|
|
||
| /// Decoder for [XsdDateTimeCodec]. | ||
| class XsdDateTimeDecoder extends Converter<String, XsdDateTime> { | ||
| const XsdDateTimeDecoder(); | ||
|
|
||
| @override | ||
| XsdDateTime convert(String input) { | ||
| return XsdDateTime.parse(input); | ||
| } | ||
| } |
This file contains hidden or 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
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<XsdDateTime> { | ||
| /// 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); | ||
| } | ||
| } | ||
This file contains hidden or 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| }); | ||
| }); | ||
| } | ||
|
mark-dropbear marked this conversation as resolved.
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The modulo operation
% 1000is redundant here. TheDateTime.microsecondproperty is already documented to be in the range of 0 to 999. You can safely remove the modulo for cleaner code.