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
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,60 @@
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
* Utility class for converting the record from ServiceNow data type to CDAP schema data types
*/
public class ServiceNowRecordConverter {
private static final String DATE_PATTERN = "yyyy-MM-dd";
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
private static final String TIME_PATTERN = "HH:mm:ss";

// supported date and time formats.
// ref: https://www.servicenow.com/docs/bundle/yokohama-api-reference/page/app-store/
// dev_portal/API_reference/GlideDateTime/concept/c_GlideDateTimeAPI.html
// Instead of using DateTimeFormatterBuilder.appendOptional(...), we maintain a List<DateTimeFormatter>
// and attempt to parse the input with each formatter in order. This approach is more reliable because
// DateTimeFormatterBuilder processes patterns sequentially and may partially match an incorrect format.
// For example, if "MM/dd/yyyy HH:mm:ss" is tried before "dd/MM/yyyy HH:mm:ss", an input like
// "14/06/2025 13:00:00" would fail, as 14 is not a valid month.
// By explicitly trying each formatter, we avoid such ambiguities and ensure correct parsing of
// date formats with overlapping patterns.
private static final List<DateTimeFormatter> DATE_TIME_FORMATTER = Collections.unmodifiableList(
Arrays.asList(
DateTimeFormatter.ofPattern("dd.MM.yyyy hh:mm:ss a"),
DateTimeFormatter.ofPattern("dd.MM.yyyy hh.mm.ss a"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH.mm.ss"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH.mm.ss"),
DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("MM-dd-yyyy HH:mm:ss"),
DateTimeFormatter.ofPattern("dd-MM-yy HH.mm.ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"),
DateTimeFormatter.ofPattern("MM-dd-yyyy HH:mm"),
DateTimeFormatter.ofPattern("dd-MM-yyyy HH.mm")
));

private static final List<DateTimeFormatter> DATE_FORMATTER = Collections.unmodifiableList(
Arrays.asList(
DateTimeFormatter.ofPattern("yyyy-MM-dd"),
DateTimeFormatter.ofPattern("dd.MM.yyyy"),
DateTimeFormatter.ofPattern("dd-MM-yyyy"),
DateTimeFormatter.ofPattern("dd/MM/yyyy"),
DateTimeFormatter.ofPattern("MM/dd/yyyy"),
DateTimeFormatter.ofPattern("MM-dd-yyyy")
));

private static final List<DateTimeFormatter> TIME_FORMATTER = Collections.unmodifiableList(
Arrays.asList(
DateTimeFormatter.ofPattern("HH:mm:ss"),
DateTimeFormatter.ofPattern("HH:mm")
));

public static void convertToValue(String fieldName, Schema fieldSchema, Map<String, String> record,
StructuredRecord.Builder recordBuilder) {
Expand All @@ -53,34 +97,16 @@ public static void convertToValue(String fieldName, Schema fieldSchema, Map<Stri
if (fieldLogicalType != null) {
switch (fieldLogicalType) {
case DATETIME:
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
try {
recordBuilder.setDateTime(fieldName, LocalDateTime.parse(fieldValue, dateTimeFormatter));
} catch (DateTimeParseException exception) {
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, fieldSchema.getDisplayName(), fieldValue), exception);
}
recordBuilder.setDateTime(fieldName,
parseDateTimeFormat(fieldValue, fieldName, fieldSchema.getDisplayName()));
return;
case DATE:
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(DATE_PATTERN);
try {
recordBuilder.setDate(fieldName, LocalDate.parse(fieldValue, dateFormatter));
} catch (DateTimeParseException exception) {
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, fieldSchema.getDisplayName(), fieldValue), exception);
}
recordBuilder.setDate(fieldName,
parseDateFormat(fieldValue, fieldName, fieldSchema.getDisplayName()));
return;
case TIME_MICROS:
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern(TIME_PATTERN);
try {
recordBuilder.setTime(fieldName, LocalTime.parse(fieldValue, timeFormatter));
} catch (DateTimeParseException exception) {
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, fieldSchema.getDisplayName(), fieldValue), exception);
}
recordBuilder.setTime(fieldName,
parseTimeFormat(fieldValue, fieldName, fieldSchema.getDisplayName()));
return;
default:
throw new IllegalStateException(String.format("Field '%s' is of unsupported type '%s'", fieldName,
Expand Down Expand Up @@ -139,4 +165,43 @@ public static Boolean convertToBooleanValue(String fieldValue) {
String.format("Field with value '%s' is not in valid format.", fieldValue));

}

private static LocalDateTime parseDateTimeFormat(String fieldValue, String fieldName, String displayName) {
for (DateTimeFormatter dateTimeFormatter : DATE_TIME_FORMATTER) {
try {
return LocalDateTime.parse(fieldValue, dateTimeFormatter);
} catch (DateTimeParseException e) {
// Only throw exception once all the formats are checked.
}
}
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, displayName, fieldValue));
}

private static LocalDate parseDateFormat(String fieldValue, String fieldName, String displayName) {
for (DateTimeFormatter dateFormatter : DATE_FORMATTER) {
try {
return LocalDate.parse(fieldValue, dateFormatter);
} catch (DateTimeParseException e) {
// Only throw exception once all the formats are checked.
}
}
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, displayName, fieldValue));
}

private static LocalTime parseTimeFormat(String fieldValue, String fieldName, String displayName) {
for (DateTimeFormatter timeFormatter : TIME_FORMATTER) {
try {
return LocalTime.parse(fieldValue, timeFormatter);
} catch (DateTimeParseException e) {
// Only throw exception once all the formats are checked.
}
}
throw new UnexpectedFormatException(
String.format("Field '%s' of type '%s' with value '%s' is not in ISO-8601 format.",
fieldName, displayName, fieldValue));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import java.io.IOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -132,6 +133,101 @@ public void testConvertToValue() {
ServiceNowRecordConverter.convertToValue("TimeField", fieldSchema, map, recordBuilder);
}

@Test
public void testConvertToDateTimeValue() {
Schema recordSchema = Schema.recordOf(
"record",
Schema.Field.of("DateTimeField", Schema.of(Schema.LogicalType.DATETIME))
);
Schema fieldSchema = recordSchema.getField("DateTimeField").getSchema();

List<String> dateTimeValues = Arrays.asList(
"2025-05-14 13:45:30",
"2025-05-14 13:45",
"14-04-2025 13.45.32",
"14-04-25 13.45.34",
"14/05/2025 13:45:30",
"14.05.2025 01.45.30 PM",
"14.05.2025 01:45:30 PM",
"28/10/2017 01:00:01"
);

for (String value : dateTimeValues) {
Map<String, String> inputMap = new HashMap<>();
inputMap.put("DateTimeField", value);

StructuredRecord.Builder recordBuilder = StructuredRecord.builder(recordSchema);
try {
ServiceNowRecordConverter.convertToValue("DateTimeField", fieldSchema, inputMap, recordBuilder);
StructuredRecord record = recordBuilder.build();
Assert.assertNotNull("Parsed datetime should not be null for input: " + value,
record.get("DateTimeField"));
} catch (UnexpectedFormatException e) {
Assert.fail("Failed to parse valid datetime format: " + value + " - " + e.getMessage());
}
}
}

@Test
public void testConvertToDateValue() {
Schema recordSchema = Schema.recordOf(
"record",
Schema.Field.of("DateField", Schema.of(Schema.LogicalType.DATE))
);
Schema fieldSchema = recordSchema.getField("DateField").getSchema();

List<String> dateValues = Arrays.asList(
"2025-05-14",
"14/05/2025",
"14.05.2025",
"02/10/2017"
);

for (String value : dateValues) {
Map<String, String> inputMap = new HashMap<>();
inputMap.put("DateField", value);

StructuredRecord.Builder recordBuilder = StructuredRecord.builder(recordSchema);
try {
ServiceNowRecordConverter.convertToValue("DateField", fieldSchema, inputMap, recordBuilder);
StructuredRecord record = recordBuilder.build();
Assert.assertNotNull("Parsed date should not be null for input: " + value,
record.get("DateField"));
} catch (UnexpectedFormatException e) {
Assert.fail("Failed to parse valid date format: " + value + " - " + e.getMessage());
}
}
}

@Test
public void testConvertToTimeValue() {
Schema recordSchema = Schema.recordOf(
"record",
Schema.Field.of("TimeField", Schema.of(Schema.LogicalType.TIME_MICROS))
);
Schema fieldSchema = recordSchema.getField("TimeField").getSchema();

List<String> timeValues = Arrays.asList(
"13:45:30",
"13:45"
);

for (String value : timeValues) {
Map<String, String> inputMap = new HashMap<>();
inputMap.put("TimeField", value);

StructuredRecord.Builder recordBuilder = StructuredRecord.builder(recordSchema);
try {
ServiceNowRecordConverter.convertToValue("TimeField", fieldSchema, inputMap, recordBuilder);
StructuredRecord record = recordBuilder.build();
Assert.assertNotNull("Parsed date should not be null for input: " + value,
record.get("TimeField"));
} catch (UnexpectedFormatException e) {
Assert.fail("Failed to parse valid date format: " + value + " - " + e.getMessage());
}
}
}

@Test
public void testConvertToDoubleValue() throws ParseException {
Assert.assertEquals(42.0, ServiceNowRecordConverter.convertToDoubleValue("42"),
Expand Down
Loading