diff --git a/CHANGELOG.md b/CHANGELOG.md index f5df9c7..93f184c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index bb69e05..5e1b65d 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/lib/src/codecs/time/xsd_time_codec.dart b/lib/src/codecs/time/xsd_time_codec.dart new file mode 100644 index 0000000..805c1a2 --- /dev/null +++ b/lib/src/codecs/time/xsd_time_codec.dart @@ -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 { + const XsdTimeCodec(); + + @override + Converter get encoder => const XsdTimeEncoder(); + + @override + Converter get decoder => const XsdTimeDecoder(); +} + +/// Encoder for [XsdTimeCodec]. +class XsdTimeEncoder extends Converter { + const XsdTimeEncoder(); + + @override + String convert(XsdTime input) { + return input.toString(); + } +} + +/// Decoder for [XsdTimeCodec]. +class XsdTimeDecoder extends Converter { + const XsdTimeDecoder(); + + @override + XsdTime convert(String input) { + return XsdTime.parse(input); + } +} diff --git a/lib/src/types/types.dart b/lib/src/types/types.dart index 4e5568d..b97adb9 100644 --- a/lib/src/types/types.dart +++ b/lib/src/types/types.dart @@ -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'; diff --git a/lib/src/types/xsd_time.dart b/lib/src/types/xsd_time.dart new file mode 100644 index 0000000..31d9a0e --- /dev/null +++ b/lib/src/types/xsd_time.dart @@ -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 { + /// 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); + 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; + } + + // 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+$'), ''); + 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); + } +} diff --git a/test/codecs/xsd_time_codec_test.dart b/test/codecs/xsd_time_codec_test.dart new file mode 100644 index 0000000..f3e00a7 --- /dev/null +++ b/test/codecs/xsd_time_codec_test.dart @@ -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); + }); + }); +} diff --git a/test/types/xsd_time_test.dart b/test/types/xsd_time_test.dart new file mode 100644 index 0000000..68d2203 --- /dev/null +++ b/test/types/xsd_time_test.dart @@ -0,0 +1,138 @@ +import 'package:test/test.dart'; +import 'package:xsd/src/types/xsd_time.dart'; + +void main() { + group('XsdTime', () { + test('parses floating time', () { + final t = XsdTime.parse('12:00:00'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isTrue); + expect(t.originalOffset, isNull); + expect(t.toString(), '12:00:00'); + }); + + test('parses time with fractional seconds', () { + final t = XsdTime.parse('12:00:00.123'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.value.millisecond, 123); + expect(t.isFloating, isTrue); + expect(t.toString(), '12:00:00.123'); + }); + + test('parses time with Z', () { + final t = XsdTime.parse('12:00:00Z'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration.zero); + expect(t.toString(), '12:00:00Z'); + }); + + test('parses time with offset', () { + final t = XsdTime.parse('12:00:00+05:00'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration(hours: 5)); + expect(t.toString(), '12:00:00+05:00'); + }); + + test('parses time with negative offset', () { + final t = XsdTime.parse('12:00:00-05:00'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration(hours: -5)); + expect(t.toString(), '12:00:00-05:00'); + }); + + test('parses time with the maximum negative offset', () { + final t = XsdTime.parse('12:00:00-14:00'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration(hours: -14)); + expect(t.toString(), '12:00:00-14:00'); + }); + + test('parses time with the maximum positive offset', () { + final t = XsdTime.parse('12:00:00+14:00'); + expect(t.value.hour, 12); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration(hours: 14)); + expect(t.toString(), '12:00:00+14:00'); + }); + + test('parses 24:00:00 as 00:00:00', () { + final t = XsdTime.parse('24:00:00'); + expect(t.value.hour, 0); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isTrue); + expect(t.toString(), '00:00:00'); + }); + + test('parses 24:00:00Z as 00:00:00Z', () { + final t = XsdTime.parse('24:00:00Z'); + expect(t.value.hour, 0); + expect(t.value.minute, 0); + expect(t.value.second, 0); + expect(t.isFloating, isFalse); + expect(t.originalOffset, Duration.zero); + expect(t.toString(), '00:00:00Z'); + }); + + test('throws on invalid format', () { + expect(() => XsdTime.parse('invalid'), throwsFormatException); + }); + + test('throws on invalid hour', () { + expect(() => XsdTime.parse('25:00:00'), throwsFormatException); + }); + + test('throws on invalid minute', () { + expect(() => XsdTime.parse('12:60:00'), throwsFormatException); + }); + + test('throws on invalid second', () { + expect(() => XsdTime.parse('12:00:60'), throwsFormatException); + }); + + test('throws on invalid offset', () { + // Offset > 14 hours + expect(() => XsdTime.parse('12:00:00+14:01'), throwsFormatException); + expect(() => XsdTime.parse('12:00:00-14:01'), throwsFormatException); + }); + + test('equality', () { + final t1 = XsdTime.parse('12:00:00'); + final t2 = XsdTime.parse('12:00:00'); + final t3 = XsdTime.parse('12:00:00Z'); + expect(t1, t2); + expect(t1, isNot(t3)); + }); + + test('hashCode', () { + final t1 = XsdTime.parse('12:00:00'); + final t2 = XsdTime.parse('12:00:00'); + expect(t1.hashCode, t2.hashCode); + }); + + test('compareTo', () { + final t1 = XsdTime.parse('12:00:00'); + final t2 = XsdTime.parse('13:00:00'); + expect(t1.compareTo(t2), lessThan(0)); + expect(t2.compareTo(t1), greaterThan(0)); + }); + }); +}