-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add XsdDate type and codec for XSD-compliant date handling.
#75
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
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,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<XsdDate, String> { | ||
| const XsdDateCodec(); | ||
|
|
||
| @override | ||
| Converter<XsdDate, String> get encoder => const XsdDateEncoder(); | ||
|
|
||
| @override | ||
| Converter<String, XsdDate> get decoder => const XsdDateDecoder(); | ||
| } | ||
|
|
||
| /// Encoder for [XsdDateCodec]. | ||
| class XsdDateEncoder extends Converter<XsdDate, String> { | ||
| const XsdDateEncoder(); | ||
|
|
||
| @override | ||
| String convert(XsdDate input) { | ||
| return input.toString(); | ||
| } | ||
| } | ||
|
|
||
| /// Decoder for [XsdDateCodec]. | ||
| class XsdDateDecoder extends Converter<String, XsdDate> { | ||
| 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); | ||
| } | ||
| } |
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,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<XsdDate> { | ||
| /// 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); | ||
| } | ||
| } | ||
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,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); | ||
| }); | ||
| }); | ||
| } |
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,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 | ||
| }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test group is great for format validation! To make it even more robust, could you also add test cases for invalid timezone offsets? This will help prevent future regressions for the timezone validation logic you're adding. For example: expect(
() => XsdDate.parse('2002-10-10+15:00'), // Hour > 14
throwsFormatException,
);
expect(
() => XsdDate.parse('2002-10-10-14:01'), // Minute > 00 when hour is 14
throwsFormatException,
); |
||
|
|
||
| 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)); | ||
| }); | ||
| }); | ||
| } | ||
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 logic for parsing the timezone can be improved for clarity and correctness.
hhmust be between 00-14 andmmbetween 00-59 (withmmbeing 00 ifhhis 14). An invalid offset like+15:00would be incorrectly parsed._dateRegexensures thattzStr(if not null and not 'Z') will be in the[+-]HH:mmformat, the_offsetRegexwill always find a match. The null check ontzMatchis redundant.The suggested change addresses both points by adding the validation and simplifying the logic.