From 7313e4981ace799389597364938422a1276e6ccc Mon Sep 17 00:00:00 2001 From: Benjamin Gaidioz Date: Wed, 18 Dec 2024 15:53:09 +0100 Subject: [PATCH] Fixed RD-15247: IS [NOT] NULL generates wrong JQL --- .../DASJiraIssueTransformationTable.java | 31 +- .../jira/tables/DASJiraJqlQueryBuilder.java | 516 ++++++- .../rawlabs/das/jira/tables/DASJiraTable.java | 8 +- .../das/jira/tables/DASJiraTableManager.java | 15 +- .../definitions/DASJiraIssueCommentTable.java | 4 +- .../tables/definitions/DASJiraIssueTable.java | 10 +- .../definitions/DASJiraIssueWorklogTable.java | 4 +- .../tables/DASJiraJqlQueryBuilderTest.java | 1251 ++++++++++++++++- 8 files changed, 1700 insertions(+), 139 deletions(-) diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraIssueTransformationTable.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraIssueTransformationTable.java index ba2cdf3..87be3c3 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraIssueTransformationTable.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraIssueTransformationTable.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.rawlabs.das.sdk.java.exceptions.DASSdkApiException; import com.rawlabs.protocol.das.Row; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.*; @SuppressWarnings("unchecked") @@ -52,11 +56,12 @@ protected void processFields( assignee.map(a -> a.get("displayName")).orElse(null), columns); - addToRow( - "created", - rowBuilder, - maybeFields.map(f -> f.get(names.get("Created"))).orElse(null), - columns); + String created = + maybeFields + .map(f -> f.get(names.get("Created"))) + .map(c -> DateTime.parse(c.toString()).withZone(DateTimeZone.UTC).toString()) + .orElse(null); + addToRow("created", rowBuilder, created, columns); var creator = maybeFields.map(f -> f.get(names.get("Creator"))).map(p -> (Map) p); @@ -87,11 +92,9 @@ protected void processFields( throw new DASSdkApiException("error processing 'description'", e); } - addToRow( - "due_date", - rowBuilder, - maybeFields.map(f -> f.get(names.get("Due date"))).orElse(null), - columns); + String due_date = maybeFields.flatMap(f -> Optional.ofNullable(f.get(names.get("Due date")))).map(Object::toString).orElse(null); + + addToRow("due_date", rowBuilder, due_date, columns); var priority = maybeFields.map(f -> f.get(names.get("Priority"))).map(p -> (Map) p); @@ -106,12 +109,20 @@ protected void processFields( rowBuilder, reporter.map(r -> r.get("accountId")).orElse(null), columns); + addToRow( "reporter_display_name", rowBuilder, reporter.map(r -> r.get("displayName")).orElse(null), columns); + String resolved = + maybeFields + .flatMap(f -> Optional.ofNullable(f.get(names.get("Resolved")))) + .map(c -> DateTime.parse(c.toString()).withZone(DateTimeZone.UTC).toString()) + .orElse(null); + addToRow("resolution_date", rowBuilder, resolved, columns); + addToRow( "summary", rowBuilder, diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilder.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilder.java index 61260d7..396ca55 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilder.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilder.java @@ -2,23 +2,27 @@ import com.rawlabs.das.sdk.java.utils.factory.value.DefaultExtractValueFactory; import com.rawlabs.das.sdk.java.utils.factory.value.ExtractValueFactory; +import com.rawlabs.protocol.das.ListQual; import com.rawlabs.protocol.das.Operator; import com.rawlabs.protocol.das.Qual; import com.rawlabs.protocol.raw.Value; -import java.time.OffsetDateTime; -import java.time.OffsetTime; +import java.time.*; import java.time.format.DateTimeFormatter; -import java.util.List; -import java.util.Map; -import java.util.StringJoiner; +import java.util.*; public class DASJiraJqlQueryBuilder { + private final ZoneId zoneId; + + public DASJiraJqlQueryBuilder(ZoneId zoneId) { + this.zoneId = zoneId; + } + private static final ExtractValueFactory extractValueFactory = new DefaultExtractValueFactory(); static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - static String mapOperator(Operator operator) { + String mapOperator(Operator operator) { return switch (operator) { case Operator op when op.hasEquals() -> "="; case Operator op when op.hasNotEquals() -> "!="; @@ -30,54 +34,476 @@ static String mapOperator(Operator operator) { }; } - static String mapValue(Value value) { - String s = switch (value) { - case Value v when v.hasString() -> v.getString().getV(); - case Value v when v.hasTime() -> { - OffsetTime time = (OffsetTime) extractValueFactory.extractValue(value); - yield time.format(formatter); - } - case Value v when (v.hasTimestamp() || v.hasDate()) -> { - OffsetDateTime time = (OffsetDateTime) extractValueFactory.extractValue(value); - yield time.format(formatter); + String mapValue(Value value) { + String s = + switch (value) { + case Value v when v.hasString() -> v.getString().getV(); + case Value v when v.hasTime() -> { + OffsetTime time = (OffsetTime) extractValueFactory.extractValue(value); + yield time.format(formatter); + } + case Value v when v.hasDate() -> { + OffsetDateTime time = (OffsetDateTime) extractValueFactory.extractValue(value); + yield time.toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE); + } + case Value v when v.hasTimestamp() -> { + OffsetDateTime time = (OffsetDateTime) extractValueFactory.extractValue(value); + ZonedDateTime dTime = + time.withOffsetSameInstant(ZoneOffset.UTC).atZoneSameInstant(zoneId); + yield dTime.format(formatter); + } + default -> throw new IllegalArgumentException("Unexpected value: " + value); + }; + // If the string is all digits, it's a number and we don't need to quote it. This permits + // to filter on 'id' without quotes, which is the required syntax. If it's not all digits, we + // quote it, because it doesn't hurt, and it protects potential keywords. + boolean allDigits = s.chars().allMatch(Character::isDigit); + if (allDigits) { + return s; + } else { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + } + + // Tries to convert a list qual to a JQL string using IN or NOT IN. + private Optional maybeInCheck(ListQual qual) { + List rawValues = qual.getValuesList(); + // If some values are null, we can't support this + if (rawValues.stream().anyMatch(Value::hasNull)) { + return Optional.empty(); + } + List values = new ArrayList<>(); + for (Value rawValue : rawValues) { + values.add(mapValue(rawValue)); + } + Operator rawOperator = qual.getOperator(); + boolean isAny = qual.getIsAny(); + if (rawOperator.hasEquals() && isAny) { + // col EQUAL to ANY value IN (1,2,3) ==> IN + StringJoiner joiner = new StringJoiner(", ", "IN (", ")"); + values.forEach(joiner::add); + return Optional.of(joiner.toString()); + } else { + if (rawOperator.hasNotEquals() && !isAny) { + // col NOT EQUAL to ALL values IN (1,2,3) ==> NOT IN + StringJoiner joiner = new StringJoiner(", ", "NOT IN (", ")"); + values.forEach(joiner::add); + return Optional.of(joiner.toString()); + } else { + // We don't support other cases (ANY > 1 value, ALL > 1 value, etc.) + return Optional.empty(); } - default -> throw new IllegalArgumentException("Unexpected value: " + value); - }; - return "\"" + s + "\""; + } } - // The exposed column name needs to be mapped to the corresponding inner JQL key. - // JIRA represents several fields as JSON objects, and we often expose multiple - // nested fields from these objects as separate columns in the table output. - // These columns are named using the pattern: original key + "_" + nested key. - // From this naming convention, the JQL key can typically be inferred by taking - // the first part of the column name. - private static String getIssueJqlKey(String columnName) { - // However, there are exceptions, such as 'status_category', which should not - // be mapped to the JQL 'status' field, but to `statusCategory`. Exceptions are - // found in jqlKeyMap. - if (jqlKeyMap.containsKey(columnName)) { - return jqlKeyMap.get(columnName); + // Tries to convert an operator to a JQL string using IS NULL or IS NOT NULL. Basically only + // equals and not equals are supported. + private Optional maybeNullCheck(Operator rawOperator) { + if (rawOperator.hasEquals()) { + return Optional.of("IS NULL"); + } else if (rawOperator.hasNotEquals()) { + return Optional.of("IS NOT NULL"); + } else { + return Optional.empty(); } - return columnName.split("_")[0].toLowerCase(); } - private static final Map jqlKeyMap = - Map.of("status_category", "statusCategory", "epic_key", "parentEpic"); + // Tries to convert a list of quals to a list of JQL optional strings. If the qual is not + // supported, the optional is empty. Returning a list of optionals allows us to know if all quals + // are supported or not, which impacts whether or not one pushes LIMIT after. + public List> mkJql(List quals) { + List> jqls = new ArrayList<>(); + for (Qual qual : quals) { + String rawColumn = qual.getFieldName(); + if (rawColumn.equals("id") || rawColumn.equals("key") || rawColumn.equals("title")) { + if (qual.hasSimpleQual()) { + Value rawValue = qual.getSimpleQual().getValue(); + // All simple comparisons are supported but not IS NULL OR IS NOT NULL + if (rawValue.hasNull()) { + jqls.add(Optional.empty()); + continue; + } + Operator rawOperator = qual.getSimpleQual().getOperator(); + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "issueKey" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "issueKey " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("self")) { + // Not supported + jqls.add(Optional.empty()); + } else if (rawColumn.equals("project_key") + || rawColumn.equals("project_id") + || rawColumn.equals("project_name")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "project " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "project" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "project " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("status")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "status " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "status" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "status " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("status_category")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "statusCategory " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "statusCategory" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "statusCategory " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("epic_key")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // We have to filter on the parent field. + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "parent " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "parent" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "parent " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("sprint_ids") || rawColumn.equals("sprint_names")) { + // Not supported because the column is a list of values, and no predicate applies to that. + jqls.add(Optional.empty()); + } else if (rawColumn.equals("assignee_account_id") + || rawColumn.equals("assignee_email_address") + || rawColumn.equals("assignee_display_name")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "assignee " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "assignee" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "assignee " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("creator_account_id") + || rawColumn.equals("creator_email_address") + || rawColumn.equals("creator_display_name")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "creator " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "creator" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "creator " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("created")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // All are supported. IS NULL and IS NOT NULL are supported. + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "created " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "created" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "created " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("due_date")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // All are supported. IS NULL and IS NOT NULL are supported. + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "due " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "due" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "due " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("description")) { + // JSONB, not supported + jqls.add(Optional.empty()); + } else if (rawColumn.equals("type")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + // IN is supported + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "type " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "type" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "type " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("labels")) { + // List, not supported + jqls.add(Optional.empty()); + } else if (rawColumn.equals("priority")) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others because they aren't regular string comparisons. + // IS NULL and IS NOT NULL are supported. + // IN is supported + if (qual.hasSimpleQual()) { + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "priority " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "priority" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "priority " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("reporter_account_id") + || rawColumn.equals("reporter_email_address") + || rawColumn.equals("reporter_display_name")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // != and == are supported, not others. IS NULL and IS NOT NULL are supported. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "reporter " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "reporter" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "reporter " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("summary")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // Only IS NULL and IS NOT NULL are supported. But we support = and != with ~ and !~ + // with Postgres applying the operator eventually. + if (!rawOperator.hasEquals() && !rawOperator.hasNotEquals()) { + jqls.add(Optional.empty()); + continue; + } + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "summary " + p)); + } else if (rawValue.hasString()) { + String value = rawValue.getString().getV(); + String escaped = value.replace("\"", "\\\""); + if (rawOperator.hasEquals()) { + String jql = "summary ~ \"" + escaped + "\""; + jqls.add(Optional.of(jql)); + } else { + String jql = "summary !~ \"" + escaped + "\""; + jqls.add(Optional.of(jql)); + } + } + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("updated")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // All are supported. IS NULL and IS NOT NULL are supported. + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "updated " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "updated" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "updated " + s)); + } else { + jqls.add(Optional.empty()); + } + } else if (rawColumn.equals("resolution_date")) { + if (qual.hasSimpleQual()) { + Operator rawOperator = qual.getSimpleQual().getOperator(); + // All are supported. IS NULL and IS NOT NULL are supported. + Value rawValue = qual.getSimpleQual().getValue(); + if (rawValue.hasNull()) { + jqls.add(maybeNullCheck(rawOperator).map(p -> "resolved " + p)); + continue; + } + String operator = mapOperator(rawOperator); + String value = mapValue(rawValue); + String jql = "resolved" + " " + operator + " " + value; + jqls.add(Optional.of(jql)); + } else if (qual.hasListQual()) { + // IN and NOT IN are supported + Optional maybeIn = maybeInCheck(qual.getListQual()); + jqls.add(maybeIn.map(s -> "resolved " + s)); + } + } else if (rawColumn.equals("components")) { + // Not supported (it's a list) + jqls.add(Optional.empty()); + } else if (rawColumn.equals("fields")) { + // Not supported (JSONB) + jqls.add(Optional.empty()); + } else if (rawColumn.equals("tags")) { + // Not supported (JSONB) + jqls.add(Optional.empty()); + } else { + jqls.add(Optional.empty()); + } + } + return jqls; + } - public static String buildJqlQuery(List quals) { + public String buildJqlQuery(List quals) { StringBuilder jqlQuery = new StringBuilder(); StringJoiner joiner = new StringJoiner(" AND "); - quals.stream() - .filter(Qual::hasSimpleQual) - .forEach( - q -> { - String column = getIssueJqlKey(q.getFieldName()); - String operator = mapOperator(q.getSimpleQual().getOperator()); - String value = mapValue(q.getSimpleQual().getValue()); - joiner.add(column + " " + operator + " " + value); - }); + List> jqls = mkJql(quals); + for (Optional jql : jqls) { + jql.ifPresent(joiner::add); + } jqlQuery.append(joiner); - return jqlQuery.toString(); + String jql = jqlQuery.toString(); + return jql; } } diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTable.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTable.java index 814dad9..3df7608 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTable.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTable.java @@ -57,15 +57,15 @@ public RowsEstimation getRelSize(List quals, List columns) { protected void addToRow( String columnName, Row.Builder rowBuilder, Object value, List columns) { if (hasProjection(columns, columnName)) { + ColumnDefinition definition = columnDefinitions.get(columnName); rowBuilder.putData( - columnName, - valueFactory.createValue( - new ValueTypeTuple(value, columnDefinitions.get(columnName).getType()))); + columnName, valueFactory.createValue(new ValueTypeTuple(value, definition.getType()))); } } private boolean hasProjection(List columns, String columnName) { - return columns == null || columns.isEmpty() || columns.contains(columnName); + return columnDefinitions.containsKey(columnName) + && (columns == null || columns.isEmpty() || columns.contains(columnName)); } protected abstract LinkedHashMap buildColumnDefinitions(); diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTableManager.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTableManager.java index c434beb..2b42cc4 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTableManager.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/DASJiraTableManager.java @@ -1,5 +1,6 @@ package com.rawlabs.das.jira.tables; +import com.rawlabs.das.jira.rest.platform.ApiException; import com.rawlabs.das.jira.rest.platform.api.*; import com.rawlabs.das.jira.rest.software.api.BoardApi; import com.rawlabs.das.jira.rest.software.api.EpicApi; @@ -8,8 +9,10 @@ import com.rawlabs.das.jira.tables.definitions.*; import com.rawlabs.das.jira.tables.definitions.DASJiraAdvancedSettingsTable; import com.rawlabs.das.jira.tables.definitions.DASJiraBoardTable; +import com.rawlabs.das.sdk.java.exceptions.DASSdkApiException; import com.rawlabs.protocol.das.TableDefinition; +import java.time.ZoneId; import java.util.List; import java.util.Map; @@ -21,6 +24,14 @@ public DASJiraTableManager( Map options, com.rawlabs.das.jira.rest.platform.ApiClient apiClientPlatform, com.rawlabs.das.jira.rest.software.ApiClient apiClientSoftware) { + MyselfApi myselfApi = new MyselfApi(apiClientPlatform); + final String zoneName; + try { + zoneName = myselfApi.getCurrentUser("").getTimeZone(); + } catch (ApiException e) { + throw new DASSdkApiException("Error getting user timezone", e); + } + ZoneId zoneId = ZoneId.of(zoneName); tables = List.of( new DASJiraAdvancedSettingsTable(options, new JiraSettingsApi(apiClientPlatform)), @@ -36,14 +47,16 @@ public DASJiraTableManager( new DASJiraGroupTable(options, new GroupsApi(apiClientPlatform)), new DASJiraIssueCommentTable( options, + zoneId, new IssueCommentsApi(apiClientPlatform), new IssueSearchApi(apiClientPlatform), new IssuesApi(apiClientPlatform)), new DASJiraIssueTable( - options, new IssueSearchApi(apiClientPlatform), new IssuesApi(apiClientPlatform)), + options, zoneId, new IssueSearchApi(apiClientPlatform), new IssuesApi(apiClientPlatform)), new DASJiraIssueTypeTable(options, new IssueTypesApi(apiClientPlatform)), new DASJiraIssueWorklogTable( options, + zoneId, new IssueWorklogsApi(apiClientPlatform), new IssueSearchApi(apiClientPlatform), new IssuesApi(apiClientPlatform)), diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueCommentTable.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueCommentTable.java index bc0714b..afb94ae 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueCommentTable.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueCommentTable.java @@ -18,6 +18,7 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.time.ZoneId; import java.util.*; import static com.rawlabs.das.sdk.java.utils.factory.table.ColumnFactory.createColumn; @@ -33,12 +34,13 @@ public class DASJiraIssueCommentTable extends DASJiraTable { public DASJiraIssueCommentTable( Map options, + ZoneId zoneId, IssueCommentsApi issueCommentsApi, IssueSearchApi issueSearchApi, IssuesApi issuesApi) { super(options, TABLE_NAME, "Comments that provided in issue."); this.issueCommentsApi = issueCommentsApi; - this.parentTable = new DASJiraIssueTable(options, issueSearchApi, issuesApi); + this.parentTable = new DASJiraIssueTable(options, zoneId, issueSearchApi, issuesApi); } @Override diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueTable.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueTable.java index 03b1a0d..f05971b 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueTable.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueTable.java @@ -3,6 +3,7 @@ import com.rawlabs.das.jira.rest.platform.ApiException; import com.rawlabs.das.jira.rest.platform.api.IssueSearchApi; import com.rawlabs.das.jira.rest.platform.api.IssuesApi; +import com.rawlabs.das.jira.rest.platform.api.MyselfApi; import com.rawlabs.das.jira.rest.platform.model.IssueBean; import com.rawlabs.das.jira.rest.platform.model.IssueUpdateDetails; import com.rawlabs.das.jira.tables.DASJiraIssueTransformationTable; @@ -15,6 +16,7 @@ import com.rawlabs.protocol.raw.Value; import org.jetbrains.annotations.Nullable; +import java.time.ZoneId; import java.util.*; import static com.rawlabs.das.sdk.java.utils.factory.table.ColumnFactory.createColumn; @@ -25,13 +27,15 @@ public class DASJiraIssueTable extends DASJiraIssueTransformationTable { public static final String TABLE_NAME = "jira_issue"; private final IssueSearchApi issueSearchApi; private final IssuesApi issuesApi; + private final ZoneId zoneId; public DASJiraIssueTable( - Map options, IssueSearchApi issueSearchApi, IssuesApi issueApi) { + Map options, ZoneId zoneId, IssueSearchApi issueSearchApi, IssuesApi issueApi) { super( options, TABLE_NAME, "Issues help manage code, estimate workload, and keep track of team."); this.issueSearchApi = issueSearchApi; this.issuesApi = issueApi; + this.zoneId = zoneId; } @Override @@ -85,7 +89,7 @@ public Row next() { @Override public DASJiraPage fetchPage(long offset) { try { - String jql = DASJiraJqlQueryBuilder.buildJqlQuery(quals); + String jql = new DASJiraJqlQueryBuilder(zoneId).buildJqlQuery(quals); var result = issueSearchApi.searchForIssuesUsingJql( jql, @@ -255,7 +259,7 @@ protected LinkedHashMap buildColumnDefinitions() { createColumn( "due_date", "Time by which the issue is expected to be completed", - createTimestampType())); + createDateType())); columns.put( "description", createColumn("description", "Description of the issue", createAnyType())); columns.put("type", createColumn("type", "The name of the issue type", createStringType())); diff --git a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueWorklogTable.java b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueWorklogTable.java index dac53a5..e5c7401 100644 --- a/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueWorklogTable.java +++ b/das-jira-connector/src/main/java/com/rawlabs/das/jira/tables/definitions/DASJiraIssueWorklogTable.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.time.ZoneId; import java.util.*; import static com.rawlabs.das.sdk.java.utils.factory.table.ColumnFactory.createColumn; @@ -39,6 +40,7 @@ public class DASJiraIssueWorklogTable extends DASJiraTable { public DASJiraIssueWorklogTable( Map options, + ZoneId zoneId, IssueWorklogsApi issueWorklogsApi, IssueSearchApi issueSearchApi, IssuesApi issuesApi) { @@ -47,7 +49,7 @@ public DASJiraIssueWorklogTable( TABLE_NAME, "Jira worklog is a feature within the Jira software that allows users to record the amount of time they have spent working on various tasks or issues."); this.issueWorklogsApi = issueWorklogsApi; - parentTable = new DASJiraIssueTable(options, issueSearchApi, issuesApi); + parentTable = new DASJiraIssueTable(options, zoneId, issueSearchApi, issuesApi); } @Override diff --git a/das-jira-connector/src/test/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilderTest.java b/das-jira-connector/src/test/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilderTest.java index 0b549c0..2ae5b1c 100644 --- a/das-jira-connector/src/test/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilderTest.java +++ b/das-jira-connector/src/test/java/com/rawlabs/das/jira/tables/DASJiraJqlQueryBuilderTest.java @@ -1,85 +1,1188 @@ package com.rawlabs.das.jira.tables; -import com.rawlabs.das.sdk.java.utils.factory.value.DefaultValueFactory; -import com.rawlabs.das.sdk.java.utils.factory.value.ValueFactory; -import com.rawlabs.das.sdk.java.utils.factory.value.ValueTypeTuple; -import com.rawlabs.protocol.das.Equals; -import com.rawlabs.protocol.das.GreaterThanOrEqual; -import com.rawlabs.protocol.das.Operator; +import com.rawlabs.protocol.das.*; +import com.rawlabs.protocol.raw.Value; +import com.rawlabs.protocol.raw.ValueNull; +import com.rawlabs.protocol.raw.ValueString; +import com.rawlabs.protocol.raw.ValueTimestamp; +import com.rawlabs.protocol.raw.ValueDate; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.ZoneId; import java.util.List; +import java.util.Optional; -import static com.rawlabs.das.sdk.java.utils.factory.qual.QualFactory.*; -import static com.rawlabs.das.sdk.java.utils.factory.type.TypeFactory.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; -@DisplayName("Jira JQL Query Builder") +/** + * Comprehensive coverage for Qual across all columns that DASJiraJqlQueryBuilder supports. + */ +@DisplayName("DASJiraJqlQueryBuilder - All SimpleQual Tests") public class DASJiraJqlQueryBuilderTest { - private final ValueFactory valueFactory = new DefaultValueFactory(); - - @Test - @DisplayName("Should map only string or temporal values") - public void shouldMapValues() { - - var string = - DASJiraJqlQueryBuilder.mapValue( - valueFactory.createValue(new ValueTypeTuple("DAS", createStringType()))); - - var timestamp = - DASJiraJqlQueryBuilder.mapValue( - valueFactory.createValue( - new ValueTypeTuple("2021-01-01T00:00:00Z", createTimestampType()))); - - assertEquals("\"DAS\"", string); - assertEquals("\"2021-01-01 00:00\"", timestamp); - assertThrows( - IllegalArgumentException.class, - () -> - DASJiraJqlQueryBuilder.mapValue( - valueFactory.createValue(new ValueTypeTuple(1, createIntType())))); - } - - @Test - @DisplayName("Should map operators to JQL") - public void shouldMapOperators() { - - var geq = - DASJiraJqlQueryBuilder.mapOperator( - Operator.newBuilder() - .setGreaterThanOrEqual(GreaterThanOrEqual.newBuilder().build()) - .build()); - - var eq = - DASJiraJqlQueryBuilder.mapOperator( - Operator.newBuilder().setEquals(Equals.newBuilder().build()).build()); - - assertEquals(">=", geq); - assertEquals("=", eq); - } - - @Test - @DisplayName("Should generate proper JQL queries") - public void shouldGenerateJqlQuery() { - String result = - DASJiraJqlQueryBuilder.buildJqlQuery( - List.of( - createEq( - valueFactory.createValue(new ValueTypeTuple("DAS", createStringType())), - "summary"), - createGte( - valueFactory.createValue( - new ValueTypeTuple("2021-01-01T00:00:00Z", createTimestampType())), - "created_date"), - createLte( - valueFactory.createValue( - new ValueTypeTuple("2021-01-01T00:00:00Z", createTimestampType())), - "due_date"))); - - assertEquals( - "summary = \"DAS\" AND created >= \"2021-01-01 00:00\" AND due <= \"2021-01-01 00:00\"", result); - } + private final DASJiraJqlQueryBuilder jqlBuilder = new DASJiraJqlQueryBuilder(ZoneId.of("UTC")); + + // ------------------------------------------------------------------------ + // Helper methods + // ------------------------------------------------------------------------ + + private Value stringValue(String s) { + return Value.newBuilder().setString(ValueString.newBuilder().setV(s)).build(); + } + + private Value nullValue() { + return Value.newBuilder().setNull(ValueNull.getDefaultInstance()).build(); + } + + private Value timestampValue(int year, int month, int day, int hour, int min, int sec, int nano) { + return Value.newBuilder() + .setTimestamp( + ValueTimestamp.newBuilder() + .setYear(year).setMonth(month).setDay(day) + .setHour(hour).setMinute(min).setSecond(sec).setNano(nano) + ).build(); + } + + private Value dateValue(int year, int month, int day) { + return Value.newBuilder() + .setDate( + ValueDate.newBuilder() + .setYear(year).setMonth(month).setDay(day) + ).build(); + } + + private Operator eqOperator() { + return Operator.newBuilder().setEquals(Equals.newBuilder().build()).build(); + } + + private Operator neOperator() { + return Operator.newBuilder().setNotEquals(NotEquals.newBuilder().build()).build(); + } + + private Operator gtOperator() { + return Operator.newBuilder().setGreaterThan(GreaterThan.newBuilder().build()).build(); + } + + private Operator gteOperator() { + return Operator.newBuilder().setGreaterThanOrEqual(GreaterThanOrEqual.newBuilder().build()).build(); + } + + private Operator ltOperator() { + return Operator.newBuilder().setLessThan(LessThan.newBuilder().build()).build(); + } + + private Operator lteOperator() { + return Operator.newBuilder().setLessThanOrEqual(LessThanOrEqual.newBuilder().build()).build(); + } + + private Qual simpleQual(String column, Operator op, Value val) { + return Qual.newBuilder() + .setFieldName(column) + .setSimpleQual( + SimpleQual.newBuilder() + .setOperator(op) + .setValue(val) + .build()) + .build(); + } + + /** + * Creates a ListQual for (column operator [values]) with isAny controlling IN or NOT IN usage: + * - eq + isAny=true => IN(...) + * - ne + isAny=false => NOT IN(...) + */ + private Qual listQual(String column, Operator op, boolean isAny, List values) { + return Qual.newBuilder() + .setFieldName(column) + .setListQual( + ListQual.newBuilder() + .setOperator(op) + .setIsAny(isAny) + .addAllValues(values) + .build()) + .build(); + } + + + // -------------------------------------------------------------------------- + // Columns: "id", "key", "title" => all turn into JQL field "issueKey" + // - All comparisons are supported: =, !=, <, <=, >, >= + // - Null is not supported => mkJql returns Optional.empty() + // - All values are quoted except for numeric values + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Columns: id/key/title => issueKey (All comparisons supported, Null not supported, IN supported)") + class IssueKeyTests { + + @Test + @DisplayName("issueKey = 'ABC-123' => issueKey = \"ABC-123\"") + void testEq() { + Qual q = simpleQual("key", eqOperator(), stringValue("ABC-123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey = \"ABC-123\"", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey != 'XYZ-999' => issueKey != \"XYZ-999\"") + void testNe() { + Qual q = simpleQual("id", neOperator(), stringValue("XYZ-999")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey != \"XYZ-999\"", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey < 100 => issueKey < 100") + void testLt() { + Qual q = simpleQual("title", ltOperator(), stringValue("100")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey < 100", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey <= 'ABC-1' => issueKey <= \"ABC-1\"") + void testLte() { + Qual q = simpleQual("key", lteOperator(), stringValue("ABC-1")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey <= \"ABC-1\"", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey > 123 => issueKey > 123 (unquoted if numeric)") + void testGt() { + Qual q = simpleQual("id", gtOperator(), stringValue("123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey > 123", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey >= \"XYZ-123\" => issueKey >= \"XYZ-123\"") + void testGte() { + Qual q = simpleQual("title", gteOperator(), stringValue("XYZ-123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("issueKey >= \"XYZ-123\"", res.orElseThrow()); + } + + @Test + @DisplayName("issueKey = null => not supported => Optional.empty()") + void testNull() { + Qual q = simpleQual("key", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty(), "Should be Optional.empty() because null is not supported for key"); + } + + @Test + @DisplayName("issueKey IN(...) and NOT IN(...)") + public void testIssueKeyInNotIn() { + // eq + isAny = true => issueKey IN (...) + Qual inQual = listQual("key", eqOperator(), true, + List.of(stringValue("ABC-123"), stringValue("XYZ-888"))); + // ne + isAny = false => issueKey NOT IN (...) + Qual notInQual = listQual("title", neOperator(), false, + List.of(stringValue("K-999"), stringValue("T-111"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("issueKey IN (\"ABC-123\", \"XYZ-888\")", jqls.get(0).orElseThrow()); + assertEquals("issueKey NOT IN (\"K-999\", \"T-111\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Columns: "project_key", "project_id", "project_name" => "project" + // - Supported: =, !=, plus null checks => IS NULL, IS NOT NULL + // - Not supported: <, <=, >, >= => Optional.empty() + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Columns: project_key/id/name => project (==, !=, NULL checks supported, IN supported)") + class ProjectTests { + + @Test + @DisplayName("project_key = 'ABC' => project = \"ABC\" (or unquoted if numeric)") + void testEq() { + Qual q = simpleQual("project_key", eqOperator(), stringValue("ABC")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("project = \"ABC\"", res.orElseThrow()); + } + + @Test + @DisplayName("project_id != 123 => project != 123") + void testNe() { + Qual q = simpleQual("project_id", neOperator(), stringValue("123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("project != 123", res.orElseThrow()); + } + + @Test + @DisplayName("project_name = null => project IS NULL") + void testEqNull() { + Qual q = simpleQual("project_name", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("project IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("project_key != null => project IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("project_key", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("project IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("project_id < 'ABC' => not supported => Optional.empty()") + void testLt() { + Qual q = simpleQual("project_id", ltOperator(), stringValue("ABC")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("project IN(...) and NOT IN(...)") + public void testProjectInNotIn() { + // eq + isAny => project IN(...) + Qual inQual = listQual("project_key", eqOperator(), true, + List.of(stringValue("MYPROJ"), stringValue("1234"))); + // ne + !isAny => project NOT IN(...) + Qual notInQual = listQual("project_id", neOperator(), false, + List.of(stringValue("X"), stringValue("999"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("project IN (\"MYPROJ\", 1234)", jqls.get(0).orElseThrow()); + assertEquals("project NOT IN (\"X\", 999)", jqls.get(1).orElseThrow()); + } + + } + + // -------------------------------------------------------------------------- + // Column: "status" + // - eq, ne supported, plus null checks => IS NULL, IS NOT NULL + // - <, <=, >, >= => not supported => Optional.empty() + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: status => eq, ne, null checks only, IN supported") + class StatusTests { + + @Test + @DisplayName("status = 'Open' => status = \"Open\"") + void testEq() { + Qual q = simpleQual("status", eqOperator(), stringValue("Open")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("status = \"Open\"", res.orElseThrow()); + } + + @Test + @DisplayName("status != 'Closed' => status != \"Closed\"") + void testNe() { + Qual q = simpleQual("status", neOperator(), stringValue("Closed")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("status != \"Closed\"", res.orElseThrow()); + } + + @Test + @DisplayName("status = null => status IS NULL") + void testEqNull() { + Qual q = simpleQual("status", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("status IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("status != null => status IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("status", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("status IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("status < 'X' => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("status", ltOperator(), stringValue("X")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("status IN(...) and NOT IN(...)") + public void testStatusInNotIn() { + // eq + isAny => status IN(...) + Qual inQual = listQual("status", eqOperator(), true, + List.of(stringValue("Open"), stringValue("In Progress"))); + + // ne + !isAny => status NOT IN(...) + Qual notInQual = listQual("status", neOperator(), false, + List.of(stringValue("Closed"), stringValue("Resolved"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("status IN (\"Open\", \"In Progress\")", jqls.get(0).orElseThrow()); + assertEquals("status NOT IN (\"Closed\", \"Resolved\")", jqls.get(1).orElseThrow()); + } + + } + + // -------------------------------------------------------------------------- + // Column: "status_category" => statusCategory + // - eq, ne, null checks supported + // - <, <=, >, >= => not supported + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: status_category => eq, ne, null checks only, IN supported") + class StatusCategoryTests { + + @Test + @DisplayName("status_category = 'To Do' => statusCategory = \"To Do\"") + void testEq() { + Qual q = simpleQual("status_category", eqOperator(), stringValue("To Do")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("statusCategory = \"To Do\"", res.orElseThrow()); + } + + @Test + @DisplayName("status_category != 'Done' => statusCategory != \"Done\"") + void testNe() { + Qual q = simpleQual("status_category", neOperator(), stringValue("Done")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("statusCategory != \"Done\"", res.orElseThrow()); + } + + @Test + @DisplayName("status_category = null => statusCategory IS NULL") + void testEqNull() { + Qual q = simpleQual("status_category", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("statusCategory IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("status_category != null => statusCategory IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("status_category", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("statusCategory IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("status_category < 'X' => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("status_category", ltOperator(), stringValue("X")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("statusCategory IN(...) and NOT IN(...)") + public void testStatusCategoryInNotIn() { + Qual inQual = listQual("status_category", eqOperator(), true, + List.of(stringValue("To Do"), stringValue("In Progress"))); + Qual notInQual = listQual("status_category", neOperator(), false, + List.of(stringValue("Done"), stringValue("Closed"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("statusCategory IN (\"To Do\", \"In Progress\")", jqls.get(0).orElseThrow()); + assertEquals("statusCategory NOT IN (\"Done\", \"Closed\")", jqls.get(1).orElseThrow()); + } + + } + + // -------------------------------------------------------------------------- + // Column: "epic_key" => parent + // - eq, ne, null checks => supported + // - <, <=, >, >= => not supported + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: epic_key => parent => eq, ne, null checks") + class EpicKeyTests { + + @Test + @DisplayName("epic_key = 'ABC-123' => parent = \"ABC-123\"") + void testEq() { + Qual q = simpleQual("epic_key", eqOperator(), stringValue("ABC-123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("parent = \"ABC-123\"", res.orElseThrow()); + } + + @Test + @DisplayName("epic_key != 'XYZ' => parent != \"XYZ\"") + void testNe() { + Qual q = simpleQual("epic_key", neOperator(), stringValue("XYZ")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("parent != \"XYZ\"", res.orElseThrow()); + } + + @Test + @DisplayName("epic_key = null => parent IS NULL") + void testEqNull() { + Qual q = simpleQual("epic_key", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("parent IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("epic_key != null => parent IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("epic_key", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("parent IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("epic_key < 'xx' => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("epic_key", ltOperator(), stringValue("xx")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("epic_key IN(...) and NOT IN(...) => parent IN/NOT IN(...)") + public void testEpicKeyInNotIn() { + Qual inQual = listQual("epic_key", eqOperator(), true, + List.of(stringValue("E-1"), stringValue("E-2"))); + Qual notInQual = listQual("epic_key", neOperator(), false, + List.of(stringValue("E-99"), stringValue("E-100"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("parent IN (\"E-1\", \"E-2\")", jqls.get(0).orElseThrow()); + assertEquals("parent NOT IN (\"E-99\", \"E-100\")", jqls.get(1).orElseThrow()); + } + + } + + // -------------------------------------------------------------------------- + // Columns: assignee_* => "assignee" + // eq, ne, null checks supported + // <, <=, >, >= => not supported + // IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Columns: assignee_* => assignee => eq, ne, null checks only, IN supported") + class AssigneeTests { + + @Test + @DisplayName("assignee_email_address = 'bob@example.com' => assignee = \"bob@example.com\"") + void testEq() { + Qual q = simpleQual("assignee_email_address", eqOperator(), stringValue("bob@example.com")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("assignee = \"bob@example.com\"", res.orElseThrow()); + } + + @Test + @DisplayName("assignee_account_id != '1234' => assignee != 1234") + void testNe() { + Qual q = simpleQual("assignee_account_id", neOperator(), stringValue("1234")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("assignee != 1234", res.orElseThrow()); + } + + @Test + @DisplayName("assignee_display_name = null => assignee IS NULL") + void testEqNull() { + Qual q = simpleQual("assignee_display_name", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("assignee IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("assignee_account_id != null => assignee IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("assignee_account_id", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("assignee IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("assignee_* < => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("assignee_email_address", ltOperator(), stringValue("zzz")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("assignee IN(...) and NOT IN(...)") + public void testAssigneeInNotIn() { + Qual inQual = listQual("assignee_display_name", eqOperator(), true, + List.of(stringValue("Alice"), stringValue("Bob"))); + Qual notInQual = listQual("assignee_email_address", neOperator(), false, + List.of(stringValue("charlie@example.com"), stringValue("david@example.com"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("assignee IN (\"Alice\", \"Bob\")", jqls.get(0).orElseThrow()); + assertEquals("assignee NOT IN (\"charlie@example.com\", \"david@example.com\")", jqls.get(1).orElseThrow()); + } + + } + + // -------------------------------------------------------------------------- + // Columns: creator_* => "creator" + // eq, ne, null checks supported + // <, <=, >, >= => not supported + // IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Columns: creator_* => creator => eq, ne, null checks only, IN supported") + class CreatorTests { + + @Test + @DisplayName("creator_email_address = 'alice@raw-labs.com' => creator = \"alice@raw-labs.com\"") + void testEq() { + Qual q = simpleQual("creator_email_address", eqOperator(), stringValue("alice@raw-labs.com")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("creator = \"alice@raw-labs.com\"", res.orElseThrow()); + } + + @Test + @DisplayName("creator_account_id != 'abcd' => creator != \"abcd\" (or numeric unquoted)") + void testNe() { + Qual q = simpleQual("creator_account_id", neOperator(), stringValue("abcd")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("creator != \"abcd\"", res.orElseThrow()); + } + + @Test + @DisplayName("creator_display_name = null => creator IS NULL") + void testEqNull() { + Qual q = simpleQual("creator_display_name", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("creator IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("creator_email_address != null => creator IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("creator_email_address", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("creator IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("creator_* < => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("creator_account_id", ltOperator(), stringValue("1234")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("creator IN(...) and NOT IN(...)") + public void testCreatorInNotIn() { + Qual inQual = listQual("creator_email_address", eqOperator(), true, + List.of(stringValue("alice@foo.com"), stringValue("bob@foo.com"))); + Qual notInQual = listQual("creator_account_id", neOperator(), false, + List.of(stringValue("1234"), stringValue("5678"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("creator IN (\"alice@foo.com\", \"bob@foo.com\")", jqls.get(0).orElseThrow()); + // numeric => unquoted + assertEquals("creator NOT IN (1234, 5678)", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Columns: reporter_* => "reporter" + // eq, ne, null checks supported + // <, <=, >, >= => not supported + // IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Columns: reporter_* => reporter => eq, ne, null checks only, IN supported") + class ReporterTests { + + @Test + @DisplayName("reporter_display_name = 'John Doe' => reporter = \"John Doe\"") + void testEq() { + Qual q = simpleQual("reporter_display_name", eqOperator(), stringValue("John Doe")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("reporter = \"John Doe\"", res.orElseThrow()); + } + + @Test + @DisplayName("reporter_display_name != 'John Doe' => reporter != \"John Doe\"") + void testNe() { + Qual q = simpleQual("reporter_display_name", neOperator(), stringValue("John Doe")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("reporter != \"John Doe\"", res.orElseThrow()); + } + + @Test + @DisplayName("reporter_account_id = null => reporter IS NULL") + void testEqNull() { + Qual q = simpleQual("reporter_account_id", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("reporter IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("reporter_display_name != null => reporter IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("reporter_display_name", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("reporter IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("reporter_* < => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("reporter_display_name", ltOperator(), stringValue("Z")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("reporter IN(...) and NOT IN(...)") + public void testReporterInNotIn() { + Qual inQual = listQual("reporter_display_name", eqOperator(), true, + List.of(stringValue("Tom"), stringValue("Jerry"))); + Qual notInQual = listQual("reporter_account_id", neOperator(), false, + List.of(stringValue("1234"), stringValue("1235"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("reporter IN (\"Tom\", \"Jerry\")", jqls.get(0).orElseThrow()); + assertEquals("reporter NOT IN (1234, 1235)", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: created => "created" + // - All comparisons supported: =, !=, <, <=, >, >= + // - Null => created IS NULL, created IS NOT NULL + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: created => all comparisons, null checks") + class CreatedTests { + + @Test + @DisplayName("created = '2024-01-01T10:00:00Z' => created = \"2024-01-01 10:00\"") + void testEq() { + Qual q = simpleQual("created", eqOperator(), timestampValue(2024,1,1,10,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created = \"2024-01-01 10:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("created != '2024-02-02' => created != \"2024-02-02\" (Date => yyyy-MM-dd)") + void testNe() { + Qual q = simpleQual("created", neOperator(), dateValue(2024,2,2)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created != \"2024-02-02\"", res.orElseThrow()); + } + + @Test + @DisplayName("created < '2023-12-31T23:00' => created < \"2023-12-31 23:00\"") + void testLt() { + Qual q = simpleQual("created", ltOperator(), timestampValue(2023,12,31,23,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created < \"2023-12-31 23:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("created <= '2024-05-01T10:00' => created <= \"2024-05-01 10:00\"") + void testLte() { + Qual q = simpleQual("created", lteOperator(), timestampValue(2024,5,1,10,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created <= \"2024-05-01 10:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("created > '2023-10-10T15:30' => created > \"2023-10-10 15:30\"") + void testGt() { + Qual q = simpleQual("created", gtOperator(), timestampValue(2023,10,10,15,30,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created > \"2023-10-10 15:30\"", res.orElseThrow()); + } + + @Test + @DisplayName("created >= '2023-01-01' => created >= \"2023-01-01\"") + void testGte() { + Qual q = simpleQual("created", gteOperator(), dateValue(2023,1,1)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created >= \"2023-01-01\"", res.orElseThrow()); + } + + @Test + @DisplayName("created = null => created IS NULL") + void testEqNull() { + Qual q = simpleQual("created", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("created != null => created IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("created", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("created IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("created IN(...) and NOT IN(...) for timestamps/dates") + public void testCreatedInNotIn() { + // eq + isAny => created IN(...) + // For demonstration, mix a timestampValue and a dateValue + Qual inQual = listQual("created", eqOperator(), true, + List.of( + timestampValue(2024,1,1,10,0,0,0), + dateValue(2024,2,5) // code will produce "2024-02-05" + )); + // ne + !isAny => created NOT IN(...) + Qual notInQual = listQual("created", neOperator(), false, + List.of( + timestampValue(2023,12,31,23,0,0,0), + timestampValue(2024,6,10,12,30,0,0) + )); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + // eq + isAny => created IN("2024-01-01 10:00", "2024-02-05") + assertEquals("created IN (\"2024-01-01 10:00\", \"2024-02-05\")", jqls.get(0).orElseThrow()); + // ne + !isAny => created NOT IN("2023-12-31 23:00", "2024-06-10 12:30") + assertEquals("created NOT IN (\"2023-12-31 23:00\", \"2024-06-10 12:30\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: due_date => "due" + // - All comparisons, plus null checks + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: due_date => due => all comparisons, null checks") + class DueDateTests { + + @Test + @DisplayName("due_date = null => due IS NULL") + void testEqNull() { + Qual q = simpleQual("due_date", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("due IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("due_date != null => due IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("due_date", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("due IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("due_date > '2024-10-01T10:00' => due > \"2024-10-01 10:00\"") + void testGt() { + Qual q = simpleQual("due_date", gtOperator(), timestampValue(2024,10,1,10,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("due > \"2024-10-01 10:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("due_date IN(...) / NOT IN(...)") + public void testDueDateInNotIn() { + Qual inQual = listQual("due_date", eqOperator(), true, + List.of( + dateValue(2025,3,10), + timestampValue(2025,3,11,8,0,0,0) + )); + Qual notInQual = listQual("due_date", neOperator(), false, + List.of( + timestampValue(2025,1,1,12,0,0,0), + timestampValue(2025,1,2,12,0,0,0) + )); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("due IN (\"2025-03-10\", \"2025-03-11 08:00\")", jqls.get(0).orElseThrow()); + assertEquals("due NOT IN (\"2025-01-01 12:00\", \"2025-01-02 12:00\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: due_date => "due" + // - All comparisons, plus null checks + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: updated => updated => all comparisons, null checks") + class UpdatedDateTests { + + @Test + @DisplayName("updated = null => updated IS NULL") + void testEqNull() { + Qual q = simpleQual("updated", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("updated IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("updated != null => updated IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("updated", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("updated IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("updated > '2024-10-01T10:00' => updated > \"2024-10-01 10:00\"") + void testGt() { + Qual q = simpleQual("updated", gtOperator(), timestampValue(2024,10,1,10,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("updated > \"2024-10-01 10:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("updated IN(...) / NOT IN(...)") + public void testupdatedDateInNotIn() { + Qual inQual = listQual("updated", eqOperator(), true, + List.of( + dateValue(2025,3,10), + timestampValue(2025,3,11,8,0,0,0) + )); + Qual notInQual = listQual("updated", neOperator(), false, + List.of( + timestampValue(2025,1,1,12,0,0,0), + timestampValue(2025,1,2,12,0,0,0) + )); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("updated IN (\"2025-03-10\", \"2025-03-11 08:00\")", jqls.get(0).orElseThrow()); + assertEquals("updated NOT IN (\"2025-01-01 12:00\", \"2025-01-02 12:00\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: resolution_date => "resolved" + // - All comparisons, plus null checks + // - IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: resolution_date => resolved => all comparisons, null checks") + class ResolutionDateTests { + @Test + @DisplayName("resolution_date = 2023-01-10T12:00 => resolved = \"2023-01-10 12:00\"") + void testEq() { + Qual q = simpleQual("resolution_date", eqOperator(), timestampValue(2023,1,10,12,0,0,0)); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("resolved = \"2023-01-10 12:00\"", res.orElseThrow()); + } + + @Test + @DisplayName("resolution_date = null => resolved IS NULL") + void testEqNull() { + Qual q = simpleQual("resolution_date", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("resolved IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("resolution_date => resolved IN(...) / NOT IN(...)") + public void testResolutionDateInNotIn() { + // eq + isAny => resolved IN(...) + Qual inQual = listQual("resolution_date", eqOperator(), true, + List.of( + timestampValue(2024,1,1,10,0,0,0), + timestampValue(2024,2,10,15,30,0,0) + )); + // ne + !isAny => resolved NOT IN(...) + Qual notInQual = listQual("resolution_date", neOperator(), false, + List.of( + dateValue(2024,3,1), + timestampValue(2024,4,15,9,45,0,0) + )); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("resolved IN (\"2024-01-01 10:00\", \"2024-02-10 15:30\")", jqls.get(0).orElseThrow()); + assertEquals("resolved NOT IN (\"2024-03-01\", \"2024-04-15 09:45\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: summary => special eq => summary ~, ne => summary !~, null => IS/IS NOT NULL + // - No <, <=, >, >= + // - = is supported using ~ (contains) as an approximation + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: summary => eq => summary ~, ne => summary !~, null checks, others => empty") + class SummaryTests { + @Test + @DisplayName("summary = 'foo' => summary ~ \"foo\"") + void testEq() { + Qual q = simpleQual("summary", eqOperator(), stringValue("foo")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("summary ~ \"foo\"", res.orElseThrow()); + } + + @Test + @DisplayName("summary != 'bar' => summary !~ \"bar\"") + void testNe() { + Qual q = simpleQual("summary", neOperator(), stringValue("bar")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("summary !~ \"bar\"", res.orElseThrow()); + } + + @Test + @DisplayName("summary = null => summary IS NULL") + void testEqNull() { + Qual q = simpleQual("summary", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("summary IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("summary != null => summary IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("summary", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("summary IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("summary < 'x' => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("summary", ltOperator(), stringValue("x")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("summary IN(...) / NOT IN(...) not supported") + public void testSummaryInNotIn() { + // eq + isAny => resolved IN(...) + Qual inQual = listQual("summary", eqOperator(), true, + List.of( + stringValue("foo"), + stringValue("bar") + )); + // ne + !isAny => resolved NOT IN(...) + Qual notInQual = listQual("summary", neOperator(), false, + List.of( + stringValue("baz"), + stringValue("qux") + )); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assert(jqls.get(0).isEmpty()); + assert(jqls.get(1).isEmpty()); + } + + } + + // -------------------------------------------------------------------------- + // Column: type => eq, ne, null checks. < etc. => not supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: type => eq, ne, null checks only") + class TypeTests { + @Test + @DisplayName("type = 'Bug' => type = \"Bug\"") + void testEq() { + Qual q = simpleQual("type", eqOperator(), stringValue("Bug")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("type = \"Bug\"", res.orElseThrow()); + } + + @Test + @DisplayName("type != 'Task' => type != \"Task\"") + void testNe() { + Qual q = simpleQual("type", neOperator(), stringValue("Task")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("type != \"Task\"", res.orElseThrow()); + } + + @Test + @DisplayName("type = null => type IS NULL") + void testEqNull() { + Qual q = simpleQual("type", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("type IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("type != null => type IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("type", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("type IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("type < => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("type", ltOperator(), stringValue("X")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("type IN(...) and NOT IN(...)") + public void testTypeInNotIn() { + Qual inQual = listQual("type", eqOperator(), true, + List.of(stringValue("Bug"), stringValue("Task"))); + Qual notInQual = listQual("type", neOperator(), false, + List.of(stringValue("Story"), stringValue("Epic"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("type IN (\"Bug\", \"Task\")", jqls.get(0).orElseThrow()); + assertEquals("type NOT IN (\"Story\", \"Epic\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Column: priority => eq, ne, null checks only + // We don't support > and < because the priority values are not ordered like strings + // IN supported + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Column: priority => eq, ne, null checks only") + class PriorityTests { + @Test + @DisplayName("priority = 'High' => priority = \"High\"") + void testEq() { + Qual q = simpleQual("priority", eqOperator(), stringValue("High")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("priority = \"High\"", res.orElseThrow()); + } + + @Test + @DisplayName("priority != 'Low' => priority != \"Low\"") + void testNe() { + Qual q = simpleQual("priority", neOperator(), stringValue("Low")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("priority != \"Low\"", res.orElseThrow()); + } + + @Test + @DisplayName("priority = null => priority IS NULL") + void testEqNull() { + Qual q = simpleQual("priority", eqOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("priority IS NULL", res.orElseThrow()); + } + + @Test + @DisplayName("priority != null => priority IS NOT NULL") + void testNeNull() { + Qual q = simpleQual("priority", neOperator(), nullValue()); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertEquals("priority IS NOT NULL", res.orElseThrow()); + } + + @Test + @DisplayName("priority < => not supported => Optional.empty()") + void testUnsupportedLt() { + Qual q = simpleQual("priority", ltOperator(), stringValue("X")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("priority IN(...) and NOT IN(...)") + public void testPriorityInNotIn() { + Qual inQual = listQual("priority", eqOperator(), true, + List.of(stringValue("High"), stringValue("Medium"))); + Qual notInQual = listQual("priority", neOperator(), false, + List.of(stringValue("Low"), stringValue("Critical"))); + + var jqls = jqlBuilder.mkJql(List.of(inQual, notInQual)); + assertEquals("priority IN (\"High\", \"Medium\")", jqls.get(0).orElseThrow()); + assertEquals("priority NOT IN (\"Low\", \"Critical\")", jqls.get(1).orElseThrow()); + } + } + + // -------------------------------------------------------------------------- + // Columns: labels, components, fields, tags, sprint_ids, sprint_names, description, self + // => not supported => always Optional.empty() + // -------------------------------------------------------------------------- + @Nested + @DisplayName("Unsupported columns => always Optional.empty()") + class UnsupportedColumnsTests { + + @Test + @DisplayName("labels => Optional.empty()") + void testLabels() { + Qual q = simpleQual("labels", eqOperator(), stringValue("something")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("components => Optional.empty()") + void testComponents() { + Qual q = simpleQual("components", eqOperator(), stringValue("UI")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("fields => Optional.empty()") + void testFields() { + Qual q = simpleQual("fields", eqOperator(), stringValue("anything")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("tags => Optional.empty()") + void testTags() { + Qual q = simpleQual("tags", eqOperator(), stringValue("myTag")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("sprint_ids => Optional.empty()") + void testSprintIds() { + Qual q = simpleQual("sprint_ids", eqOperator(), stringValue("123")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("sprint_names => Optional.empty()") + void testSprintNames() { + Qual q = simpleQual("sprint_names", eqOperator(), stringValue("Sprint 1")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("description => Optional.empty()") + void testDescription() { + Qual q = simpleQual("description", eqOperator(), stringValue("some text")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + + @Test + @DisplayName("self => Optional.empty()") + void testSelf() { + Qual q = simpleQual("self", eqOperator(), stringValue("http://whatever")); + Optional res = jqlBuilder.mkJql(List.of(q)).get(0); + assertTrue(res.isEmpty()); + } + } + + // -------------------------------------------------------------------------- + // Combined usage example + // -------------------------------------------------------------------------- + @Test + @DisplayName("Combined usage => multiple columns => buildJqlQuery joined by AND") + void testCombinedBuild() { + // 1) key < "ABC-123" => issueKey < "ABC-123" + Qual issueKeyLt = simpleQual("key", ltOperator(), stringValue("ABC-123")); + // 2) project_name != null => project IS NOT NULL + Qual projectNeNull = simpleQual("project_name", neOperator(), nullValue()); + // 3) status = 'Open' => status = "Open" + Qual statusEq = simpleQual("status", eqOperator(), stringValue("Open")); + // 4) labels => unsupported => omitted + + String jql = jqlBuilder.buildJqlQuery(List.of(issueKeyLt, projectNeNull, statusEq, + simpleQual("labels", eqOperator(), stringValue("something")))); + + // Expect: "issueKey < \"ABC-123\" AND project IS NOT NULL AND status = \"Open\"" + // (labels is dropped) + String expected = "issueKey < \"ABC-123\" AND project IS NOT NULL AND status = \"Open\""; + assertEquals(expected, jql); + } }