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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@
- Implement `xsd:unsignedInt` codec
- Implement `xsd:integer` codec
- Implement `xsd:negativeInteger` codec
- Implement `xsd:date` codec and data type
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
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 @@ -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';
41 changes: 41 additions & 0 deletions lib/src/codecs/date/xsd_date_codec.dart
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);
}
}
1 change: 1 addition & 0 deletions lib/src/types/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
131 changes: 131 additions & 0 deletions lib/src/types/xsd_date.dart
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;
}
}
Comment on lines +70 to +81
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The logic for parsing the timezone can be improved for clarity and correctness.

  1. Correctness: The current implementation does not validate the range of the timezone offset. According to the XSD 1.1 specification, the offset hh must be between 00-14 and mm between 00-59 (with mm being 00 if hh is 14). An invalid offset like +15:00 would be incorrectly parsed.
  2. Clarity: Since _dateRegex ensures that tzStr (if not null and not 'Z') will be in the [+-]HH:mm format, the _offsetRegex will always find a match. The null check on tzMatch is redundant.

The suggested change addresses both points by adding the validation and simplifying the logic.

      final Duration offset;
      if (tzStr == 'Z') {
        offset = Duration.zero;
      } else {
        // _dateRegex ensures tzStr is in [+-]HH:mm format, so a match is guaranteed.
        final tzMatch = _offsetRegex.firstMatch(tzStr)!;
        final sign = tzMatch.group(1) == '+' ? 1 : -1;
        final hours = int.parse(tzMatch.group(2)!);
        final minutes = int.parse(tzMatch.group(3)!);
        if (hours > 14 || minutes > 59 || (hours == 14 && minutes != 0)) {
          throw FormatException('Invalid timezone offset range', input);
        }
        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);
}
}
59 changes: 59 additions & 0 deletions test/codecs/xsd_date_codec_test.dart
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);
});
});
}
80 changes: 80 additions & 0 deletions test/types/xsd_date_test.dart
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
});
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

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));
});
});
}