diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1e457..641e116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,4 @@ - Implement `xsd:nonPositiveInteger` data type codec - Implement `xsd:unsignedLong` data type codec - Implement `xsd:gDay` codec and data type +- Implement `xsd:gMonthDay` codec and data type diff --git a/README.md b/README.md index aaebbf7..ddd4613 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ This library aims to support the following XSD 1.1 built-in datatypes that are c | `xsd:time` | ✅ | ❌ | ??? | ??? | | `xsd:gYearMonth` | ✅ | ✅ | `YearMonth` | `package:xsd` | | `xsd:gYear` | ✅ | ✅ | `GregorianYear` | `package:xsd` | -| `xsd:gMonthDay` | ✅ | ❌ | ??? | ??? | -| `xsd:gDay` | ✅ | ✅ | GregorianDay | `package:xsd` | +| `xsd:gMonthDay` | ✅ | ✅ | `GregorianMonthDay` | `package:xsd` | +| `xsd:gDay` | ✅ | ✅ | `GregorianDay` | `package:xsd` | | `xsd:gMonth` | ✅ | ✅ | `GregorianMonth` | `package:xsd` | | `xsd:QName` | ❌ | ❌ | ❌ | ❌ | | `xsd:NOTATION` | ❌ | ❌ | ❌ | ❌ | diff --git a/lib/src/codecs/codecs.dart b/lib/src/codecs/codecs.dart index 1358fab..37a92cc 100644 --- a/lib/src/codecs/codecs.dart +++ b/lib/src/codecs/codecs.dart @@ -21,3 +21,4 @@ export 'positive_integer/positive_integer_codec.dart'; export 'non_positive_integer/non_positive_integer_codec.dart'; export 'unsigned_long/unsigned_long_codec.dart'; export 'g_day/g_day_codec.dart'; +export 'g_month_day/gregorian_month_day_codec.dart'; diff --git a/lib/src/codecs/g_month_day/gregorian_month_day_codec.dart b/lib/src/codecs/g_month_day/gregorian_month_day_codec.dart new file mode 100644 index 0000000..121555c --- /dev/null +++ b/lib/src/codecs/g_month_day/gregorian_month_day_codec.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import '../../types/gregorian_month_day.dart'; +import 'gregorian_month_day_decoder.dart'; +import 'gregorian_month_day_encoder.dart'; + +/// A codec for converting between [GregorianMonthDay] objects and their XSD gMonthDay string representations. +class GregorianMonthDayCodec extends Codec { + const GregorianMonthDayCodec(); + + @override + Converter get decoder => + const GregorianMonthDayDecoder(); + + @override + Converter get encoder => + const GregorianMonthDayEncoder(); +} diff --git a/lib/src/codecs/g_month_day/gregorian_month_day_decoder.dart b/lib/src/codecs/g_month_day/gregorian_month_day_decoder.dart new file mode 100644 index 0000000..2e21a82 --- /dev/null +++ b/lib/src/codecs/g_month_day/gregorian_month_day_decoder.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +import '../../types/gregorian_month_day.dart'; + +/// A converter that parses XSD gMonthDay strings into [GregorianMonthDay] objects. +class GregorianMonthDayDecoder extends Converter { + const GregorianMonthDayDecoder(); + + @override + GregorianMonthDay convert(String input) { + return GregorianMonthDay.parse(input); + } +} diff --git a/lib/src/codecs/g_month_day/gregorian_month_day_encoder.dart b/lib/src/codecs/g_month_day/gregorian_month_day_encoder.dart new file mode 100644 index 0000000..8cd9b18 --- /dev/null +++ b/lib/src/codecs/g_month_day/gregorian_month_day_encoder.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +import '../../types/gregorian_month_day.dart'; + +/// A converter that converts [GregorianMonthDay] objects to their XSD gMonthDay string representation. +class GregorianMonthDayEncoder extends Converter { + const GregorianMonthDayEncoder(); + + @override + String convert(GregorianMonthDay input) { + return input.toString(); + } +} diff --git a/lib/src/types/gregorian_month_day.dart b/lib/src/types/gregorian_month_day.dart new file mode 100644 index 0000000..d4c188b --- /dev/null +++ b/lib/src/types/gregorian_month_day.dart @@ -0,0 +1,213 @@ +import 'package:meta/meta.dart'; + +import '../helpers/whitespace.dart'; + +/// Represents an xsd:gMonthDay value. +/// +/// A gMonthDay is a specific day of a specific month, independent of the year. +/// It has a month, a day, and an optional timezone. +/// +/// See [XSD gMonthDay](https://www.w3.org/TR/xmlschema11-2/#gMonthDay). +@immutable +class GregorianMonthDay { + /// The month component of this gMonthDay, from 1 (January) to 12 (December). + final int month; + + /// The day component of this gMonthDay, from 1 to 31. + final int day; + + /// Optional timezone offset in minutes from UTC. + /// + /// A positive value indicates a timezone east of UTC, negative for west. + /// `null` means no timezone is specified. + final int? timezoneOffsetInMinutes; + + /// Constructs a [GregorianMonthDay] instance. + /// + /// The [month] must be between 1 and 12. + /// The [day] must be valid for the given [month] (e.g., 30 for April, 31 for January). + /// February 29 is allowed. + /// The [timezoneOffsetInMinutes], if provided, must be between -840 and 840. + GregorianMonthDay(this.month, this.day, {this.timezoneOffsetInMinutes}) { + if (month < 1 || month > 12) { + throw ArgumentError.value( + month, + 'month', + 'Month must be between 1 and 12', + ); + } + if (day < 1 || day > 31) { + throw ArgumentError.value(day, 'day', 'Day must be between 1 and 31'); + } + // Validate day against month + final maxDays = _daysInMonth(month); + if (day > maxDays) { + throw ArgumentError.value( + day, + 'day', + 'Day must be between 1 and $maxDays for month $month', + ); + } + + const minOffset = -14 * 60; + const maxOffset = 14 * 60; + if (timezoneOffsetInMinutes != null) { + if (timezoneOffsetInMinutes! < minOffset || + timezoneOffsetInMinutes! > maxOffset) { + throw ArgumentError.value( + timezoneOffsetInMinutes, + 'timezoneOffsetInMinutes', + 'Timezone offset must be between -PT14H and PT14H ($minOffset and $maxOffset minutes)', + ); + } + } + } + + static int _daysInMonth(int month) { + switch (month) { + case 2: + return 29; // gMonthDay allows Feb 29 + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + /// Parses a string in XSD gMonthDay format into a [GregorianMonthDay] object. + /// + /// The expected format is `--MM-DD` with an optional timezone suffix + /// (e.g., "--10-25", "--02-29Z", "--12-31+05:30"). + /// + /// Throws a [FormatException] if the input string is not a valid gMonthDay. + static GregorianMonthDay parse(String lexicalForm) { + final String collapsedInput = processWhiteSpace( + lexicalForm, + Whitespace.collapse, + ); + + // Regular expression for gMonthDay: --MM-DD(Z|(+|-)HH:MM)? + // Month part: (0[1-9]|1[0-2]) + // Day part: (0[1-9]|[12][0-9]|3[01]) + // Timezone part: (Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))? + final RegExp gMonthDayPattern = RegExp( + r'^--(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))?$', + ); + + final Match? match = gMonthDayPattern.firstMatch(collapsedInput); + + if (match == null) { + throw FormatException( + "Invalid gMonthDay format: '$lexicalForm' (collapsed to '$collapsedInput')", + ); + } + + final String monthStr = match.group(1)!; + final String dayStr = match.group(2)!; + final String? timezoneStr = match.group(3); // This group is optional + + final int month; + try { + month = int.parse(monthStr); + } catch (e) { + throw FormatException( + "Invalid month component in gMonthDay: '$monthStr'", + collapsedInput, + ); + } + + final int day; + try { + day = int.parse(dayStr); + } catch (e) { + throw FormatException( + "Invalid day component in gMonthDay: '$dayStr'", + collapsedInput, + ); + } + + // Validate day against month logic (regex is loose on 30/31 days for some months) + final maxDays = _daysInMonth(month); + if (day > maxDays) { + throw FormatException( + "Day value '$dayStr' is invalid for month '$monthStr' (max $maxDays).", + collapsedInput, + ); + } + + int? timezoneOffsetMinutes; + if (timezoneStr != null) { + if (timezoneStr == 'Z') { + timezoneOffsetMinutes = 0; + } else { + final int sign = timezoneStr.startsWith('+') ? 1 : -1; + final parts = timezoneStr.substring(1).split(':'); + final int hours = int.parse(parts[0]); + final int minutes = int.parse(parts[1]); + timezoneOffsetMinutes = sign * (hours * 60 + minutes); + } + } + + return GregorianMonthDay( + month, + day, + timezoneOffsetInMinutes: timezoneOffsetMinutes, + ); + } + + /// Returns the canonical lexical representation of this [GregorianMonthDay]. + /// + /// Format is "--MM-DD" e.g., "--10-25". + /// If a timezone is present, it's appended (e.g., "--05-20Z", "--12-01+05:30"). + @override + String toString() { + final monthString = month.toString().padLeft(2, '0'); + final dayString = day.toString().padLeft(2, '0'); + final buffer = StringBuffer('--$monthString-$dayString'); + + if (timezoneOffsetInMinutes != null) { + if (timezoneOffsetInMinutes == 0) { + buffer.write('Z'); + } else { + final int offset = timezoneOffsetInMinutes!; + final String sign = offset.isNegative ? '-' : '+'; + final int absOffset = offset.abs(); + final String hours = (absOffset ~/ 60).toString().padLeft(2, '0'); + final String minutes = (absOffset % 60).toString().padLeft(2, '0'); + buffer.write('$sign$hours:$minutes'); + } + } + return buffer.toString(); + } + + /// Creates a new [GregorianMonthDay] instance with the given fields updated. + GregorianMonthDay copyWith({ + int? month, + int? day, + int? timezoneOffsetInMinutes, + bool setToNoTimezone = false, // Special flag to remove timezone + }) { + return GregorianMonthDay( + month ?? this.month, + day ?? this.day, + timezoneOffsetInMinutes: + timezoneOffsetInMinutes ?? + (setToNoTimezone ? null : this.timezoneOffsetInMinutes), + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is GregorianMonthDay && + other.month == month && + other.day == day && + other.timezoneOffsetInMinutes == timezoneOffsetInMinutes; + } + + @override + int get hashCode => Object.hash(month, day, timezoneOffsetInMinutes); +} diff --git a/lib/src/types/types.dart b/lib/src/types/types.dart index d449f5b..06eb771 100644 --- a/lib/src/types/types.dart +++ b/lib/src/types/types.dart @@ -2,3 +2,4 @@ export 'year_month.dart'; export 'gregorian_year.dart'; export 'gregorian_month.dart'; export 'gregorian_day.dart'; +export 'gregorian_month_day.dart'; diff --git a/test/types/gregorian_month_day_test.dart b/test/types/gregorian_month_day_test.dart new file mode 100644 index 0000000..b822135 --- /dev/null +++ b/test/types/gregorian_month_day_test.dart @@ -0,0 +1,141 @@ +import 'package:test/test.dart'; +import 'package:xsd/src/types/gregorian_month_day.dart'; + +void main() { + group('GregorianMonthDay', () { + test('constructor validates month', () { + expect(() => GregorianMonthDay(0, 1), throwsArgumentError); + expect(() => GregorianMonthDay(13, 1), throwsArgumentError); + expect(GregorianMonthDay(1, 1).month, 1); + expect(GregorianMonthDay(12, 1).month, 12); + }); + + test('constructor validates day', () { + expect(() => GregorianMonthDay(1, 0), throwsArgumentError); + expect(() => GregorianMonthDay(1, 32), throwsArgumentError); + expect(GregorianMonthDay(1, 1).day, 1); + expect(GregorianMonthDay(1, 31).day, 31); + }); + + test('constructor validates day against month', () { + expect( + () => GregorianMonthDay(4, 31), + throwsArgumentError, + ); // April has 30 days + expect(GregorianMonthDay(4, 30).day, 30); + expect(GregorianMonthDay(2, 29).day, 29); // Feb 29 is allowed + expect(() => GregorianMonthDay(2, 30), throwsArgumentError); + }); + + test('constructor validates timezone', () { + expect( + () => GregorianMonthDay(1, 1, timezoneOffsetInMinutes: -841), + throwsArgumentError, + ); + expect( + () => GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 841), + throwsArgumentError, + ); + expect( + GregorianMonthDay( + 1, + 1, + timezoneOffsetInMinutes: -840, + ).timezoneOffsetInMinutes, + -840, + ); + expect( + GregorianMonthDay( + 1, + 1, + timezoneOffsetInMinutes: 840, + ).timezoneOffsetInMinutes, + 840, + ); + }); + + test('parse valid strings', () { + expect(GregorianMonthDay.parse('--01-01'), GregorianMonthDay(1, 1)); + expect(GregorianMonthDay.parse('--12-31'), GregorianMonthDay(12, 31)); + expect(GregorianMonthDay.parse('--02-29'), GregorianMonthDay(2, 29)); + expect( + GregorianMonthDay.parse('--01-01Z'), + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 0), + ); + expect( + GregorianMonthDay.parse('--01-01+05:30'), + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 330), + ); + expect( + GregorianMonthDay.parse('--01-01-05:00'), + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: -300), + ); + expect( + GregorianMonthDay.parse('--01-01+14:00'), + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 840), + ); + }); + + test('parse invalid strings', () { + expect(() => GregorianMonthDay.parse('01-01'), throwsFormatException); + expect(() => GregorianMonthDay.parse('--1-1'), throwsFormatException); + expect(() => GregorianMonthDay.parse('--13-01'), throwsFormatException); + expect(() => GregorianMonthDay.parse('--01-32'), throwsFormatException); + expect( + () => GregorianMonthDay.parse('--04-31'), + throwsFormatException, + ); // April 31 + expect(() => GregorianMonthDay.parse('--02-30'), throwsFormatException); + expect( + () => GregorianMonthDay.parse('--01-01+14:01'), + throwsFormatException, + ); // Invalid timezone + }); + + test('toString returns canonical format', () { + expect(GregorianMonthDay(1, 1).toString(), '--01-01'); + expect(GregorianMonthDay(12, 31).toString(), '--12-31'); + expect( + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 0).toString(), + '--01-01Z', + ); + expect( + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 330).toString(), + '--01-01+05:30', + ); + expect( + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: -300).toString(), + '--01-01-05:00', + ); + }); + + test('equality and hashCode', () { + final md1 = GregorianMonthDay(1, 1); + final md2 = GregorianMonthDay(1, 1); + final md3 = GregorianMonthDay(1, 2); + final md4 = GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 0); + + expect(md1, md2); + expect(md1.hashCode, md2.hashCode); + expect(md1, isNot(md3)); + expect(md1, isNot(md4)); + }); + + test('copyWith', () { + final md = GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 0); + expect( + md.copyWith(month: 2), + GregorianMonthDay(2, 1, timezoneOffsetInMinutes: 0), + ); + expect( + md.copyWith(day: 2), + GregorianMonthDay(1, 2, timezoneOffsetInMinutes: 0), + ); + expect( + md.copyWith(timezoneOffsetInMinutes: 60), + GregorianMonthDay(1, 1, timezoneOffsetInMinutes: 60), + ); + expect(md.copyWith(setToNoTimezone: true), GregorianMonthDay(1, 1)); + }); + }); +}