Skip to content
Open
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 @@ -35,3 +35,4 @@
- Implement `xsd:integer` codec
- Implement `xsd:negativeInteger` codec
- Implement `xsd:date` codec and data type
- Implement `xsd:time` codec and data type
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ This library aims to support the following XSD 1.1 built-in datatypes that are c
| `xsd:dateTime` | ✅ | ✅ | `XsdDateTime`[2] | `package:xsd` |
| `xsd:dateTimeStamp` | ✅ | ❌ | ??? | ??? |
| `xsd:date` | ✅ | ✅ | `XsdDate` | `package:xsd` |
| `xsd:time` | ✅ | | ??? | ??? |
| `xsd:time` | ✅ | | `XsdTime` | `package:xsd` |
| `xsd:gYearMonth` | ✅ | ✅ | `YearMonth` | `package:xsd` |
| `xsd:gYear` | ✅ | ✅ | `GregorianYear` | `package:xsd` |
| `xsd:gMonthDay` | ✅ | ✅ | `GregorianMonthDay` | `package:xsd` |
Expand Down
34 changes: 34 additions & 0 deletions lib/src/codecs/time/xsd_time_codec.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'dart:convert';

import '../../types/xsd_time.dart';

/// A [Codec] that converts between [XsdTime] objects and their XSD string representations.
class XsdTimeCodec extends Codec<XsdTime, String> {
const XsdTimeCodec();

@override
Converter<XsdTime, String> get encoder => const XsdTimeEncoder();

@override
Converter<String, XsdTime> get decoder => const XsdTimeDecoder();
}

/// Encoder for [XsdTimeCodec].
class XsdTimeEncoder extends Converter<XsdTime, String> {
const XsdTimeEncoder();

@override
String convert(XsdTime input) {
return input.toString();
}
}

/// Decoder for [XsdTimeCodec].
class XsdTimeDecoder extends Converter<String, XsdTime> {
const XsdTimeDecoder();

@override
XsdTime convert(String input) {
return XsdTime.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 @@ -6,3 +6,4 @@ export 'gregorian_month_day.dart';
export 'xsd_duration.dart';
export 'xsd_datetime.dart';
export 'xsd_date.dart';
export 'xsd_time.dart';
204 changes: 204 additions & 0 deletions lib/src/types/xsd_time.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'package:meta/meta.dart';

/// A wrapper around [DateTime] that provides XSD-compliant handling of
/// `xsd:time` values.
///
/// `xsd:time` represents a time of day (HH:MM:SS). Like `xsd:dateTime`,
/// it can be "floating" (timezone-less) or zoned.
///
/// This class ensures that the date components of the underlying [DateTime]
/// are always set to 1970-01-01.
@immutable
class XsdTime implements Comparable<XsdTime> {
/// The underlying [DateTime] value, always in UTC, with date components set to 1970-01-01.
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;

XsdTime(DateTime value, {this.isFloating = false, this.originalOffset})
: value = DateTime.utc(
1970,
1,
1,
value.hour,
value.minute,
value.second,
value.millisecond,
value.microsecond,
) {
if (originalOffset != null) {
if (originalOffset!.inMinutes.abs() > 14 * 60) {
throw ArgumentError('Timezone offset must be within +/- 14 hours');
}
}
}

// Regex for xsd:time
// (([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]+)?|(24:00:00(\.0+)?))(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))?
// Simplified capture groups:
// Group 1: Hour
// Group 2: Minute
// Group 3: Second
// Group 4: Fractional seconds (optional)
// Group 5: Timezone (optional)
static final _timeRegex = RegExp(
r'^(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-](?:(?:0\d|1[0-3]):[0-5]\d|14:00))?$',
);

static final _offsetRegex = RegExp(r'([+-])(\d{2}):(\d{2})$');

/// Parses an XSD time string.
///
/// Throws a [FormatException] if the [input] is not a valid representation.
static XsdTime parse(String input) {
// Special case for 24:00:00 which is valid in XSD but not in DateTime
if (input.startsWith('24:00:00')) {
// Check if it has fractional seconds that are all zero
final fractionalMatch = RegExp(r'^24:00:00(\.0+)?').firstMatch(input);
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The RegExp for matching 24:00:00 is created on each call to parse. For better performance, this should be defined as a static final field at the class level and reused here.

For example, you can add:

static final _twentyFourHourRegex = RegExp(r'^24:00:00(\.0+)?');

And then use it in this line.

Suggested change
final fractionalMatch = RegExp(r'^24:00:00(\.0+)?').firstMatch(input);
final fractionalMatch = _twentyFourHourRegex.firstMatch(input);

if (fractionalMatch != null) {
final rest = input.substring(fractionalMatch.end);
// If rest is empty or just timezone, it's midnight of the next day,
// but for xsd:time it's just 00:00:00.
// We'll treat it as 00:00:00.
// We need to handle the timezone part if present.
String effectiveInput = '00:00:00$rest';
return parse(effectiveInput);
}
}

final match = _timeRegex.firstMatch(input);
if (match == null) {
throw FormatException('Invalid xsd:time format', input);
}

final hourStr = match.group(1)!;
final minuteStr = match.group(2)!;
final secondStr = match.group(3)!;
final fractionStr = match.group(4);
final tzStr = match.group(5);

final hour = int.parse(hourStr);
final minute = int.parse(minuteStr);
final second = int.parse(secondStr);

int millisecond = 0;
int microsecond = 0;

if (fractionStr != null) {
// fractionStr includes the dot, e.g. ".123"
final fractionalPart = double.parse(fractionStr);
final ms = (fractionalPart * 1000).round();
millisecond = ms % 1000;
microsecond = ((fractionalPart * 1000000).round()) % 1000;
}
Comment on lines +93 to +99
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

The current logic for parsing fractional seconds is flawed. Using double.parse can introduce precision errors, and the subsequent calculations for milliseconds and microseconds are incorrect for certain inputs (e.g., those with more than 3 fractional digits).

A more robust approach is to handle the fractional part as a string to avoid floating-point inaccuracies and ensure correct parsing.

    if (fractionStr != null) {
      // fractionStr includes the dot, e.g. ".123456"
      var fraction = fractionStr.substring(1);
      // DateTime supports up to 6 digits for fractional seconds (microseconds).
      // Truncate if longer.
      if (fraction.length > 6) {
        fraction = fraction.substring(0, 6);
      }
      // Pad with zeros to make it 6 digits long for easy parsing.
      final paddedFraction = fraction.padRight(6, '0');
      millisecond = int.parse(paddedFraction.substring(0, 3));
      microsecond = int.parse(paddedFraction.substring(3, 6));
    }


// Validate time components
if (hour > 24 ||
(hour == 24 &&
(minute > 0 || second > 0 || millisecond > 0 || microsecond > 0))) {
throw FormatException('Invalid time components', input);
}
if (minute > 59) {
throw FormatException('Invalid minute component', input);
}
if (second > 59) {
throw FormatException('Invalid second component', input);
}

// We use 1970-01-01 as the base date
final dt = DateTime.utc(
1970,
1,
1,
hour,
minute,
second,
millisecond,
microsecond,
);

if (tzStr == null) {
return XsdTime(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 XsdTime(dt, isFloating: false, originalOffset: offset);
}
}

/// Returns the ISO 8601 string representation (HH:MM:SS).
@override
String toString() {
final h = value.hour.toString().padLeft(2, '0');
final m = value.minute.toString().padLeft(2, '0');
final s = value.second.toString().padLeft(2, '0');

var timePart = '$h:$m:$s';

if (value.millisecond > 0 || value.microsecond > 0) {
final ms = value.millisecond.toString().padLeft(3, '0');
final us = (value.microsecond > 0)
? value.microsecond.toString().padLeft(3, '0')
: '';
// Remove trailing zeros if any? XSD doesn't strictly require it but canonical usually does.
// For now let's keep it simple and consistent with XsdDateTime
timePart = '$timePart.$ms$us';
// Strip trailing zeros from fraction
timePart = timePart.replaceAll(RegExp(r'0+$'), '');
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The RegExp for stripping trailing zeros is created on each call to toString. For better performance, this should be defined as a static final field at the class level and reused here.

For example, you can add:

static final _trailingZerosRegex = RegExp(r'0+$');

And then use it in this line.

      timePart = timePart.replaceAll(_trailingZerosRegex, '');

if (timePart.endsWith('.')) {
timePart = timePart.substring(0, timePart.length - 1);
}
}

if (isFloating) {
return timePart;
}

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 '$timePart$sign$hours:$minutes';
}

return '${timePart}Z';
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is XsdTime &&
other.value == value &&
other.isFloating == isFloating &&
other.originalOffset == originalOffset;
}

@override
int get hashCode => Object.hash(value, isFloating, originalOffset);

/// Compares this [XsdTime] to [other].
///
/// **Note**: This comparison treats "floating" (timezone-less) values as if
/// they were in UTC.
@override
int compareTo(XsdTime other) {
return value.compareTo(other.value);
}
}
20 changes: 20 additions & 0 deletions test/codecs/xsd_time_codec_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'package:test/test.dart';
import 'package:xsd/src/types/xsd_time.dart';
import 'package:xsd/src/codecs/time/xsd_time_codec.dart';

void main() {
group('XsdTimeCodec', () {
const codec = XsdTimeCodec();

test('encodes', () {
final t = XsdTime.parse('12:00:00');
expect(codec.encode(t), '12:00:00');
});

test('decodes', () {
final t = codec.decode('12:00:00');
expect(t.value.hour, 12);
expect(t.isFloating, isTrue);
});
});
}
Loading