Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Implement `xsd:duration` codec and data type
- Implement `xsd:datetime` codec and data type
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ✅ | ❌ | ??? | ??? |
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/src/codecs/codecs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
34 changes: 34 additions & 0 deletions lib/src/codecs/datetime/xsd_datetime_codec.dart
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);
}
}
1 change: 1 addition & 0 deletions lib/src/types/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
151 changes: 151 additions & 0 deletions lib/src/types/xsd_datetime.dart
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')
: '';
Comment on lines +94 to +96

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The modulo operation % 1000 is redundant here. The DateTime.microsecond property is already documented to be in the range of 0 to 999. You can safely remove the modulo for cleaner code.

Suggested change
final us = (dt.microsecond > 0)
? (dt.microsecond % 1000).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);
}
}
81 changes: 81 additions & 0 deletions test/types/xsd_datetime_test.dart
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));
});
});
}
Comment thread
mark-dropbear marked this conversation as resolved.