From e0157d8b58857b2b070734b5ba36485061bc1340 Mon Sep 17 00:00:00 2001 From: mark Date: Thu, 27 Nov 2025 18:49:27 +0000 Subject: [PATCH] feat: Add `XsdDate` type and codec for XSD-compliant date handling. --- CHANGELOG.md | 1 + README.md | 2 +- lib/src/codecs/codecs.dart | 1 + lib/src/codecs/date/xsd_date_codec.dart | 41 ++++++++ lib/src/types/types.dart | 1 + lib/src/types/xsd_date.dart | 131 ++++++++++++++++++++++++ test/codecs/xsd_date_codec_test.dart | 59 +++++++++++ test/types/xsd_date_test.dart | 80 +++++++++++++++ 8 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 lib/src/codecs/date/xsd_date_codec.dart create mode 100644 lib/src/types/xsd_date.dart create mode 100644 test/codecs/xsd_date_codec_test.dart create mode 100644 test/types/xsd_date_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c114f..f5df9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,3 +34,4 @@ - Implement `xsd:unsignedInt` codec - Implement `xsd:integer` codec - Implement `xsd:negativeInteger` codec +- Implement `xsd:date` codec and data type diff --git a/README.md b/README.md index 9c017b4..bb69e05 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This library aims to support the following XSD 1.1 built-in datatypes that are c | `xsd:dayTimeDuration` | ✅ | ❌ | ??? | ??? | | `xsd:dateTime` | ✅ | ✅ | `XsdDateTime`[2] | `package:xsd` | | `xsd:dateTimeStamp` | ✅ | ❌ | ??? | ??? | -| `xsd:date` | ✅ | ❌ | ??? | ??? | +| `xsd:date` | ✅ | ✅ | `XsdDate` | `package:xsd` | | `xsd:time` | ✅ | ❌ | ??? | ??? | | `xsd:gYearMonth` | ✅ | ✅ | `YearMonth` | `package:xsd` | | `xsd:gYear` | ✅ | ✅ | `GregorianYear` | `package:xsd` | diff --git a/lib/src/codecs/codecs.dart b/lib/src/codecs/codecs.dart index 65d893f..56639b9 100644 --- a/lib/src/codecs/codecs.dart +++ b/lib/src/codecs/codecs.dart @@ -31,3 +31,4 @@ export 'float/float_codec.dart'; export 'integer/integer_codec.dart'; export 'unsigned_int/unsigned_int_codec.dart'; export 'negative_integer/negative_integer_codec.dart'; +export 'date/xsd_date_codec.dart'; diff --git a/lib/src/codecs/date/xsd_date_codec.dart b/lib/src/codecs/date/xsd_date_codec.dart new file mode 100644 index 0000000..6dfcbf4 --- /dev/null +++ b/lib/src/codecs/date/xsd_date_codec.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import '../../helpers/whitespace.dart'; +import '../../types/xsd_date.dart'; + +/// A [Codec] that converts between [XsdDate] objects and their XSD string representations. +class XsdDateCodec extends Codec { + const XsdDateCodec(); + + @override + Converter get encoder => const XsdDateEncoder(); + + @override + Converter get decoder => const XsdDateDecoder(); +} + +/// Encoder for [XsdDateCodec]. +class XsdDateEncoder extends Converter { + const XsdDateEncoder(); + + @override + String convert(XsdDate input) { + return input.toString(); + } +} + +/// Decoder for [XsdDateCodec]. +class XsdDateDecoder extends Converter { + const XsdDateDecoder(); + + @override + XsdDate convert(String input) { + final trimmed = processWhiteSpace(input, Whitespace.collapse); + + if (trimmed.isEmpty) { + throw const FormatException('The input string cannot be empty.'); + } + + return XsdDate.parse(trimmed); + } +} diff --git a/lib/src/types/types.dart b/lib/src/types/types.dart index 2356613..4e5568d 100644 --- a/lib/src/types/types.dart +++ b/lib/src/types/types.dart @@ -5,3 +5,4 @@ export 'gregorian_day.dart'; export 'gregorian_month_day.dart'; export 'xsd_duration.dart'; export 'xsd_datetime.dart'; +export 'xsd_date.dart'; diff --git a/lib/src/types/xsd_date.dart b/lib/src/types/xsd_date.dart new file mode 100644 index 0000000..0f8b860 --- /dev/null +++ b/lib/src/types/xsd_date.dart @@ -0,0 +1,131 @@ +import 'package:meta/meta.dart'; + +/// A wrapper around [DateTime] that provides XSD-compliant handling of +/// `xsd:date` values. +/// +/// `xsd:date` represents a calendar date (YYYY-MM-DD). Like `xsd:dateTime`, +/// it can be "floating" (timezone-less) or zoned. +/// +/// This class ensures that the time components of the underlying [DateTime] +/// are always zero (00:00:00.000). +@immutable +class XsdDate implements Comparable { + /// The underlying [DateTime] value, always in UTC, with time components set to zero. + 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; + + XsdDate(DateTime value, {this.isFloating = false, this.originalOffset}) + : value = DateTime.utc(value.year, value.month, value.day); + + // Regex for xsd:date + // -?([1-9][0-9]{3,}|0[0-9]{3})-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))? + // Simplified for capture groups: + // Group 1: Year + // Group 2: Month + // Group 3: Day + // Group 4: Timezone (optional) + static final _dateRegex = RegExp( + r'^(-?\d{4,})-(\d{2})-(\d{2})(Z|[+-]\d{2}:\d{2})?$', + ); + + static final _offsetRegex = RegExp(r'([+-])(\d{2}):(\d{2})$'); + + /// Parses an XSD date string. + /// + /// Throws a [FormatException] if the [input] is not a valid representation. + static XsdDate parse(String input) { + final match = _dateRegex.firstMatch(input); + if (match == null) { + throw FormatException('Invalid xsd:date format', input); + } + + final yearStr = match.group(1)!; + final monthStr = match.group(2)!; + final dayStr = match.group(3)!; + final tzStr = match.group(4); + + final year = int.parse(yearStr); + final month = int.parse(monthStr); + final day = int.parse(dayStr); + + // Validate day/month (basic check, DateTime handles leap years etc usually, but we might want strict XSD checks?) + // XSD requires strict validation. DateTime.utc will wrap invalid days (e.g. April 31 -> May 1). + // We should check if the created DateTime matches the input components. + final dt = DateTime.utc(year, month, day); + + if (dt.month != month || dt.day != day) { + throw FormatException('Invalid date components', input); + } + + if (tzStr == null) { + return XsdDate(dt, isFloating: true); + } else { + Duration? offset; + if (tzStr == 'Z') { + offset = Duration.zero; + } else { + final tzMatch = _offsetRegex.firstMatch(tzStr); + if (tzMatch != null) { + final sign = tzMatch.group(1) == '+' ? 1 : -1; + final hours = int.parse(tzMatch.group(2)!); + final minutes = int.parse(tzMatch.group(3)!); + offset = Duration(hours: hours, minutes: minutes) * sign; + } + } + return XsdDate(dt, isFloating: false, originalOffset: offset); + } + } + + /// Returns the ISO 8601 string representation (YYYY-MM-DD). + @override + String toString() { + final y = value.year.toString().padLeft(4, '0'); + final m = value.month.toString().padLeft(2, '0'); + final d = value.day.toString().padLeft(2, '0'); + final datePart = '$y-$m-$d'; + + if (isFloating) { + return datePart; + } + + if (originalOffset != null && originalOffset != Duration.zero) { + 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 '$datePart$sign$hours:$minutes'; + } + + return '${datePart}Z'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is XsdDate && + other.value == value && + other.isFloating == isFloating && + other.originalOffset == originalOffset; + } + + @override + int get hashCode => Object.hash(value, isFloating, originalOffset); + + /// Compares this [XsdDate] 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(XsdDate other) { + return value.compareTo(other.value); + } +} diff --git a/test/codecs/xsd_date_codec_test.dart b/test/codecs/xsd_date_codec_test.dart new file mode 100644 index 0000000..0459c74 --- /dev/null +++ b/test/codecs/xsd_date_codec_test.dart @@ -0,0 +1,59 @@ +import 'package:test/test.dart'; +import 'package:xsd/src/codecs/date/xsd_date_codec.dart'; +import 'package:xsd/src/types/xsd_date.dart'; + +void main() { + group('XsdDateCodec', () { + const codec = XsdDateCodec(); + + test('decodes string to XsdDate', () { + final d = codec.decode('2002-10-10'); + expect(d.isFloating, isTrue); + expect(d.value.year, 2002); + expect(d.value.month, 10); + expect(d.value.day, 10); + }); + + test('correctly handles whitespace', () { + final d = codec.decode(' 2002-10-10 '); + expect(d.isFloating, isTrue); + expect(d.value.year, 2002); + expect(d.value.month, 10); + expect(d.value.day, 10); + }); + + test('correctly handles tabs and newline characters', () { + final d = codec.decode('\n\t2002-10-10'); + expect(d.isFloating, isTrue); + expect(d.value.year, 2002); + expect(d.value.month, 10); + expect(d.value.day, 10); + }); + + test('encodes XsdDate to string', () { + final d = XsdDate.parse('2002-10-10'); + expect(codec.encode(d), '2002-10-10'); + }); + + test('round-trip floating', () { + const input = '2002-10-10'; + final decoded = codec.decode(input); + final encoded = codec.encode(decoded); + expect(encoded, input); + }); + + test('round-trip UTC', () { + const input = '2002-10-10Z'; + final decoded = codec.decode(input); + final encoded = codec.encode(decoded); + expect(encoded, input); + }); + + test('round-trip offset', () { + const input = '2002-10-10-05:00'; + final decoded = codec.decode(input); + final encoded = codec.encode(decoded); + expect(encoded, input); + }); + }); +} diff --git a/test/types/xsd_date_test.dart b/test/types/xsd_date_test.dart new file mode 100644 index 0000000..48eedb0 --- /dev/null +++ b/test/types/xsd_date_test.dart @@ -0,0 +1,80 @@ +import 'package:test/test.dart'; +import 'package:xsd/src/types/xsd_date.dart'; + +void main() { + group('XsdDate', () { + test('parses floating date', () { + final d = XsdDate.parse('2002-10-10'); + expect(d.isFloating, isTrue); + expect(d.value.isUtc, isTrue); + expect(d.value.year, 2002); + expect(d.value.month, 10); + expect(d.value.day, 10); + expect(d.value.hour, 0); + expect(d.value.minute, 0); + expect(d.value.second, 0); + expect(d.originalOffset, isNull); + expect(d.toString(), '2002-10-10'); + }); + + test('parses UTC date', () { + final d = XsdDate.parse('2002-10-10Z'); + expect(d.isFloating, isFalse); + expect(d.value.isUtc, isTrue); + expect(d.originalOffset, Duration.zero); + expect(d.toString(), '2002-10-10Z'); + }); + + test('parses offset date', () { + final d = XsdDate.parse('2002-10-10-05:00'); + expect(d.isFloating, isFalse); + expect(d.value.isUtc, isTrue); + expect(d.originalOffset, const Duration(hours: -5)); + expect(d.toString(), '2002-10-10-05:00'); + }); + + test('parses positive offset date', () { + final d = XsdDate.parse('2002-10-10+05:30'); + expect(d.isFloating, isFalse); + expect(d.value.isUtc, isTrue); + expect(d.originalOffset, const Duration(hours: 5, minutes: 30)); + expect(d.toString(), '2002-10-10+05:30'); + }); + + test('throws FormatException on invalid format', () { + expect( + () => XsdDate.parse('2002-10-10T12:00:00'), + throwsFormatException, + ); // dateTime format + expect( + () => XsdDate.parse('2002-10'), + throwsFormatException, + ); // gYearMonth format + expect( + () => XsdDate.parse('2002-13-10'), + throwsFormatException, + ); // Invalid month + expect( + () => XsdDate.parse('2002-02-30'), + throwsFormatException, + ); // Invalid day + }); + + test('equality', () { + final d1 = XsdDate.parse('2002-10-10'); + final d2 = XsdDate.parse('2002-10-10'); + final d3 = XsdDate.parse('2002-10-10Z'); + + expect(d1, equals(d2)); + expect(d1.hashCode, equals(d2.hashCode)); + expect(d1, isNot(equals(d3))); // Floating != Zoned + }); + + test('compareTo', () { + final d1 = XsdDate.parse('2002-10-10'); // Floating (treated as UTC) + final d2 = XsdDate.parse('2002-10-11Z'); // UTC + + expect(d1.compareTo(d2), lessThan(0)); + }); + }); +}