diff --git a/core/src/main/java/io/kestra/core/models/QueryFilter.java b/core/src/main/java/io/kestra/core/models/QueryFilter.java new file mode 100644 index 00000000000..91a5abddac9 --- /dev/null +++ b/core/src/main/java/io/kestra/core/models/QueryFilter.java @@ -0,0 +1,250 @@ +package io.kestra.core.models; + +import io.kestra.core.utils.Enums; +import lombok.Builder; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Builder +public record QueryFilter( + Field field, + Op operation, + Object value +) { + public enum Op { + EQUALS("$eq"), + NOT_EQUALS("$ne"), + GREATER_THAN("$gte"), + LESS_THAN("$lte"), + IN("$in"), + NOT_IN("$notIn"), + STARTS_WITH("$startsWith"), + ENDS_WITH("$endsWith"), + CONTAINS("$contains"), + REGEX("$regex"); + + private static final Map BY_VALUE = Arrays.stream(values()) + .collect(Collectors.toMap(Op::value, Function.identity())); + + private final String value; + + Op(String value) { + this.value = value; + } + + public static Op fromString(String value) { + return Enums.fromString(value, BY_VALUE, "operation"); + } + + public String value() { + return value; + } + } + + public enum Field { + QUERY("q") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.REGEX); + } + }, + SCOPE("scope") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS); + } + }, + NAMESPACE("namespace") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, Op.ENDS_WITH, Op.REGEX); + } + }, + LABELS("labels") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS); + } + }, + FLOW_ID("flowId") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.IN, Op.NOT_IN); + } + }, + START_DATE("startDate") { + @Override + public List supportedOp() { + return List.of(Op.GREATER_THAN, Op.LESS_THAN, Op.EQUALS, Op.NOT_EQUALS); + } + }, + END_DATE("endDate") { + @Override + public List supportedOp() { + return List.of(Op.GREATER_THAN, Op.LESS_THAN, Op.EQUALS, Op.NOT_EQUALS); + } + }, + STATE("state") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.IN, Op.NOT_IN); + } + }, + TIME_RANGE("timeRange") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, + Op.ENDS_WITH, Op.IN, Op.NOT_IN, Op.REGEX); + } + }, + TRIGGER_EXECUTION_ID("triggerExecutionId") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, Op.ENDS_WITH, Op.IN, Op.NOT_IN); + } + }, + TRIGGER_ID("triggerId") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, Op.ENDS_WITH, Op.IN, Op.NOT_IN); + } + }, + CHILD_FILTER("childFilter") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS); + } + }, + WORKER_ID("workerId") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS, Op.CONTAINS, Op.STARTS_WITH, Op.ENDS_WITH, Op.IN, Op.NOT_IN); + } + }, + EXISTING_ONLY("existingOnly") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS); + } + }, + MIN_LEVEL("level") { + @Override + public List supportedOp() { + return List.of(Op.EQUALS, Op.NOT_EQUALS); + } + }; + + private static final Map BY_VALUE = Arrays.stream(values()) + .collect(Collectors.toMap(Field::value, Function.identity())); + + public abstract List supportedOp(); + + private final String value; + + Field(String value) { + this.value = value; + } + + public static Field fromString(String value) { + return Enums.fromString(value, BY_VALUE, "field"); + } + + public String value() { + return value; + } + } + + + public enum Resource { + FLOW { + @Override + public List supportedField() { + return List.of(Field.LABELS, Field.NAMESPACE, Field.QUERY, Field.SCOPE); + } + }, + NAMESPACE { + @Override + public List supportedField() { + return List.of(Field.EXISTING_ONLY); + } + }, + EXECUTION { + @Override + public List supportedField() { + return List.of( + Field.QUERY, Field.SCOPE, Field.FLOW_ID, Field.START_DATE, Field.END_DATE, Field.TIME_RANGE, + Field.STATE, Field.LABELS, Field.TRIGGER_EXECUTION_ID, Field.CHILD_FILTER, + Field.NAMESPACE + ); + } + }, + LOG { + @Override + public List supportedField() { + return List.of(Field.NAMESPACE, Field.START_DATE, Field.END_DATE, + Field.FLOW_ID, Field.TRIGGER_ID, Field.MIN_LEVEL + ); + } + }, + TASK { + @Override + public List supportedField() { + return List.of(Field.NAMESPACE, Field.QUERY, Field.END_DATE, Field.FLOW_ID, Field.START_DATE, + Field.STATE, Field.LABELS, Field.TRIGGER_EXECUTION_ID, Field.CHILD_FILTER + ); + } + }, + TEMPLATE { + @Override + public List supportedField() { + return List.of(Field.NAMESPACE, Field.QUERY); + } + }, + TRIGGER { + @Override + public List supportedField() { + return List.of(Field.QUERY, Field.NAMESPACE, Field.WORKER_ID, Field.FLOW_ID + ); + } + }; + + public abstract List supportedField(); + + /** + * Converts {@code Resource} enums to a list of {@code ResourceField}, + * including fields and their supported operations. + * + * @return List of {@code ResourceField} with resource names, fields, and operations. + */ + public static List asResourceList() { + return Arrays.stream(values()) + .map(Resource::toResourceField) + .toList(); + } + + private static ResourceField toResourceField(Resource resource) { + List fieldOps = resource.supportedField().stream() + .map(Resource::toFieldInfo) + .toList(); + return new ResourceField(resource.name().toLowerCase(), fieldOps); + } + + private static FieldOp toFieldInfo(Field field) { + List operations = field.supportedOp().stream() + .map(Resource::toOperation) + .toList(); + return new FieldOp(field.name().toLowerCase(), field.value(), operations); + } + + private static Operation toOperation(Op op) { + return new Operation(op.name(), op.value()); + } + } + + public record ResourceField(String name, List fields) {} + public record FieldOp(String name, String value, List operations) {} + public record Operation(String name, String value) {} + +} diff --git a/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java index 6c121b23e1d..d0483529b37 100644 --- a/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java +++ b/core/src/main/java/io/kestra/core/repositories/ExecutionRepositoryInterface.java @@ -1,5 +1,6 @@ package io.kestra.core.repositories; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.executions.TaskRun; import io.kestra.core.models.executions.statistics.DailyExecutionStatistics; @@ -59,19 +60,9 @@ default Optional findById(String tenantId, String id) { ArrayListTotal find( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable List scope, - @Nullable String namespace, - @Nullable String flowId, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate, - @Nullable List state, - @Nullable Map labels, - @Nullable String triggerExecutionId, - @Nullable ChildFilter childFilter + @Nullable List filters ); - default Flux find( @Nullable String query, @Nullable String tenantId, @@ -103,18 +94,11 @@ Flux find( boolean allowDeleted ); + ArrayListTotal findTaskRun( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable String namespace, - @Nullable String flowId, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate, - @Nullable List states, - @Nullable Map labels, - @Nullable String triggerExecutionId, - @Nullable ChildFilter childFilter + List filters ); Execution delete(Execution execution); diff --git a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java index 89d6b57c953..06e8e53e29c 100644 --- a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java +++ b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java @@ -1,5 +1,6 @@ package io.kestra.core.repositories; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.SearchResult; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.flows.Flow; @@ -143,6 +144,12 @@ ArrayListTotal find( @Nullable Map labels ); + ArrayListTotal find( + Pageable pageable, + @Nullable String tenantId, + @Nullable List filters + ); + List findWithSource( @Nullable String query, @Nullable String tenantId, diff --git a/core/src/main/java/io/kestra/core/repositories/LogRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/LogRepositoryInterface.java index b4bdd74a4c2..2bb5753d9bd 100644 --- a/core/src/main/java/io/kestra/core/repositories/LogRepositoryInterface.java +++ b/core/src/main/java/io/kestra/core/repositories/LogRepositoryInterface.java @@ -1,5 +1,6 @@ package io.kestra.core.repositories; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.executions.LogEntry; import io.kestra.core.models.executions.statistics.LogStatistics; @@ -76,15 +77,9 @@ public interface LogRepositoryInterface extends SaveRepositoryInterface find( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable String namespace, - @Nullable String flowId, - @Nullable String triggerId, - @Nullable Level minLevel, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate - ); + List filters + ); Flux findAsync( @Nullable String tenantId, diff --git a/core/src/main/java/io/kestra/core/repositories/TriggerRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/TriggerRepositoryInterface.java index aade9d39e95..5b25d3ce6a3 100644 --- a/core/src/main/java/io/kestra/core/repositories/TriggerRepositoryInterface.java +++ b/core/src/main/java/io/kestra/core/repositories/TriggerRepositoryInterface.java @@ -1,5 +1,6 @@ package io.kestra.core.repositories; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.triggers.Trigger; import io.kestra.core.models.triggers.TriggerContext; @@ -29,6 +30,7 @@ public interface TriggerRepositoryInterface { Trigger lock(String triggerUid, Function function); ArrayListTotal find(Pageable from, String query, String tenantId, String namespace, String flowId, String workerId); + ArrayListTotal find(Pageable from, String tenantId, List filters); /** * Counts the total number of triggers. diff --git a/core/src/main/java/io/kestra/core/utils/Enums.java b/core/src/main/java/io/kestra/core/utils/Enums.java index 981db49c307..7eee477abda 100644 --- a/core/src/main/java/io/kestra/core/utils/Enums.java +++ b/core/src/main/java/io/kestra/core/utils/Enums.java @@ -101,6 +101,22 @@ public static > Set allExcept(final @NotNull Class enumT .collect(Collectors.toSet()); } + /** + * Converts a string to its corresponding enum value based on a provided mapping. + * + * @param value The string representation of the enum value. + * @param mapping A map of string values to enum constants. + * @param typeName A descriptive name of the enum type (used in error messages). + * @param The type of the enum. + * @return The corresponding enum constant. + * @throws IllegalArgumentException If the string does not match any enum value. + */ + public static > T fromString(String value, Map mapping, String typeName) { + return Optional.ofNullable(mapping.get(value)) + .orElseThrow(() -> new IllegalArgumentException( + "Unsupported %s '%s'. Expected one of: %s".formatted(typeName, value, mapping.keySet()) + )); + } private Enums() { } diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java index 508f0bb760a..74132bb1076 100644 --- a/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java +++ b/core/src/test/java/io/kestra/core/repositories/AbstractExecutionRepositoryTest.java @@ -4,6 +4,7 @@ import com.google.common.collect.ImmutableMap; import io.kestra.core.junit.annotations.KestraTest; import io.kestra.core.models.Label; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.dashboards.AggregationType; import io.kestra.core.models.dashboards.ColumnDescriptor; import io.kestra.core.models.executions.Execution; @@ -143,26 +144,62 @@ protected void inject(String executionTriggerId) { protected void find() { inject(); - ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, null, null); + ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10), null, null); assertThat(executions.getTotal(), is(28L)); assertThat(executions.size(), is(10)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, List.of(State.Type.RUNNING, State.Type.FAILED), null, null, null); + List filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.STATE) + .operation(QueryFilter.Op.EQUALS) + .value( List.of(State.Type.RUNNING, State.Type.FAILED)) + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(8L)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, Map.of("key", "value"), null, null); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.LABELS) + .operation(QueryFilter.Op.EQUALS) + .value(Map.of("key", "value")) + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(1L)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, Map.of("key", "value2"), null, null); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.LABELS) + .operation(QueryFilter.Op.EQUALS) + .value(Map.of("key", "value2")) + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(0L)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, "second", null, null, null, null, null, null); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.FLOW_ID) + .operation(QueryFilter.Op.EQUALS) + .value("second") + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(13L)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, NAMESPACE, "second", null, null, null, null, null, null); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.FLOW_ID) + .operation(QueryFilter.Op.EQUALS) + .value("second") + .build(), + QueryFilter.builder() + .field(QueryFilter.Field.NAMESPACE) + .operation(QueryFilter.Op.EQUALS) + .value(NAMESPACE) + .build() + ); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(13L)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, "io.kestra", null, null, null, null, null, null, null); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.NAMESPACE) + .operation(QueryFilter.Op.STARTS_WITH) + .value("io.kestra") + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(28L)); } @@ -173,22 +210,38 @@ protected void findTriggerExecutionId() { inject(executionTriggerId); inject(); - ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, executionTriggerId, null); + var filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.TRIGGER_EXECUTION_ID) + .operation(QueryFilter.Op.EQUALS) + .value(executionTriggerId) + .build()); + ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(28L)); assertThat(executions.size(), is(10)); assertThat(executions.getFirst().getTrigger().getVariables().get("executionId"), is(executionTriggerId)); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.CHILD_FILTER) + .operation(QueryFilter.Op.EQUALS) + .value(ExecutionRepositoryInterface.ChildFilter.CHILD) + .build()); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, null, ExecutionRepositoryInterface.ChildFilter.CHILD); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(28L)); assertThat(executions.size(), is(10)); assertThat(executions.getFirst().getTrigger().getVariables().get("executionId"), is(executionTriggerId)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, null, ExecutionRepositoryInterface.ChildFilter.MAIN); + filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.CHILD_FILTER) + .operation(QueryFilter.Op.EQUALS) + .value(ExecutionRepositoryInterface.ChildFilter.MAIN) + .build()); + + executions = executionRepository.find(Pageable.from(1, 10), null, filters ); assertThat(executions.getTotal(), is(28L)); assertThat(executions.size(), is(10)); assertThat(executions.getFirst().getTrigger(), is(nullValue())); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, null, null); + executions = executionRepository.find(Pageable.from(1, 10), null,null); assertThat(executions.getTotal(), is(56L)); } @@ -196,11 +249,16 @@ protected void findTriggerExecutionId() { protected void findWithSort() { inject(); - ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10, Sort.of(Sort.Order.desc("id"))), null, null, null, null, null, null, null, null, null, null, null); + ArrayListTotal executions = executionRepository.find(Pageable.from(1, 10, Sort.of(Sort.Order.desc("id"))), null, null); assertThat(executions.getTotal(), is(28L)); assertThat(executions.size(), is(10)); - executions = executionRepository.find(Pageable.from(1, 10), null, null, null, null, null, null, null, List.of(State.Type.RUNNING, State.Type.FAILED), null, null, null); + var filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.STATE) + .operation(QueryFilter.Op.EQUALS) + .value(List.of(State.Type.RUNNING, State.Type.FAILED)) + .build()); + executions = executionRepository.find(Pageable.from(1, 10), null, filters); assertThat(executions.getTotal(), is(8L)); } @@ -208,11 +266,17 @@ protected void findWithSort() { protected void findTaskRun() { inject(); - ArrayListTotal taskRuns = executionRepository.findTaskRun(Pageable.from(1, 10), null, null, null, null, null, null, null, null, null, null); + ArrayListTotal taskRuns = executionRepository.findTaskRun(Pageable.from(1, 10), null, null); assertThat(taskRuns.getTotal(), is(71L)); assertThat(taskRuns.size(), is(10)); - taskRuns = executionRepository.findTaskRun(Pageable.from(1, 10), null, null, null, null, null, null, null, Map.of("key", "value"), null, null); + var filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.LABELS) + .operation(QueryFilter.Op.EQUALS) + .value(Map.of("key", "value")) + .build()); + + taskRuns = executionRepository.findTaskRun(Pageable.from(1, 10), null, filters); assertThat(taskRuns.getTotal(), is(1L)); assertThat(taskRuns.size(), is(1)); } diff --git a/core/src/test/java/io/kestra/core/repositories/AbstractLogRepositoryTest.java b/core/src/test/java/io/kestra/core/repositories/AbstractLogRepositoryTest.java index 8ded7fc63e9..55d5afd5326 100644 --- a/core/src/test/java/io/kestra/core/repositories/AbstractLogRepositoryTest.java +++ b/core/src/test/java/io/kestra/core/repositories/AbstractLogRepositoryTest.java @@ -1,5 +1,6 @@ package io.kestra.core.repositories; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.executions.LogEntry; import io.kestra.core.models.executions.statistics.LogStatistics; @@ -41,23 +42,28 @@ private static LogEntry.LogEntryBuilder logEntry(Level level) { void all() { LogEntry.LogEntryBuilder builder = logEntry(Level.INFO); - ArrayListTotal find = logRepository.find(Pageable.UNPAGED, null, null, null, null, null, null, null, null); + ArrayListTotal find = logRepository.find(Pageable.UNPAGED, null, null); assertThat(find.size(), is(0)); + LogEntry save = logRepository.save(builder.build()); - find = logRepository.find(Pageable.UNPAGED, "doe", null, null, null, null, null, null, null); + find = logRepository.find(Pageable.UNPAGED, null, null); assertThat(find.size(), is(1)); assertThat(find.getFirst().getExecutionId(), is(save.getExecutionId())); - - find = logRepository.find(Pageable.UNPAGED, "doe", null, null, null, null, Level.WARN,null, null); + var filters = List.of(QueryFilter.builder() + .field(QueryFilter.Field.MIN_LEVEL) + .operation(QueryFilter.Op.EQUALS) + .value(Level.WARN) + .build()); + find = logRepository.find(Pageable.UNPAGED, "doe", filters); assertThat(find.size(), is(0)); - find = logRepository.find(Pageable.UNPAGED, null, null, null, null, null, null, null, null); + find = logRepository.find(Pageable.UNPAGED, null, null); assertThat(find.size(), is(1)); assertThat(find.getFirst().getExecutionId(), is(save.getExecutionId())); - logRepository.find(Pageable.UNPAGED, "kestra-io/kestra", null, null, null, null, null, null, null); + logRepository.find(Pageable.UNPAGED, "kestra-io/kestra", null); assertThat(find.size(), is(1)); assertThat(find.getFirst().getExecutionId(), is(save.getExecutionId())); diff --git a/core/src/test/java/io/kestra/core/utils/EnumsTest.java b/core/src/test/java/io/kestra/core/utils/EnumsTest.java index b90b5096561..0431c3de53f 100644 --- a/core/src/test/java/io/kestra/core/utils/EnumsTest.java +++ b/core/src/test/java/io/kestra/core/utils/EnumsTest.java @@ -1,9 +1,13 @@ package io.kestra.core.utils; +import org.junit.Assert; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; class EnumsTest { @@ -26,7 +30,52 @@ void shouldThrowExceptionGivenInvalidString() { }); } + @Test + void testFromStringValidMapping() { + // GIVEN + Map mapping = TestEnumWithValue.getMapping(); + String validValue = "enum1"; + + // WHEN + TestEnumWithValue result = Enums.fromString(validValue, mapping, "TestEnumWithValue"); + + // THEN + Assertions.assertEquals(TestEnumWithValue.ENUM1, result); + } + @Test + void testFromStringInvalidValue() { + // Arrange + Map mapping = TestEnumWithValue.getMapping(); + String invalidValue = "invalidValue"; + + // Act & Assert + IllegalArgumentException exception = Assert.assertThrows(IllegalArgumentException.class, () -> + Enums.fromString(invalidValue, mapping, "TestEnumWithValue") + ); + } + enum TestEnum { ENUM1, ENUM2 } + enum TestEnumWithValue { + ENUM1("enum1"), + ENUM2("enum2"); + + private static final Map BY_VALUE = Arrays.stream(values()) + .collect(Collectors.toMap(TestEnumWithValue::getValue, Function.identity())); + + private final String value; + + TestEnumWithValue(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static Map getMapping() { + return BY_VALUE; + } + } } \ No newline at end of file diff --git a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepository.java b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepository.java index 4bd35a6c356..a1860795101 100644 --- a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepository.java +++ b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.h2; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.jdbc.repository.AbstractJdbcExecutionRepository; import io.kestra.jdbc.runner.AbstractJdbcExecutorStateStorage; @@ -32,6 +33,11 @@ protected Condition findCondition(String query, Map labels) { return H2ExecutionRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Map value, QueryFilter.Op operation) { + return H2ExecutionRepositoryService.findCondition(value, operation); + } + @Override protected Field formatDateField(String dateField, DateUtils.GroupType groupType) { switch (groupType) { diff --git a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepositoryService.java b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepositoryService.java index 37a8599d378..59397fb50de 100644 --- a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepositoryService.java +++ b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2ExecutionRepositoryService.java @@ -1,5 +1,6 @@ package io.kestra.repository.h2; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.Condition; @@ -10,6 +11,8 @@ import java.util.List; import java.util.Map; +import static io.kestra.core.models.QueryFilter.Op.EQUALS; + public abstract class H2ExecutionRepositoryService { public static Condition findCondition(AbstractJdbcRepository jdbcRepository, String query, Map labels) { List conditions = new ArrayList<>(); @@ -29,6 +32,23 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepo }); } + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } + + public static Condition findCondition(Map labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + labels.forEach((key, value) -> { + Field valueField = DSL.field("JQ_STRING(\"value\", '.labels[]? | select(.key == \"" + key + "\") | .value')", String.class); + Condition condition = switch (operation) { + case EQUALS -> value == null ? valueField.isNull() : valueField.eq((String) value); + case NOT_EQUALS -> value == null ? valueField.isNotNull() : valueField.ne((String) value); + default -> throw new UnsupportedOperationException("Unsupported operation: " + operation); + }; + conditions.add(condition); + }); + + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); } } diff --git a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepository.java b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepository.java index ef5cb7b340c..ba4870721c1 100644 --- a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepository.java +++ b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.h2; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; import io.kestra.jdbc.repository.AbstractJdbcFlowRepository; import io.micronaut.context.ApplicationContext; @@ -8,6 +9,7 @@ import jakarta.inject.Singleton; import org.jooq.Condition; +import java.util.List; import java.util.Map; @Singleton @@ -24,6 +26,12 @@ protected Condition findCondition(String query, Map labels) { return H2FlowRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Object value, QueryFilter.Op operation) { + return H2FlowRepositoryService.findCondition(value, operation); + } + + @Override protected Condition findSourceCodeCondition(String query) { return H2FlowRepositoryService.findSourceCodeCondition(this.jdbcRepository, query); diff --git a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepositoryService.java b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepositoryService.java index 587db0cf0a0..2bef0e0a7e7 100644 --- a/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepositoryService.java +++ b/jdbc-h2/src/main/java/io/kestra/repository/h2/H2FlowRepositoryService.java @@ -1,5 +1,6 @@ package io.kestra.repository.h2; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.*; @@ -9,6 +10,7 @@ import java.util.List; import java.util.Map; +import static io.kestra.core.models.QueryFilter.Op.EQUALS; import static io.kestra.jdbc.repository.AbstractJdbcRepository.field; public abstract class H2FlowRepositoryService { @@ -36,4 +38,23 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepositor public static Condition findSourceCodeCondition(AbstractJdbcRepository jdbcRepository, String query) { return jdbcRepository.fullTextCondition(List.of("source_code"), query); } + + public static Condition findCondition(Object labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + if (labels instanceof Map labelValues) { + labelValues.forEach((key, value) -> { + Field valueField = DSL.field("JQ_STRING(\"value\", '.labels[]? | select(.key == \"" + key + "\") | .value')", String.class); + Condition condition = switch (operation) { + case EQUALS -> value == null ? valueField.isNull() : valueField.eq((String) value); + case NOT_EQUALS -> value == null ? valueField.isNotNull() : valueField.ne((String) value); + default -> throw new UnsupportedOperationException("Unsupported operation: " + operation); + }; + + conditions.add(condition); + }); + + } + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } } diff --git a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepository.java b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepository.java index dc5ad90f2fa..f7314599dc6 100644 --- a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepository.java +++ b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.mysql; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.utils.DateUtils; import io.kestra.jdbc.repository.AbstractJdbcExecutionRepository; @@ -33,6 +34,11 @@ protected Condition findCondition(String query, Map labels) { return MysqlExecutionRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Map value, QueryFilter.Op operation) { + return MysqlExecutionRepositoryService.findCondition(value, operation); + } + @Override protected Field weekFromTimestamp(Field timestampField) { return this.jdbcRepository.weekFromTimestamp(timestampField); diff --git a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepositoryService.java b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepositoryService.java index 5612f7ed834..47c40062809 100644 --- a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepositoryService.java +++ b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlExecutionRepositoryService.java @@ -1,5 +1,6 @@ package io.kestra.repository.mysql; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.Condition; @@ -11,6 +12,8 @@ import java.util.List; import java.util.Map; +import static io.kestra.core.models.QueryFilter.Op.EQUALS; + public abstract class MysqlExecutionRepositoryService { public static Condition findCondition(AbstractJdbcRepository jdbcRepository, String query, Map labels) { List conditions = new ArrayList<>(); @@ -28,4 +31,20 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepo return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); } + + public static Condition findCondition(Map labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + labels.forEach((key, value) -> { + String sql = "JSON_CONTAINS(value, JSON_ARRAY(JSON_OBJECT('key', '" + key + "', 'value', '" + value + "')), '$.labels')"; + if (operation.equals(EQUALS)) + conditions.add(DSL.condition(sql)); + else + conditions.add(DSL.not(DSL.condition(sql))); + + }); + + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } + } diff --git a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepository.java b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepository.java index efbe13a7d2a..5171236a054 100644 --- a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepository.java +++ b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.mysql; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; import io.kestra.jdbc.repository.AbstractJdbcFlowRepository; import io.micronaut.context.ApplicationContext; @@ -8,6 +9,7 @@ import jakarta.inject.Singleton; import org.jooq.Condition; +import java.util.List; import java.util.Map; @Singleton @@ -24,6 +26,11 @@ protected Condition findCondition(String query, Map labels) { return MysqlFlowRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Object value, QueryFilter.Op operation) { + return MysqlFlowRepositoryService.findCondition(value, operation); + } + @Override protected Condition findSourceCodeCondition(String query) { return MysqlFlowRepositoryService.findSourceCodeCondition(this.jdbcRepository, query); diff --git a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepositoryService.java b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepositoryService.java index 5923516fa7e..932a33adcd3 100644 --- a/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepositoryService.java +++ b/jdbc-mysql/src/main/java/io/kestra/repository/mysql/MysqlFlowRepositoryService.java @@ -1,5 +1,6 @@ package io.kestra.repository.mysql; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.Condition; @@ -8,6 +9,8 @@ import java.util.*; +import static io.kestra.core.models.QueryFilter.Op.EQUALS; + public abstract class MysqlFlowRepositoryService { public static Condition findCondition(AbstractJdbcRepository jdbcRepository, String query, Map labels) { List conditions = new ArrayList<>(); @@ -29,4 +32,18 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepositor public static Condition findSourceCodeCondition(AbstractJdbcRepository jdbcRepository, String query) { return jdbcRepository.fullTextCondition(Collections.singletonList("source_code"), query); } + + public static Condition findCondition(Object labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + if (labels instanceof Map labelValues) { + labelValues.forEach((key, value) -> { + Field valueField = DSL.field("JSON_CONTAINS(value, JSON_ARRAY(JSON_OBJECT('key', '" + key + "', 'value', '" + value + "')), '$.labels')", Boolean.class); + if(operation.equals(EQUALS)) + conditions.add(valueField.eq(value != null)); + + }); + } + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } } diff --git a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepository.java b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepository.java index 4261beaf274..bbe59376560 100644 --- a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepository.java +++ b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.postgres; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.flows.State; import io.kestra.core.utils.DateUtils; @@ -47,6 +48,11 @@ protected Condition findCondition(String query, Map labels) { return PostgresExecutionRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Map value, QueryFilter.Op operation) { + return PostgresExecutionRepositoryService.findCondition(value, operation); + } + @Override protected Field formatDateField(String dateField, DateUtils.GroupType groupType) { switch (groupType) { diff --git a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepositoryService.java b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepositoryService.java index f1230be8159..94e38272061 100644 --- a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepositoryService.java +++ b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresExecutionRepositoryService.java @@ -1,8 +1,10 @@ package io.kestra.repository.postgres; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.Execution; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.Condition; +import org.jooq.Field; import org.jooq.impl.DSL; import java.util.ArrayList; @@ -10,6 +12,8 @@ import java.util.List; import java.util.Map; +import static io.kestra.core.models.QueryFilter.Op.EQUALS; + public abstract class PostgresExecutionRepositoryService { public static Condition findCondition(AbstractJdbcRepository jdbcRepository, String query, Map labels) { List conditions = new ArrayList<>(); @@ -27,4 +31,20 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepo return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); } + + public static Condition findCondition(Map labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + labels.forEach((key, value) -> { + String sql = "value -> 'labels' @> '[{\"key\":\"" + key + "\", \"value\":\"" + value + "\"}]'"; + if (operation.equals(EQUALS)) + conditions.add(DSL.condition(sql)); + else + conditions.add(DSL.not(DSL.condition(sql))); + + }); + + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } + } diff --git a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepository.java b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepository.java index e41c5be67a5..d3775355554 100644 --- a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepository.java +++ b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepository.java @@ -1,5 +1,6 @@ package io.kestra.repository.postgres; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; import io.kestra.jdbc.repository.AbstractJdbcFlowRepository; import io.micronaut.context.ApplicationContext; @@ -8,6 +9,7 @@ import jakarta.inject.Singleton; import org.jooq.Condition; +import java.util.List; import java.util.Map; @Singleton @@ -24,6 +26,12 @@ protected Condition findCondition(String query, Map labels) { return PostgresFlowRepositoryService.findCondition(this.jdbcRepository, query, labels); } + @Override + protected Condition findCondition(Object value, QueryFilter.Op operation) { + return PostgresFlowRepositoryService.findCondition( value, operation); + } + + @Override protected Condition findSourceCodeCondition(String query) { return PostgresFlowRepositoryService.findSourceCodeCondition(this.jdbcRepository, query); diff --git a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepositoryService.java b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepositoryService.java index a77299923ca..b99ef541ac3 100644 --- a/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepositoryService.java +++ b/jdbc-postgres/src/main/java/io/kestra/repository/postgres/PostgresFlowRepositoryService.java @@ -1,14 +1,17 @@ package io.kestra.repository.postgres; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.flows.Flow; +import io.kestra.core.models.flows.FlowScope; import io.kestra.jdbc.AbstractJdbcRepository; import org.jooq.Condition; import org.jooq.impl.DSL; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; + +import static io.kestra.core.models.QueryFilter.Op.EQUALS; +import static io.kestra.jdbc.repository.AbstractJdbcRepository.field; +import static io.kestra.jdbc.repository.AbstractJdbcTriggerRepository.NAMESPACE_FIELD; public abstract class PostgresFlowRepositoryService { public static Condition findCondition(AbstractJdbcRepository jdbcRepository, String query, Map labels) { @@ -18,7 +21,7 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepositor conditions.add(jdbcRepository.fullTextCondition(Collections.singletonList("fulltext"), query)); } - if (labels != null) { + if (labels != null) { labels.forEach((key, value) -> { String sql = "value -> 'labels' @> '[{\"key\":\"" + key + "\", \"value\":\"" + value + "\"}]'"; conditions.add(DSL.condition(sql)); @@ -31,4 +34,23 @@ public static Condition findCondition(AbstractJdbcRepository jdbcRepositor public static Condition findSourceCodeCondition(AbstractJdbcRepository jdbcRepository, String query) { return jdbcRepository.fullTextCondition(Collections.singletonList("FULLTEXT_INDEX(source_code)"), query); } + + + public static Condition findCondition(Object labels, QueryFilter.Op operation) { + List conditions = new ArrayList<>(); + + if (labels instanceof Map labelValues) { + labelValues.forEach((key, value) -> { + String sql = "value -> 'labels' @> '[{\"key\":\"" + key + "\", \"value\":\"" + value + "\"}]'"; + if (operation.equals(EQUALS)) + conditions.add(DSL.condition(sql)); + else + conditions.add(DSL.not(DSL.condition(sql))); + + }); + } + return conditions.isEmpty() ? DSL.trueCondition() : DSL.and(conditions); + } + + } diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java index 1b6a82eb06e..cc4e93a3354 100644 --- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java +++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcExecutionRepository.java @@ -2,6 +2,7 @@ import io.kestra.core.events.CrudEvent; import io.kestra.core.events.CrudEventType; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.dashboards.ColumnDescriptor; import io.kestra.core.models.dashboards.DataFilter; import io.kestra.core.models.dashboards.filters.AbstractFilter; @@ -195,6 +196,7 @@ public Optional findById(String tenantId, String id, boolean allowDel } abstract protected Condition findCondition(String query, Map labels); + abstract protected Condition findCondition(Map value, QueryFilter.Op operation); protected Condition statesFilter(List state) { return field("state_current") @@ -204,17 +206,9 @@ protected Condition statesFilter(List state) { @Override public ArrayListTotal find( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable List scope, - @Nullable String namespace, - @Nullable String flowId, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate, - @Nullable List state, - @Nullable Map labels, - @Nullable String triggerExecutionId, - @Nullable ChildFilter childFilter + @Nullable List filters + ) { return this.jdbcRepository .getDslContextWrapper() @@ -223,18 +217,9 @@ public ArrayListTotal find( SelectConditionStep> select = this.findSelect( context, - query, tenantId, - scope, - namespace, - flowId, - startDate, - endDate, - state, - labels, - triggerExecutionId, - childFilter, - false + filters + ); return this.jdbcRepository.fetchPage(context, select, pageable); @@ -290,6 +275,36 @@ public Flux find( ); } + private SelectConditionStep> findSelect( + DSLContext context, + @Nullable String tenantId, + @Nullable List filters + ) { + + SelectConditionStep> select = context + .select( + field("value") + ) + .hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null) + .from(this.jdbcRepository.getTable()) + .where(this.defaultFilter(tenantId, false)); + + if (filters != null) + for (QueryFilter filter : filters) { + QueryFilter.Field field = filter.field(); + QueryFilter.Op operation = filter.operation(); + Object value = filter.value(); + if (field.equals(QueryFilter.Field.QUERY)) { + select = select.and(this.findCondition(filter.value().toString(), null)); + } else if (field.equals(QueryFilter.Field.LABELS) && value instanceof Map labels) + select = select.and(findCondition(labels, operation)); + else + select = getConditionOnField(select, field, value, operation, "start_date"); + } + + return select; + } + private SelectConditionStep> findSelect( DSLContext context, @Nullable String query, @@ -351,16 +366,8 @@ public ArrayListTotal findByFlowId(String tenantId, String namespace, @Override public ArrayListTotal findTaskRun( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable String namespace, - @Nullable String flowId, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate, - @Nullable List states, - @Nullable Map labels, - @Nullable String triggerExecutionId, - @Nullable ChildFilter childFilter + List filters ) { throw new UnsupportedOperationException(); } diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java index ff6437bd3d6..baddd92b776 100644 --- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java +++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java @@ -5,6 +5,7 @@ import io.kestra.core.events.CrudEvent; import io.kestra.core.events.CrudEventType; import io.kestra.core.exceptions.DeserializationException; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.SearchResult; import io.kestra.core.models.flows.*; import io.kestra.core.models.triggers.Trigger; @@ -479,6 +480,30 @@ private SelectConditionStep fullTextSelect(String tenan } abstract protected Condition findCondition(String query, Map labels); + abstract protected Condition findCondition(Object value, QueryFilter.Op operation); + + @Override + public ArrayListTotal find(Pageable pageable, @Nullable String tenantId, @Nullable List filters) { + return this.jdbcRepository + .getDslContextWrapper() + .transactionResult(configuration -> { + DSLContext context = DSL.using(configuration); + + SelectConditionStep> select = this.fullTextSelect(tenantId, context, Collections.emptyList()); + + if (filters != null) + for (QueryFilter filter : filters) { + QueryFilter.Field field = filter.field(); + QueryFilter.Op operation = filter.operation(); + Object value = filter.value(); + if (field.equals(QueryFilter.Field.LABELS) && value instanceof Map labels) + select = select.and(findCondition(labels, operation)); + else + select = getConditionOnField(select, field, value, operation, null); + } + return this.jdbcRepository.fetchPage(context, select, pageable); + }); + } public ArrayListTotal find( Pageable pageable, diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcLogRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcLogRepository.java index cd197f43d28..f1129c5a722 100644 --- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcLogRepository.java +++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcLogRepository.java @@ -1,5 +1,6 @@ package io.kestra.jdbc.repository; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.dashboards.ColumnDescriptor; import io.kestra.core.models.dashboards.DataFilter; import io.kestra.core.models.executions.Execution; @@ -63,7 +64,6 @@ public AbstractJdbcLogRepository(io.kestra.jdbc.AbstractJdbcRepository Logs.Fields.LEVEL, "level", Logs.Fields.MESSAGE, "message" ); - @Override public Set dateFields() { return Set.of(Logs.Fields.DATE); @@ -77,15 +77,11 @@ public Logs.Fields dateFilterField() { @Override public ArrayListTotal find( Pageable pageable, - @Nullable String query, @Nullable String tenantId, - @Nullable String namespace, - @Nullable String flowId, - @Nullable String triggerId, - @Nullable Level minLevel, - @Nullable ZonedDateTime startDate, - @Nullable ZonedDateTime endDate + @Nullable List filters ) { + + String query = getQuery(filters); return this.jdbcRepository .getDslContextWrapper() .transactionResult(configuration -> { @@ -95,9 +91,10 @@ public ArrayListTotal find( .select(field("value")) .hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null) .from(this.jdbcRepository.getTable()) - .where(this.defaultFilter(tenantId)); + .where(this.defaultFilter(tenantId)) + .and(this.findCondition(query)); - this.filter(select, query, namespace, flowId, triggerId, minLevel, startDate , endDate); + select = this.filter(select, filters, "timestamp"); return this.jdbcRepository.fetchPage(context, select, pageable); }); diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java index 386fb9db4d1..3ffaef85f81 100644 --- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java +++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java @@ -1,27 +1,46 @@ package io.kestra.jdbc.repository; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.dashboards.ColumnDescriptor; import io.kestra.core.models.dashboards.DataFilter; import io.kestra.core.models.dashboards.Order; +import io.kestra.core.models.executions.LogEntry; +import io.kestra.core.models.flows.FlowScope; +import io.kestra.core.models.flows.State; +import io.kestra.core.repositories.ExecutionRepositoryInterface.ChildFilter; import io.kestra.core.utils.DateUtils; import io.kestra.core.utils.ListUtils; import io.kestra.jdbc.services.JdbcFilterService; +import io.micronaut.context.annotation.Value; import io.micronaut.core.annotation.Nullable; import io.micronaut.data.model.Pageable; +import lombok.Getter; import org.jooq.Record; import org.jooq.*; import org.jooq.impl.DSL; +import org.slf4j.event.Level; import java.sql.Timestamp; import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; import java.util.stream.Stream; +import java.util.*; + +import static io.kestra.core.utils.NamespaceUtils.SYSTEM_FLOWS_DEFAULT_NAMESPACE; public abstract class AbstractJdbcRepository { + @Getter + @Value("${kestra.system-flows.namespace:" + SYSTEM_FLOWS_DEFAULT_NAMESPACE + "}") + private String systemFlowNamespace; + + private static final Field NAMESPACE_FIELD = field("namespace", String.class); + protected Condition defaultFilter() { return field("deleted", Boolean.class).eq(false); } @@ -214,4 +233,222 @@ protected List> fetchSeekStep(SelectSeekStepN select protected > Field columnToField(ColumnDescriptor column, Map fieldsMapping) { return column.getField() != null ? field(fieldsMapping.get(column.getField())) : null; } + + protected SelectConditionStep filter( + SelectConditionStep select, + List filters, + String dateColumn + ) { + if (filters != null) { + for (QueryFilter filter : filters) { + QueryFilter.Field field = filter.field(); + QueryFilter.Op operation = filter.operation(); + Object value = filter.value(); + select = getConditionOnField(select, field, value, operation, dateColumn); + } + } + return select; + } + + protected SelectConditionStep getConditionOnField( + SelectConditionStep select, + QueryFilter.Field field, + Object value, + QueryFilter.Op operation, + String dateColumn) + { + if(field.equals(QueryFilter.Field.QUERY)) { + return select; + } + // Handling for Field.STATE + if (field.equals(QueryFilter.Field.STATE)) { + + return select.and(generateStateCondition(value, operation)); + } + // Handle Field.CHILD_FILTER + if (field.equals(QueryFilter.Field.CHILD_FILTER)) { + return handleChildFilter(select, value); + } + // Handling for Field.MIN_LEVEL + if (field.equals(QueryFilter.Field.MIN_LEVEL)) { + return handleMinLevelField(select, value, operation); + } + + // Special handling for START_DATE and END_DATE + if (field == QueryFilter.Field.START_DATE || field == QueryFilter.Field.END_DATE) { + OffsetDateTime dateTime = (value instanceof ZonedDateTime) + ? ((ZonedDateTime) value).toOffsetDateTime() + : ZonedDateTime.parse(value.toString()).toOffsetDateTime(); + return applyDateCondition(select, dateTime, operation, dateColumn); + } + + if (field == QueryFilter.Field.SCOPE) { + return applyScopeCondition(select, value, operation); + } + if (field == QueryFilter.Field.NAMESPACE) { + return applyNamespaceCondition(select, value, operation); + } + + // Convert the field name to lowercase and quote it + Name columnName = DSL.quotedName(field.name().toLowerCase()); + + // Default handling for other fields + switch (operation) { + case EQUALS -> select = select.and(DSL.field(columnName).eq(value)); + case NOT_EQUALS -> select = select.and(DSL.field(columnName).ne(value)); + case GREATER_THAN -> select = select.and(DSL.field(columnName).greaterThan(value)); + case LESS_THAN -> select = select.and(DSL.field(columnName).lessThan(value)); + case IN -> { + if (value instanceof Collection) { + select = select.and(DSL.field(columnName).in((Collection) value)); + } else { + throw new IllegalArgumentException("IN operation requires a collection as value"); + } + } + case NOT_IN -> { + if (value instanceof Collection) { + select = select.and(DSL.field(columnName).notIn((Collection) value)); + } else { + throw new IllegalArgumentException("NOT_IN operation requires a collection as value"); + } + } + case STARTS_WITH -> select = select.and(DSL.field(columnName).like(value + "%")); + + case ENDS_WITH -> select = select.and(DSL.field(columnName).like("%" + value)); + case CONTAINS -> select = select.and(DSL.field(columnName).like("%" + value + "%")); + case REGEX -> select = select.and(DSL.field(columnName).likeRegex((String) value)); + default -> throw new UnsupportedOperationException("Unsupported operation: " + operation); + } + return select; + } + + private SelectConditionStep applyNamespaceCondition(SelectConditionStep select, Object value, QueryFilter.Op operation) { + + switch (operation) { + case EQUALS -> select = select.and(NAMESPACE_FIELD.eq((String) value)); + case NOT_EQUALS -> select = select.and(NAMESPACE_FIELD.ne((String) value)); + case CONTAINS -> select = select.and(NAMESPACE_FIELD.eq((String) value) + .or(NAMESPACE_FIELD.like( value + ".%")) + .or(NAMESPACE_FIELD.like("%." + value))) + ; + case STARTS_WITH -> select = select.and(NAMESPACE_FIELD.like(value + ".%") + .or(NAMESPACE_FIELD.eq((String) value))); + case ENDS_WITH -> select = select.and(NAMESPACE_FIELD.like("%." + value)); + default -> + throw new UnsupportedOperationException("Unsupported operation '%s' for field 'namespace'.".formatted(operation)); + } + return select; + } + + // Generate the condition for Field.STATE + private Condition generateStateCondition(Object value, QueryFilter.Op operation) { + List stateList = switch (value) { + case List list when !list.isEmpty() && list.getFirst() instanceof State.Type -> + (List) list; + case List list -> + list.stream().map(item -> State.Type.valueOf(item.toString())).toList(); + case State.Type state -> List.of(state); + case String state -> List.of(State.Type.valueOf(state)); + default -> throw new IllegalArgumentException("Field 'state' requires a State.Type or List value"); + }; + + return switch (operation) { + case IN, EQUALS -> statesFilter(stateList); + case NOT_IN, NOT_EQUALS -> DSL.not(statesFilter(stateList)); + default -> throw new IllegalArgumentException("Unsupported operation for State.Type: " + operation); + }; + } + protected Condition statesFilter(List state) { + return DSL.field(DSL.quotedName("state_current")) + .in(state.stream().map(Enum::name).toList()); + } + + // Handle CHILD_FILTER field logic + private SelectConditionStep handleChildFilter(SelectConditionStep select, Object value) { + ChildFilter childFilter = (value instanceof String val)? ChildFilter.valueOf(val) : (ChildFilter) value; + + return switch (childFilter) { + case CHILD -> select.and(DSL.field(DSL.quotedName("trigger_execution_id")).isNotNull()); + case MAIN -> select.and(DSL.field(DSL.quotedName("trigger_execution_id")).isNull()); + }; + } + + private SelectConditionStep handleMinLevelField( + SelectConditionStep select, + Object value, + QueryFilter.Op operation + ) { + Level minLevel = value instanceof Level ? (Level) value : Level.valueOf((String) value); + + switch (operation) { + case EQUALS -> select = select.and(minLevelCondition(minLevel)); + case NOT_EQUALS -> select = select.and(minLevelCondition(minLevel).not()); + default -> throw new UnsupportedOperationException( + "Unsupported operation for MIN_LEVEL: " + operation + ); + } + return select; + } + private Condition minLevelCondition(Level minLevel) { + return levelsCondition(LogEntry.findLevelsByMin(minLevel)); + } + + protected Condition levelsCondition(List levels) { + return field("level").in(levels.stream().map(level -> level.name()).toList()); + } + + private SelectConditionStep applyDateCondition( + SelectConditionStep select, OffsetDateTime dateTime, QueryFilter.Op operation,String fieldName + ) { + switch (operation) { + case LESS_THAN -> select = select.and(DSL.field(fieldName).lessThan(dateTime)); + case GREATER_THAN -> select = select.and(DSL.field(fieldName).greaterThan(dateTime)); + case EQUALS -> select = select.and(DSL.field(fieldName).eq(dateTime)); + case NOT_EQUALS -> select = select.and(DSL.field(fieldName).ne(dateTime)); + default -> throw new UnsupportedOperationException("Unsupported operation for date condition: " + operation); + } + return select; + } + protected static String getQuery(List filters) { + if (filters == null || filters.isEmpty()) return null; + return filters.stream() + .filter(filter -> filter.field() == QueryFilter.Field.QUERY) + .map(filter -> filter.value().toString()) + .findFirst() + .orElse(null); + } + private SelectConditionStep applyScopeCondition( + SelectConditionStep select, Object value, QueryFilter.Op operation) { + + if (!(value instanceof List scopeValues)) { + throw new IllegalArgumentException("Invalid value for SCOPE filtering"); + } + + List validScopes = Arrays.stream(FlowScope.values()).toList(); + if (!validScopes.containsAll(scopeValues)) { + throw new IllegalArgumentException("Scope values must be a subset of FlowScope"); + } + if (operation != QueryFilter.Op.EQUALS && operation != QueryFilter.Op.NOT_EQUALS) { + throw new UnsupportedOperationException("Unsupported operation for SCOPE: " + operation); + } + + boolean isEqualsOperation = (operation == QueryFilter.Op.EQUALS); + String systemNamespace = this.getSystemFlowNamespace(); + + if (scopeValues.contains(FlowScope.USER)) { + Condition userCondition = isEqualsOperation + ? DSL.field("namespace").ne(systemNamespace) + : DSL.field("namespace").eq(systemNamespace); + select = select.and(userCondition); + } + + if (scopeValues.contains(FlowScope.SYSTEM)) { + Condition systemCondition = isEqualsOperation + ? DSL.field("namespace").eq(systemNamespace) + : DSL.field("namespace").ne(systemNamespace); + select = select.and(systemCondition); + } + + return select; + } } diff --git a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcTriggerRepository.java b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcTriggerRepository.java index 84916273eec..f45ff0b7df3 100644 --- a/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcTriggerRepository.java +++ b/jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcTriggerRepository.java @@ -1,5 +1,6 @@ package io.kestra.jdbc.repository; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.conditions.ConditionContext; import io.kestra.core.models.executions.Execution; import io.kestra.core.models.flows.Flow; @@ -19,6 +20,7 @@ import reactor.core.publisher.FluxSink; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; @@ -250,6 +252,28 @@ public Trigger lock(String triggerUid, Function function) { return null; }); } + @Override + public ArrayListTotal find(Pageable pageable,String tenantId, List filters) { + return this.jdbcRepository + .getDslContextWrapper() + .transactionResult(configuration -> { + DSLContext context = DSL.using(configuration); + // extract Query field from the filters list + String query = getQuery(filters); + + // Base query with table and DSL fields + SelectConditionStep select = context + .select(field("value")) + .hint(context.configuration().dialect().supports(SQLDialect.MYSQL) ? "SQL_CALC_FOUND_ROWS" : null) + .from(this.jdbcRepository.getTable()) + .where(this.defaultFilter(tenantId)) + .and(this.fullTextCondition(query)); + + filter(select, filters, "next_execution_date"); + // Return paginated results + return this.jdbcRepository.fetchPage(context, select, pageable); + }); + } @Override public ArrayListTotal find(Pageable pageable, String query, String tenantId, String namespace, String flowId, String workerId) { diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java index 4c7e705260d..858f8227a7f 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java @@ -6,6 +6,7 @@ import io.kestra.core.exceptions.IllegalVariableEvaluationException; import io.kestra.core.exceptions.InternalException; import io.kestra.core.models.Label; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.*; import io.kestra.core.models.flows.Flow; import io.kestra.core.models.flows.FlowForExecution; @@ -36,11 +37,14 @@ import io.kestra.core.utils.Await; import io.kestra.core.utils.ListUtils; import io.kestra.plugin.core.trigger.Webhook; +import io.kestra.webserver.converters.QueryFilterFormat; import io.kestra.webserver.responses.BulkErrorResponse; import io.kestra.webserver.responses.BulkResponse; import io.kestra.webserver.responses.PagedResults; import io.kestra.webserver.utils.PageableUtils; +import io.kestra.webserver.utils.QueryFilterUtils; import io.kestra.webserver.utils.RequestUtils; +import io.kestra.webserver.utils.TimeLineSearch; import io.kestra.webserver.utils.filepreview.FileRender; import io.kestra.webserver.utils.filepreview.FileRenderBuilder; import io.micronaut.context.annotation.Value; @@ -188,37 +192,59 @@ public PagedResults find( @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) int page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") @Min(1) int size, @Parameter(description = "The sort of current page") @Nullable @QueryValue List sort, - @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query, - @Parameter(description = "The scope of the executions to include") @Nullable @QueryValue(value = "scope") List scope, - @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace, - @Parameter(description = "A flow id filter") @Nullable @QueryValue String flowId, - @Parameter(description = "The start datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, - @Parameter(description = "The end datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate, - @Parameter(description = "A time range filter relative to the current time", examples = { + @Parameter(description = "Filters") @QueryFilterFormat List filters, + //Deprecated params + @Parameter(description = "A string filter", deprecated = true) @Nullable @QueryValue(value = "q") String query, + @Parameter(description = "The scope of the executions to include",deprecated = true) @Nullable @QueryValue(value = "scope") List scope, + @Parameter(description = "A namespace filter prefix",deprecated = true) @Nullable @QueryValue String namespace, + @Parameter(description = "A flow id filter",deprecated = true) @Nullable @QueryValue String flowId, + @Parameter(description = "The start datetime",deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, + @Parameter(description = "The end datetime",deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate, + @Parameter(description = "A time range filter relative to the current time",deprecated = true, examples = { @ExampleObject(name = "Filter last 5 minutes", value = "PT5M"), @ExampleObject(name = "Filter last 24 hours", value = "P1D") }) @Nullable @QueryValue Duration timeRange, - @Parameter(description = "A state filter") @Nullable @QueryValue List state, - @Parameter(description = "A labels filter as a list of 'key:value'") @Nullable @QueryValue @Format("MULTI") List labels, - @Parameter(description = "The trigger execution id") @Nullable @QueryValue String triggerExecutionId, - @Parameter(description = "A execution child filter") @Nullable @QueryValue ExecutionRepositoryInterface.ChildFilter childFilter + @Parameter(description = "A state filter",deprecated = true) @Nullable @QueryValue List state, + @Parameter(description = "A labels filter as a list of 'key:value'", deprecated = true) @Nullable @QueryValue @Format("MULTI") List labels, + @Parameter(description = "The trigger execution id", deprecated = true) @Nullable @QueryValue String triggerExecutionId, + @Parameter(description = "A execution child filter", deprecated = true) @Nullable @QueryValue ExecutionRepositoryInterface.ChildFilter childFilter + ) { - validateTimeline(startDate, endDate); + + // If filters is empty, map old params to QueryFilter + if (filters == null || filters.isEmpty()) { + filters = RequestUtils.mapLegacyParamsToFilters( + query, + namespace, + flowId, + triggerExecutionId, + null, + startDate, + endDate, + scope, + labels, + timeRange, + childFilter, + state, + null); + } final ZonedDateTime now = ZonedDateTime.now(); + TimeLineSearch timeLineSearch = TimeLineSearch.extractFrom(filters); + validateTimeline(timeLineSearch.getStartDate(), timeLineSearch.getEndDate()); + + ZonedDateTime resolvedStartDate = resolveAbsoluteDateTime(timeLineSearch.getStartDate(), + timeLineSearch.getTimeRange(), + now); + + // Update filters with the resolved startDate + filters = QueryFilterUtils.updateFilters(filters, resolvedStartDate); + return PagedResults.of(executionRepository.find( + PageableUtils.from(page, size, sort, executionRepository.sortMapping()), - query, tenantService.resolveTenant(), - scope, - namespace, - flowId, - resolveAbsoluteDateTime(startDate, timeRange, now), - endDate, - state, - RequestUtils.toMap(labels), - triggerExecutionId, - childFilter + filters )); } diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java index cf857bdb264..884020aeb1d 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import io.kestra.core.exceptions.IllegalVariableEvaluationException; import io.kestra.core.exceptions.InternalException; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.HasSource; import io.kestra.core.models.SearchResult; import io.kestra.core.models.flows.Flow; @@ -29,6 +30,7 @@ import io.kestra.core.tenant.TenantService; import io.kestra.core.topologies.FlowTopologyService; import io.kestra.webserver.controllers.domain.IdWithNamespace; +import io.kestra.webserver.converters.QueryFilterFormat; import io.kestra.webserver.responses.BulkResponse; import io.kestra.webserver.responses.PagedResults; import io.kestra.webserver.utils.PageableUtils; @@ -47,8 +49,6 @@ import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.extensions.Extension; -import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.inject.Inject; @@ -57,15 +57,11 @@ import jakarta.validation.constraints.Min; import lombok.extern.slf4j.Slf4j; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; import static io.kestra.core.utils.Rethrow.throwFunction; @@ -102,6 +98,7 @@ public class FlowController { @Inject private TenantService tenantService; + @ExecuteOn(TaskExecutors.IO) @Get(uri = "{namespace}/{id}/graph") @Operation(tags = {"Flows"}, summary = "Generate a graph for a flow") @@ -207,22 +204,40 @@ public PagedResults find( @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) int page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") @Min(1) int size, @Parameter(description = "The sort of current page") @Nullable @QueryValue List sort, - @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query, - @Parameter(description = "The scope of the flows to include") @Nullable @QueryValue List scope, - @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace, - @Parameter(description = "A labels filter as a list of 'key:value'") @Nullable @QueryValue @Format("MULTI") List labels + @Parameter(description = "Filters") @QueryFilterFormat() List filters, + // Deprecated params + @Parameter(description = "A string filter",deprecated = true) @Nullable @QueryValue(value = "q") String query, + @Parameter(description = "The scope of the flows to include", deprecated = true) @Nullable @QueryValue List scope, + @Parameter(description = "A namespace filter prefix", deprecated = true) @Nullable @QueryValue String namespace, + @Parameter(description = "A labels filter as a list of 'key:value'", deprecated = true) @Nullable @QueryValue @Format("MULTI") List labels + ) throws HttpStatusException { + // If filters is empty, map old params to QueryFilter + if (filters == null || filters.isEmpty()) { + filters = RequestUtils.mapLegacyParamsToFilters( + query, + namespace, + null, + null, + null, + null, + null, + scope, + labels, + null, + null, + null, + null); + } return PagedResults.of(flowRepository.find( PageableUtils.from(page, size, sort), - query, tenantService.resolveTenant(), - scope, - namespace, - RequestUtils.toMap(labels) + filters )); } + @ExecuteOn(TaskExecutors.IO) @Get(uri = "/{namespace}") @Operation(tags = {"Flows"}, summary = "Retrieve all flows from a given namespace") diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/LogController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/LogController.java index 97f41b451b9..cf1b4bc3759 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/LogController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/LogController.java @@ -1,16 +1,22 @@ package io.kestra.webserver.controllers.api; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.LogEntry; import io.kestra.core.repositories.LogRepositoryInterface; import io.kestra.core.services.ExecutionLogService; import io.kestra.core.tenant.TenantService; +import io.kestra.webserver.converters.QueryFilterFormat; import io.kestra.webserver.responses.PagedResults; import io.kestra.webserver.utils.PageableUtils; +import io.kestra.webserver.utils.QueryFilterUtils; +import io.kestra.webserver.utils.RequestUtils; +import io.kestra.webserver.utils.TimeLineSearch; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.convert.format.Format; import io.micronaut.http.MediaType; import io.micronaut.http.annotation.*; +import io.micronaut.http.exceptions.HttpStatusException; import io.micronaut.http.server.types.files.StreamedFile; import io.micronaut.http.sse.Event; import io.micronaut.scheduling.TaskExecutors; @@ -30,6 +36,7 @@ import static io.kestra.core.utils.DateUtils.validateTimeline; + @Validated @Controller("/api/v1/") @Requires(beans = LogRepositoryInterface.class) @@ -47,22 +54,52 @@ public class LogController { @Get(uri = "logs/search") @Operation(tags = {"Logs"}, summary = "Search for logs") public PagedResults find( - @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query, @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) int page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") @Min(1) int size, @Parameter(description = "The sort of current page") @Nullable @QueryValue List sort, - @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace, - @Parameter(description = "A flow id filter") @Nullable @QueryValue String flowId, - @Parameter(description = "A trigger id filter") @Nullable @QueryValue String triggerId, - @Parameter(description = "The min log level filter") @Nullable @QueryValue Level minLevel, - @Parameter(description = "The start datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, - @Parameter(description = "The end datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate - ) { - validateTimeline(startDate, endDate); + @Parameter(description = "Filters") @Nullable @QueryFilterFormat List filters, + // Deprecated params + @Parameter(description = "A string filter", deprecated = true) @Nullable @QueryValue(value = "q") String query, + @Parameter(description = "A namespace filter prefix",deprecated = true) @Nullable @QueryValue String namespace, + @Parameter(description = "A flow id filter", deprecated = true) @Nullable @QueryValue String flowId, + @Parameter(description = "A trigger id filter",deprecated = true) @Nullable @QueryValue String triggerId, + @Parameter(description = "The min log level filter", deprecated = true) @Nullable @QueryValue Level minLevel, + @Parameter(description = "The start datetime", deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, + @Parameter(description = "The end datetime", deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate + ) throws HttpStatusException { + // If filters is empty, map old params to QueryFilter + if (filters == null || filters.isEmpty()) { + filters = RequestUtils.mapLegacyParamsToFilters( + query, + namespace, + flowId, + triggerId, + minLevel, + startDate, + endDate, + null, + null, + null, + null, + null, + null); + } + final ZonedDateTime now = ZonedDateTime.now(); - return PagedResults.of( - logRepository.find(PageableUtils.from(page, size, sort), query, tenantService.resolveTenant(), namespace, flowId, triggerId, minLevel, startDate, endDate) - ); + TimeLineSearch timeLineSearch = TimeLineSearch.extractFrom(filters); + validateTimeline(timeLineSearch.getStartDate(), timeLineSearch.getEndDate()); + + ZonedDateTime resolvedStartDate = RequestUtils.resolveAbsoluteDateTime(timeLineSearch.getStartDate(), + timeLineSearch.getTimeRange(), + now); + + // Update filters with the resolved startDate + filters = QueryFilterUtils.updateFilters(filters, resolvedStartDate); + return PagedResults.of(logRepository.find( + PageableUtils.from(page, size, sort), + tenantService.resolveTenant(), + filters + )); } @ExecuteOn(TaskExecutors.IO) diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/MiscController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/MiscController.java index a1aec19d43d..3eca6dcd36e 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/MiscController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/MiscController.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonProcessingException; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.collectors.Usage; import io.kestra.core.repositories.DashboardRepositoryInterface; import io.kestra.core.repositories.ExecutionRepositoryInterface; @@ -101,6 +102,7 @@ public Configuration configuration() throws JsonProcessingException { .build() ).isBasicAuthEnabled(basicAuthService.isEnabled()) .systemNamespace(namespaceUtils.getSystemFlowNamespace()) + .resourceToFilters(QueryFilter.Resource.asResourceList()) .hiddenLabelsPrefixes(hiddenLabelsPrefixes); if (this.environmentName != null || this.environmentColor != null) { @@ -166,6 +168,8 @@ public static class Configuration { String systemNamespace; List hiddenLabelsPrefixes; + // List of filter by component + List resourceToFilters; } @Value diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/TaskRunController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/TaskRunController.java index ec07cbec262..97bb564a5c1 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/TaskRunController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/TaskRunController.java @@ -1,22 +1,25 @@ package io.kestra.webserver.controllers.api; -import com.google.common.annotations.VisibleForTesting; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.executions.TaskRun; import io.kestra.core.models.flows.State; import io.kestra.core.repositories.ExecutionRepositoryInterface; import io.kestra.core.tenant.TenantService; +import io.kestra.webserver.converters.QueryFilterFormat; import io.kestra.webserver.responses.PagedResults; import io.kestra.webserver.utils.PageableUtils; +import io.kestra.webserver.utils.QueryFilterUtils; import io.kestra.webserver.utils.RequestUtils; +import io.kestra.webserver.utils.TimeLineSearch; import io.micronaut.context.annotation.Requires; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.convert.format.Format; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.QueryValue; +import io.micronaut.http.exceptions.HttpStatusException; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -45,48 +48,58 @@ public PagedResults findTaskRun( @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) int page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") @Min(1) int size, @Parameter(description = "The sort of current page") @Nullable @QueryValue List sort, - @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query, - @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace, - @Parameter(description = "A flow id filter") @Nullable @QueryValue String flowId, - @Parameter(description = "The start datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, - @Parameter(description = "The end datetime") @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate, - @Parameter(description = "A time range filter relative to the current time", examples = { + @Parameter(description = "Filters") @QueryFilterFormat List filters, + // Deprecated params + @Parameter(description = "A string filter",deprecated = true) @Nullable @QueryValue(value = "q") String query, + @Parameter(description = "A namespace filter prefix", deprecated = true) @Nullable @QueryValue String namespace, + @Parameter(description = "A flow id filter",deprecated = true) @Nullable @QueryValue String flowId, + @Parameter(description = "The start datetime",deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime startDate, + @Parameter(description = "The end datetime", deprecated = true) @Nullable @Format("yyyy-MM-dd'T'HH:mm[:ss][.SSS][XXX]") @QueryValue ZonedDateTime endDate, + @Parameter(description = "A time range filter relative to the current time", deprecated = true, examples = { @ExampleObject(name = "Filter last 5 minutes", value = "PT5M"), @ExampleObject(name = "Filter last 24 hours", value = "P1D") }) @Nullable @QueryValue Duration timeRange, - @Parameter(description = "A state filter") @Nullable @QueryValue List state, - @Parameter(description = "A labels filter as a list of 'key:value'") @Nullable @QueryValue @Format("MULTI") List labels, - @Parameter(description = "The trigger execution id") @Nullable @QueryValue String triggerExecutionId, - @Parameter(description = "A execution child filter") @Nullable @QueryValue ExecutionRepositoryInterface.ChildFilter childFilter - ) { - validateTimeline(startDate, endDate); + @Parameter(description = "A state filter",deprecated = true) @Nullable @QueryValue List state, + @Parameter(description = "A labels filter as a list of 'key:value'", deprecated = true) @Nullable @QueryValue @Format("MULTI") List labels, + @Parameter(description = "The trigger execution id",deprecated = true) @Nullable @QueryValue String triggerExecutionId, + @Parameter(description = "A execution child filter", deprecated = true) @Nullable @QueryValue ExecutionRepositoryInterface.ChildFilter childFilter + ) throws HttpStatusException { + + // If filters is empty, map old params to QueryFilter + if (filters == null || filters.isEmpty()) { + filters = RequestUtils.mapLegacyParamsToFilters( + query, + namespace, + flowId, + triggerExecutionId, + null, + startDate, + endDate, + null, + labels, + timeRange, + childFilter, + state, + null); + } final ZonedDateTime now = ZonedDateTime.now(); + TimeLineSearch timeLineSearch = TimeLineSearch.extractFrom(filters); + validateTimeline(timeLineSearch.getStartDate(), timeLineSearch.getEndDate()); + + ZonedDateTime resolvedStartDate = RequestUtils.resolveAbsoluteDateTime(timeLineSearch.getStartDate(), + timeLineSearch.getTimeRange(), + now); + + // Update filters with the resolved startDate + filters = QueryFilterUtils.updateFilters(filters, resolvedStartDate); + return PagedResults.of(executionRepository.findTaskRun( - PageableUtils.from(page, size, sort, executionRepository.sortMapping()), - query, + PageableUtils.from(page, size, sort), tenantService.resolveTenant(), - namespace, - flowId, - resolveAbsoluteDateTime(startDate, timeRange, now), - endDate, - state, - RequestUtils.toMap(labels), - triggerExecutionId, - childFilter + filters )); } - @VisibleForTesting - ZonedDateTime resolveAbsoluteDateTime(ZonedDateTime absoluteDateTime, Duration timeRange, ZonedDateTime now) { - if (timeRange != null) { - if (absoluteDateTime != null) { - throw new IllegalArgumentException("Parameters 'startDate' and 'timeRange' are mutually exclusive"); - } - return now.minus(timeRange.abs()); - } - - return absoluteDateTime; - } } diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/TriggerController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/TriggerController.java index 773aa73bc54..4136fe9edd1 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/TriggerController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/TriggerController.java @@ -1,5 +1,6 @@ package io.kestra.webserver.controllers.api; +import io.kestra.core.models.QueryFilter; import io.kestra.core.models.conditions.ConditionContext; import io.kestra.core.models.executions.ExecutionKilled; import io.kestra.core.models.executions.ExecutionKilledTrigger; @@ -15,9 +16,11 @@ import io.kestra.core.services.ConditionService; import io.kestra.core.tenant.TenantService; import io.kestra.plugin.core.trigger.Schedule; +import io.kestra.webserver.converters.QueryFilterFormat; import io.kestra.webserver.responses.BulkResponse; import io.kestra.webserver.responses.PagedResults; import io.kestra.webserver.utils.PageableUtils; +import io.kestra.webserver.utils.RequestUtils; import io.micronaut.core.annotation.Nullable; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; @@ -74,19 +77,37 @@ public PagedResults search( @Parameter(description = "The current page") @QueryValue(defaultValue = "1") @Min(1) int page, @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") @Min(1) int size, @Parameter(description = "The sort of current page") @Nullable @QueryValue List sort, - @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query, - @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace, - @Parameter(description = "The identifier of the worker currently evaluating the trigger") @Nullable @QueryValue String workerId, - @Parameter(description = "The flow identifier") @Nullable @QueryValue String flowId - ) throws HttpStatusException { + @Parameter(description = "Filters") @QueryFilterFormat List filters, + // Deprecated params + @Parameter(description = "A string filter",deprecated = true) @Nullable @QueryValue(value = "q") String query, + @Parameter(description = "A namespace filter prefix", deprecated = true) @Nullable @QueryValue String namespace, + @Parameter(description = "The identifier of the worker currently evaluating the trigger", deprecated = true) @Nullable @QueryValue String workerId, + @Parameter(description = "The flow identifier",deprecated = true) @Nullable @QueryValue String flowId + + ) throws HttpStatusException { + // If filters is empty, map old params to QueryFilter + if (filters == null || filters.isEmpty()) { + filters = RequestUtils.mapLegacyParamsToFilters( + query, + namespace, + flowId, + null, + null, + null, + null, + null, + null, + null, + null, + null, + workerId); + } ArrayListTotal triggerContexts = triggerRepository.find( PageableUtils.from(page, size, sort, triggerRepository.sortMapping()), - query, tenantService.resolveTenant(), - namespace, - flowId, - workerId + filters + ); List triggers = new ArrayList<>(); diff --git a/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormat.java b/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormat.java new file mode 100644 index 00000000000..cab00fe5a89 --- /dev/null +++ b/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormat.java @@ -0,0 +1,13 @@ +package io.kestra.webserver.converters; + + +import io.micronaut.core.bind.annotation.Bindable; + +import java.lang.annotation.*; + +@Bindable +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface QueryFilterFormat { +} \ No newline at end of file diff --git a/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormatBinder.java b/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormatBinder.java new file mode 100644 index 00000000000..8b79eca11f0 --- /dev/null +++ b/webserver/src/main/java/io/kestra/webserver/converters/QueryFilterFormatBinder.java @@ -0,0 +1,73 @@ +package io.kestra.webserver.converters; + +import com.google.common.annotations.VisibleForTesting; +import io.kestra.core.models.QueryFilter; +import io.kestra.webserver.utils.RequestUtils; +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.bind.binders.AnnotatedRequestArgumentBinder; +import jakarta.inject.Singleton; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Singleton +public class QueryFilterFormatBinder implements AnnotatedRequestArgumentBinder> { + + private static final Pattern FILTER_PATTERN = Pattern.compile("filters\\[(.*?)\\]\\[(.*?)](?:\\[(\\w+)])?"); + + @VisibleForTesting + static List getQueryFilters(Map> queryParams) { + List filters = new ArrayList<>(); + + queryParams.forEach((key, values) -> { + if (!key.startsWith("filters[")) return; + + Matcher matcher = FILTER_PATTERN.matcher(key); + + if (matcher.matches()) { + String fieldStr = matcher.group(1); + String operationStr = matcher.group(2); + String nestedKey = matcher.group(3); // Extract nested key if present + + QueryFilter.Field field = QueryFilter.Field.fromString(fieldStr); + QueryFilter.Op operation = QueryFilter.Op.fromString(operationStr); + + Object value = nestedKey != null ? Map.of(nestedKey, values.getFirst()) : + switch (field) { + case SCOPE -> RequestUtils.toFlowScopes(values); + default -> (operation == QueryFilter.Op.IN || operation == QueryFilter.Op.NOT_IN) + ? List.of(values.getFirst().replaceAll("[\\[\\]]", "").split(",")) + : values.size() == 1 ? values.getFirst() : values; + }; + + filters.add(QueryFilter.builder() + .field(field) + .operation(operation) + .value(value) + .build()); + } + }); + + return filters; + } + + @Override + public Class getAnnotationType() { + return QueryFilterFormat.class; + } + + @Override + public BindingResult> bind(ArgumentConversionContext> context, + HttpRequest source) { + Map> queryParams = source.getParameters().asMap(); + List filters = getQueryFilters(queryParams); + + return () -> Optional.of(filters); + } + +} \ No newline at end of file diff --git a/webserver/src/main/java/io/kestra/webserver/utils/QueryFilterUtils.java b/webserver/src/main/java/io/kestra/webserver/utils/QueryFilterUtils.java new file mode 100644 index 00000000000..e939b593712 --- /dev/null +++ b/webserver/src/main/java/io/kestra/webserver/utils/QueryFilterUtils.java @@ -0,0 +1,39 @@ +package io.kestra.webserver.utils; + +import io.kestra.core.models.QueryFilter; +import lombok.Builder; +import lombok.Data; + +import java.time.ZonedDateTime; +import java.util.List; + +@Data +@Builder +public class QueryFilterUtils { + + public static List updateFilters(List filters, ZonedDateTime resolvedStartDate) { + + return filters.stream() + .filter(filter -> !isTimeRangeFilter(filter)) // Remove TIME_RANGE filter + .map(filter -> isStartDateFilter(filter) + ? createUpdatedStartDateFilter(filter, resolvedStartDate) + : filter) + .toList(); + } + + private static boolean isStartDateFilter(QueryFilter filter) { + return filter.field() == QueryFilter.Field.START_DATE; + } + + private static boolean isTimeRangeFilter(QueryFilter filter) { + return filter.field() == QueryFilter.Field.TIME_RANGE; + } + + private static QueryFilter createUpdatedStartDateFilter(QueryFilter filter, ZonedDateTime resolvedStartDate) { + return QueryFilter.builder() + .field(QueryFilter.Field.START_DATE) + .operation(filter.operation()) + .value(resolvedStartDate.toString()) + .build(); + } +} diff --git a/webserver/src/main/java/io/kestra/webserver/utils/RequestUtils.java b/webserver/src/main/java/io/kestra/webserver/utils/RequestUtils.java index 360556537e1..7ec534b37c8 100644 --- a/webserver/src/main/java/io/kestra/webserver/utils/RequestUtils.java +++ b/webserver/src/main/java/io/kestra/webserver/utils/RequestUtils.java @@ -1,11 +1,16 @@ package io.kestra.webserver.utils; +import io.kestra.core.models.QueryFilter; +import io.kestra.core.models.flows.FlowScope; +import io.kestra.core.models.flows.State; +import io.kestra.core.repositories.ExecutionRepositoryInterface; import io.micronaut.http.HttpStatus; import io.micronaut.http.exceptions.HttpStatusException; +import org.slf4j.event.Level; -import java.util.AbstractMap; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.*; import java.util.stream.Collectors; public class RequestUtils { @@ -25,4 +30,148 @@ public static Map toMap(List queryString) { }) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } + + public static List mapLegacyParamsToFilters( + String query, + String namespace, + String flowId, + String triggerId, + Level minLevel, + ZonedDateTime startDate, + ZonedDateTime endDate, + List scope, + List labels, + Duration timeRange, + ExecutionRepositoryInterface.ChildFilter childFilter, + List state, + String workerId + ) { + + List filters = new ArrayList<>(); + + if (query != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.QUERY) + .operation(QueryFilter.Op.EQUALS) + .value(query) + .build()); + } + + if (namespace != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.NAMESPACE) + .operation(QueryFilter.Op.STARTS_WITH) + .value(namespace) + .build()); + } + + if (flowId != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.FLOW_ID) + .operation(QueryFilter.Op.EQUALS) + .value(flowId) + .build()); + } + + if (triggerId != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.TRIGGER_ID) + .operation(QueryFilter.Op.EQUALS) + .value(triggerId) + .build()); + } + + if (minLevel != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.MIN_LEVEL) + .operation(QueryFilter.Op.EQUALS) + .value(minLevel.name()) + .build()); + } + + if (startDate != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.START_DATE) + .operation(QueryFilter.Op.GREATER_THAN) + .value(startDate.toString()) + .build()); + } + + if (endDate != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.END_DATE) + .operation(QueryFilter.Op.LESS_THAN) + .value(endDate.toString()) + .build()); + } + if (scope != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.SCOPE) + .operation(QueryFilter.Op.EQUALS) + .value(scope) + .build()); + } + if (labels != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.LABELS) + .operation(QueryFilter.Op.EQUALS) + .value(RequestUtils.toMap(labels)) + .build()); + } + if(timeRange != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.TIME_RANGE) + .operation(QueryFilter.Op.EQUALS) + .value(timeRange) + .build()); + } + if(childFilter != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.CHILD_FILTER) + .operation(QueryFilter.Op.EQUALS) + .value(childFilter) + .build()); + } + if(state != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.STATE) + .operation(QueryFilter.Op.IN) + .value(state) + .build()); + } + if(workerId != null) { + filters.add(QueryFilter.builder() + .field(QueryFilter.Field.WORKER_ID) + .operation(QueryFilter.Op.EQUALS) + .value(workerId) + .build()); + } + + return filters; + } + + public static List toFlowScopes(List values) { + return Arrays.stream(values.getFirst().split(",")) + .map(valueStr -> { + try { + return FlowScope.valueOf(valueStr.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid FlowScope value: " + valueStr, e); + } + }) + .collect(Collectors.toList()); + } + + + public static ZonedDateTime resolveAbsoluteDateTime(ZonedDateTime absoluteDateTime, Duration timeRange, ZonedDateTime now) { + if (timeRange != null) { + if (absoluteDateTime != null) { + throw new IllegalArgumentException("Parameters 'startDate' and 'timeRange' are mutually exclusive"); + } + return now.minus(timeRange.abs()); + } + + return absoluteDateTime; + } + } diff --git a/webserver/src/main/java/io/kestra/webserver/utils/TimeLineSearch.java b/webserver/src/main/java/io/kestra/webserver/utils/TimeLineSearch.java new file mode 100644 index 00000000000..916c0e76b5b --- /dev/null +++ b/webserver/src/main/java/io/kestra/webserver/utils/TimeLineSearch.java @@ -0,0 +1,41 @@ +package io.kestra.webserver.utils; + +import io.kestra.core.models.QueryFilter; +import lombok.Builder; +import lombok.Data; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; +import java.util.List; +@Data +@Builder +public class TimeLineSearch { + private ZonedDateTime startDate; + private ZonedDateTime endDate; + private Duration timeRange; + public static TimeLineSearch extractFrom(List filters) { + ZonedDateTime startDate = null; + ZonedDateTime endDate = null; + Duration timeRange = null; + + for (QueryFilter filter : filters) { + switch (filter.field()) { + case START_DATE -> startDate = ZonedDateTime.parse(filter.value().toString()); + case END_DATE -> endDate = ZonedDateTime.parse(filter.value().toString()); + case TIME_RANGE -> timeRange = parseDuration(filter.value().toString()); + } + } + + return new TimeLineSearch(startDate, endDate, timeRange); + } + private static Duration parseDuration(String duration) { + try { + return Duration.parse(duration); + } catch (DateTimeParseException e){ + throw new IllegalArgumentException("Invalid duration: " + duration); + } + } + + +} \ No newline at end of file diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java index c4c2d4d6586..49c54fa248e 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java @@ -1211,20 +1211,32 @@ void find() { // + is there to simulate that a space was added (this can be the case from UI autocompletion for eg.) executions = client.toBlocking().retrieve( - GET("/api/v1/executions/search?page=1&size=25&labels=url:+"+ENCODED_URL_LABEL_VALUE), PagedResults.class + GET("/api/v1/executions/search?page=1&size=25&filters[labels][$eq][url]="+ENCODED_URL_LABEL_VALUE), PagedResults.class + ); + + assertThat(executions.getTotal(), is(1L)); + + executions = client.toBlocking().retrieve( + GET("/api/v1/executions/search?page=1&size=25&labels=url:"+ENCODED_URL_LABEL_VALUE), PagedResults.class ); assertThat(executions.getTotal(), is(1L)); HttpClientResponseException e = assertThrows( HttpClientResponseException.class, - () -> client.toBlocking().retrieve(GET("/api/v1/executions/search?startDate=2024-01-07T18:43:11.248%2B01:00&timeRange=PT12H")) + () -> client.toBlocking().retrieve(GET("/api/v1/executions/search?filters[startDate][$eq]=2024-01-07T18:43:11.248%2B01:00&filters[timeRange][$eq]=PT12H")) ); assertThat(e.getStatus(), is(HttpStatus.UNPROCESSABLE_ENTITY)); assertThat(e.getResponse().getBody(String.class).isPresent(), is(true)); assertThat(e.getResponse().getBody(String.class).get(), containsString("are mutually exclusive")); + executions = client.toBlocking().retrieve( + GET("/api/v1/executions/search?filters[timeRange][$eq]=PT12H"), PagedResults.class + ); + + assertThat(executions.getTotal(), is(1L)); + executions = client.toBlocking().retrieve( GET("/api/v1/executions/search?timeRange=PT12H"), PagedResults.class ); @@ -1233,9 +1245,14 @@ void find() { e = assertThrows( HttpClientResponseException.class, - () -> client.toBlocking().retrieve(GET("/api/v1/executions/search?timeRange=P1Y")) + () -> client.toBlocking().retrieve(GET("/api/v1/executions/search?filters[timeRange][$eq]=P1Y")) ); + assertThat(e.getStatus(), is(HttpStatus.UNPROCESSABLE_ENTITY)); + e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking().retrieve(GET("/api/v1/executions/search?timeRange=P1Y")) + ); assertThat(e.getStatus(), is(HttpStatus.UNPROCESSABLE_ENTITY)); e = assertThrows( @@ -1495,9 +1512,16 @@ void getFlowFromNamespace() { @Test void badDate() { HttpClientResponseException exception = assertThrows(HttpClientResponseException.class, () -> - client.toBlocking().retrieve(GET("/api/v1/executions/search?startDate=2024-06-03T00:00:00.000%2B02:00&endDate=2023-06-05T00:00:00.000%2B02:00"), PagedResults.class)); + client.toBlocking().retrieve(GET( + "/api/v1/executions/search?filters[startDate][$eq]=2024-06-03T00:00:00.000%2B02:00&filters[endDate][$eq]=2023-06-05T00:00:00.000%2B02:00"), PagedResults.class)); assertThat(exception.getStatus().getCode(), is(422)); assertThat(exception.getMessage(),is("Illegal argument: Start date must be before End Date")); + + HttpClientResponseException exception_oldParameters = assertThrows(HttpClientResponseException.class, () -> + client.toBlocking().retrieve(GET( + "/api/v1/executions/search?startDate=2024-06-03T00:00:00.000%2B02:00&endDate=2023-06-05T00:00:00.000%2B02:00"), PagedResults.class)); + assertThat(exception_oldParameters.getStatus().getCode(), is(422)); + assertThat(exception_oldParameters.getMessage(),is("Illegal argument: Start date must be before End Date")); } @Test @@ -1539,8 +1563,12 @@ void commaInSingleLabelsValue() { assertThat(client.toBlocking().retrieve(createRequest, Execution.class).getLabels(), hasItem(new Label("project", "foo,bar"))); MutableHttpRequest searchRequest = HttpRequest - .GET("/api/v1/executions/search?labels=" + encodedCommaWithinLabel); + .GET("/api/v1/executions/search?filters[labels][$eq][project]=foo,bar"); assertThat(client.toBlocking().retrieve(searchRequest, PagedResults.class).getTotal(), is(2L)); + + MutableHttpRequest searchRequest_oldParameters = HttpRequest + .GET("/api/v1/executions/search?labels=project:foo,bar"); + assertThat(client.toBlocking().retrieve(searchRequest_oldParameters, PagedResults.class).getTotal(), is(2L)); } @Test @@ -1557,8 +1585,12 @@ void commaInOneOfMultiLabels() { )); MutableHttpRequest searchRequest = HttpRequest - .GET("/api/v1/executions/search?labels=" + encodedCommaWithinLabel + "&labels=" + encodedRegularLabel); + .GET("/api/v1/executions/search?filters[labels][$eq][project]=foo,bar" + "&filters[labels][$eq][status]=test"); assertThat(client.toBlocking().retrieve(searchRequest, PagedResults.class).getTotal(), is(1L)); + + MutableHttpRequest searchRequest_oldParameters = HttpRequest + .GET("/api/v1/executions/search?labels=project:foo,bar" + "&labels=status:test"); + assertThat(client.toBlocking().retrieve(searchRequest_oldParameters, PagedResults.class).getTotal(), is(1L)); } @Test diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/FlowControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/FlowControllerTest.java index 2fdde8b96be..c35d70ef4b1 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/api/FlowControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/FlowControllerTest.java @@ -170,8 +170,11 @@ void idNotFound() { @SuppressWarnings("unchecked") @Test void findAll() { - PagedResults flows = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/search?q=*"), Argument.of(PagedResults.class, Flow.class)); + PagedResults flows = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/search?filters[q][$eq]=*"), Argument.of(PagedResults.class, Flow.class)); assertThat(flows.getTotal(), equalTo(Helpers.FLOWS_COUNT)); + + PagedResults flows_oldParameters = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/search?q=*"), Argument.of(PagedResults.class, Flow.class)); + assertThat(flows_oldParameters.getTotal(), equalTo(Helpers.FLOWS_COUNT)); } @Test @@ -792,9 +795,13 @@ void commaInSingleLabelsValue() { String encodedCommaWithinLabel = URLEncoder.encode("project:foo,bar", StandardCharsets.UTF_8); MutableHttpRequest searchRequest = HttpRequest - .GET("/api/v1/flows/search?labels=" + encodedCommaWithinLabel); + .GET("/api/v1/flows/search?filters[labels][$eq][project]=foo,bar"); assertDoesNotThrow(() -> client.toBlocking().retrieve(searchRequest, PagedResults.class)); + MutableHttpRequest searchRequest_oldParameters = HttpRequest + .GET("/api/v1/flows/search?labels=project:foo,bar"); + assertDoesNotThrow(() -> client.toBlocking().retrieve(searchRequest_oldParameters, PagedResults.class)); + MutableHttpRequest exportRequest = HttpRequest .GET("/api/v1/flows/export/by-query?labels=" + encodedCommaWithinLabel); assertDoesNotThrow(() -> client.toBlocking().retrieve(exportRequest, byte[].class)); @@ -814,16 +821,18 @@ void commaInSingleLabelsValue() { @Test void commaInOneOfMultiLabels() { - String encodedCommaWithinLabel = URLEncoder.encode("project:foo,bar", StandardCharsets.UTF_8); - String encodedRegularLabel = URLEncoder.encode("status:test", StandardCharsets.UTF_8); Map flow = JacksonMapper.toMap(generateFlow("io.kestra.unittest", "a")); flow.put("labels", Map.of("project", "foo,bar", "status", "test")); parseFlow(client.toBlocking().retrieve(POST("/api/v1/flows", flow), String.class)); - var flows = client.toBlocking().retrieve(GET("/api/v1/flows/search?labels=" + encodedCommaWithinLabel + "&labels=" + encodedRegularLabel), Argument.of(PagedResults.class, Flow.class)); + var flows = client.toBlocking().retrieve(GET("/api/v1/flows/search?filters[labels][$eq][project]=foo,bar" + "&filters[labels][$eq][status]=test"), Argument.of(PagedResults.class, Flow.class)); assertThat(flows.getTotal(), is(1L)); + + flows = client.toBlocking().retrieve(GET("/api/v1/flows/search?labels=project:foo,bar" + "&labels=status:test"), Argument.of(PagedResults.class, Flow.class)); + assertThat(flows.getTotal(), is(1L)); + } @Test diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/LogControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/LogControllerTest.java index 3541a462282..d12fb3d8c05 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/api/LogControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/LogControllerTest.java @@ -62,12 +62,20 @@ void find() { ); assertThat(logs.getTotal(), is(3L)); + logs = client.toBlocking().retrieve( + GET("/api/v1/logs/search?filters[level][$eq]=INFO"), + Argument.of(PagedResults.class, LogEntry.class) + ); + assertThat(logs.getTotal(), is(2L)); + + // Test with old parameters logs = client.toBlocking().retrieve( GET("/api/v1/logs/search?minLevel=INFO"), Argument.of(PagedResults.class, LogEntry.class) ); assertThat(logs.getTotal(), is(2L)); + HttpClientResponseException e = assertThrows( HttpClientResponseException.class, () -> client.toBlocking().retrieve(GET("/api/v1/logs/search?page=1&size=-1")) diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/TriggerControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/TriggerControllerTest.java index 876861cf7a3..92668c2489a 100644 --- a/webserver/src/test/java/io/kestra/webserver/controllers/api/TriggerControllerTest.java +++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/TriggerControllerTest.java @@ -81,7 +81,10 @@ void search() { jdbcTriggerRepository.save(trigger); jdbcTriggerRepository.save(trigger.toBuilder().triggerId("trigger-nextexec-polling").build()); - PagedResults triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=schedule-trigger-search&namespace=io.kestra.tests&sort=triggerId:asc"), Argument.of(PagedResults.class, TriggerController.Triggers.class)); + PagedResults triggers = client.toBlocking().retrieve( + HttpRequest.GET("/api/v1/triggers/search?filters[q][$eq]=schedule-trigger-search&filters[namespace][$startsWith]=io.kestra.tests&sort=triggerId:asc"), + Argument.of(PagedResults.class, TriggerController.Triggers.class) + ); assertThat(triggers.getTotal(), greaterThanOrEqualTo(2L)); assertThat(triggers.getResults().stream().map(TriggerController.Triggers::getTriggerContext).toList(), Matchers.hasItems( @@ -97,6 +100,26 @@ void search() { ) ) ); + + PagedResults triggers_oldParameters = client.toBlocking().retrieve( + HttpRequest.GET("/api/v1/triggers/search?q=schedule-trigger-search&namespace=io.kestra.tests&sort=triggerId:asc"), + Argument.of(PagedResults.class, TriggerController.Triggers.class) + ); + assertThat(triggers_oldParameters.getTotal(), greaterThanOrEqualTo(2L)); + + assertThat(triggers_oldParameters.getResults().stream().map(TriggerController.Triggers::getTriggerContext).toList(), Matchers.hasItems( + allOf( + hasProperty("triggerId", is("trigger-nextexec-schedule")), + hasProperty("namespace", is(triggerNamespace)), + hasProperty("flowId", is(triggerFlowId)) + ), + allOf( + hasProperty("triggerId", is("trigger-nextexec-polling")), + hasProperty("namespace", is(triggerNamespace)), + hasProperty("flowId", is(triggerFlowId)) + ) + ) + ); } @Test @@ -348,11 +371,11 @@ void nextExecutionDate() throws InterruptedException, TimeoutException { Flow flow = generateFlow("flow-with-triggers"); jdbcFlowRepository.create(flow, flow.generateSource(), flow); Await.until( - () -> client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=trigger-nextexec"), Argument.of(PagedResults.class, Trigger.class)).getTotal() >= 2, + () -> client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?filters[q][$eq]=trigger-nextexec"), Argument.of(PagedResults.class, Trigger.class)).getTotal() >= 2, Duration.ofMillis(100), Duration.ofMinutes(2) ); - PagedResults triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=trigger-nextexec"), Argument.of(PagedResults.class, TriggerController.Triggers.class)); + PagedResults triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?filters[q][$eq]=trigger-nextexec"), Argument.of(PagedResults.class, TriggerController.Triggers.class)); assertThat(triggers.getResults().getFirst().getTriggerContext().getNextExecutionDate(), notNullValue()); assertThat(triggers.getResults().get(1).getTriggerContext().getNextExecutionDate(), notNullValue()); } diff --git a/webserver/src/test/java/io/kestra/webserver/converters/QueryFilterFormatBinderTest.java b/webserver/src/test/java/io/kestra/webserver/converters/QueryFilterFormatBinderTest.java new file mode 100644 index 00000000000..35c8b151b96 --- /dev/null +++ b/webserver/src/test/java/io/kestra/webserver/converters/QueryFilterFormatBinderTest.java @@ -0,0 +1,112 @@ +package io.kestra.webserver.converters; + +import io.kestra.core.models.QueryFilter; +import io.kestra.webserver.utils.RequestUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.uri.UriBuilder; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class QueryFilterFormatBinderTest { + + @Test + void testGetQueryFiltersWithSimpleFilters() { + // GIVEN + Map> queryParams = Map.of( + "filters[namespace][$eq]", List.of("test-namespace"), + "filters[startDate][$gte]", List.of("2024-01-01T00:00:00Z"), + "filters[state][$in]", List.of("[RUNNING,FAILED]") + ); + + //WHEN + List filters = QueryFilterFormatBinder.getQueryFilters(queryParams); + + // THEN + assertEquals(3, filters.size()); + + assertTrue(filters.stream().anyMatch(f -> + f.field() == QueryFilter.Field.NAMESPACE && f.operation() == QueryFilter.Op.EQUALS && f.value().equals("test-namespace") + )); + + assertTrue(filters.stream().anyMatch(f -> + f.field() == QueryFilter.Field.START_DATE && f.operation() == QueryFilter.Op.GREATER_THAN && f.value().equals("2024-01-01T00:00:00Z") + )); + + assertTrue(filters.stream().anyMatch(f -> + f.field() == QueryFilter.Field.STATE && f.operation() == QueryFilter.Op.IN && f.value().equals(List.of("RUNNING", "FAILED")) + )); + } + + @Test + void testGetQueryFiltersWithNestedFilters() { + // GIVEN + Map> queryParams = Map.of( + "filters[labels][$eq][key]", List.of("value") + ); + + // WHEN + List filters = QueryFilterFormatBinder.getQueryFilters(queryParams); + + // THEN + assertEquals(1, filters.size()); + + QueryFilter filter = filters.get(0); + assertEquals(QueryFilter.Field.LABELS, filter.field()); + assertEquals(QueryFilter.Op.EQUALS, filter.operation()); + assertEquals(Map.of("key", "value"), filter.value()); + } + + @Test + void testGetQueryFiltersWithScopeParsing() { + // GIVEN + Map> queryParams = Map.of( + "filters[scope][$eq]", List.of("USER,SYSTEM") + ); + // WHEN + List filters = QueryFilterFormatBinder.getQueryFilters(queryParams); + // THEN + assertEquals(1, filters.size()); + assertEquals(QueryFilter.Field.SCOPE, filters.get(0).field()); + assertEquals(RequestUtils.toFlowScopes(List.of("USER,SYSTEM")), filters.get(0).value()); + } + + @Test + void testBindHttpRequest() { + // GIVEN + HttpRequest request = HttpRequest.GET(UriBuilder.of("/") + .queryParam("filters[namespace][$eq]", "test-namespace") + .queryParam("filters[state][$in]", "[RUNNING,FAILED]") + .build()); + + // WHEN + QueryFilterFormatBinder binder = new QueryFilterFormatBinder(); + List filters = binder.bind(null, request).get(); + + // THEN + assertEquals(2, filters.size()); + + assertTrue(filters.stream().anyMatch(f -> + f.field() == QueryFilter.Field.NAMESPACE && f.operation() == QueryFilter.Op.EQUALS && f.value().equals("test-namespace") + )); + + assertTrue(filters.stream().anyMatch(f -> + f.field() == QueryFilter.Field.STATE && f.operation() == QueryFilter.Op.IN && f.value().equals(List.of("RUNNING", "FAILED")) + )); + } + + @Test + void testGetQueryFiltersWithInvalidFilterPattern() { + // GIVEN + Map> queryParams = Map.of( + "filters[invalid]", List.of("test-value") + ); + // WHEN + List filters = QueryFilterFormatBinder.getQueryFilters(queryParams); + // THEN + assertEquals(0, filters.size(), "Invalid filters should be ignored"); + } +} diff --git a/webserver/src/test/java/io/kestra/webserver/utils/RequestUtilsTest.java b/webserver/src/test/java/io/kestra/webserver/utils/RequestUtilsTest.java index c6de919f25d..4eade366111 100644 --- a/webserver/src/test/java/io/kestra/webserver/utils/RequestUtilsTest.java +++ b/webserver/src/test/java/io/kestra/webserver/utils/RequestUtilsTest.java @@ -1,12 +1,19 @@ package io.kestra.webserver.utils; +import io.kestra.core.models.QueryFilter; +import io.kestra.core.models.flows.FlowScope; +import io.kestra.core.models.flows.State; +import io.kestra.core.repositories.ExecutionRepositoryInterface; import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.ZonedDateTime; import java.util.List; import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.*; class RequestUtilsTest { @@ -16,4 +23,83 @@ void toMap() { assertThat(resultMap.get("timestamp"), is("2023-12-18T14:32:14Z")); } + + + @Test + void testMapLegacyParamsToFilters() { + ZonedDateTime startDate = ZonedDateTime.parse("2024-01-01T10:00:00Z"); + ZonedDateTime endDate = ZonedDateTime.parse("2024-01-02T10:00:00Z"); + Duration timeRange = Duration.ofHours(24); + List state = List.of(State.Type.RUNNING, State.Type.FAILED); + + List filters = RequestUtils.mapLegacyParamsToFilters( + "test-query", + "test-namespace", + "test-flow", + "test-trigger", + null, + startDate, + endDate, + null, + List.of("key:value"), + timeRange, + ExecutionRepositoryInterface.ChildFilter.MAIN, + state, + "worker-1" + ); + + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.QUERY && f.value().equals("test-query"))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.NAMESPACE && f.value().equals("test-namespace"))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.FLOW_ID && f.value().equals("test-flow"))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.TRIGGER_ID && f.value().equals("test-trigger"))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.START_DATE && f.value().equals(startDate.toString()))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.END_DATE && f.value().equals(endDate.toString()))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.TIME_RANGE && f.value().equals(timeRange))); + assertTrue(filters.stream().anyMatch(f -> f.field() == QueryFilter.Field.STATE && f.value().equals(state))); + } + + @Test + void testMapLegacyParamsToFiltersHandlesNulls() { + List filters = RequestUtils.mapLegacyParamsToFilters( + null, null, null, null, null, null, null, null, null, null, null, null, null + ); + + assertTrue(filters.isEmpty(), "Filters should be empty when all inputs are null."); + } + + @Test + void testToFlowScopesValid() { + List result = RequestUtils.toFlowScopes(List.of("USER,SYSTEM")); + + assertEquals(2, result.size()); + assertTrue(result.contains(FlowScope.USER)); + assertTrue(result.contains(FlowScope.SYSTEM)); + } + + @Test + void testToFlowScopesInvalidValue() { + Exception exception = assertThrows(IllegalArgumentException.class, () -> + RequestUtils.toFlowScopes(List.of("INVALID_SCOPE")) + ); + + assertTrue(exception.getMessage().contains("Invalid FlowScope value")); + } + + @Test + void testResolveAbsoluteDateTimeWithTimeRange() { + ZonedDateTime now = ZonedDateTime.parse("2024-01-10T10:00:00Z"); + Duration timeRange = Duration.ofDays(7); + ZonedDateTime resolved = RequestUtils.resolveAbsoluteDateTime(null, timeRange, now); + + assertEquals(now.minus(timeRange), resolved); + } + + @Test + void testResolveAbsoluteDateTimeWithAbsoluteDate() { + ZonedDateTime fixedDate = ZonedDateTime.parse("2024-01-01T10:00:00Z"); + ZonedDateTime result = RequestUtils.resolveAbsoluteDateTime(fixedDate, null, ZonedDateTime.now()); + + assertEquals(fixedDate, result); + } + } \ No newline at end of file diff --git a/webserver/src/test/java/io/kestra/webserver/utils/TimeLineSearchTest.java b/webserver/src/test/java/io/kestra/webserver/utils/TimeLineSearchTest.java new file mode 100644 index 00000000000..8df56b29a13 --- /dev/null +++ b/webserver/src/test/java/io/kestra/webserver/utils/TimeLineSearchTest.java @@ -0,0 +1,84 @@ +package io.kestra.webserver.utils; + + +import io.kestra.core.models.QueryFilter; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TimeLineSearchTest { + + @Test + void testExtractFrom() { + // GIVEN + ZonedDateTime startDate = ZonedDateTime.parse("2024-01-01T10:00:00Z"); + ZonedDateTime endDate = ZonedDateTime.parse("2024-01-02T10:00:00Z"); + Duration timeRange = Duration.ofHours(24); + + List filters = List.of( + QueryFilter.builder().field(QueryFilter.Field.START_DATE).operation(QueryFilter.Op.EQUALS).value(startDate.toString()).build(), + QueryFilter.builder().field(QueryFilter.Field.END_DATE).operation(QueryFilter.Op.EQUALS).value(endDate.toString()).build(), + QueryFilter.builder().field(QueryFilter.Field.TIME_RANGE).operation(QueryFilter.Op.EQUALS).value(timeRange.toString()).build() + ); + // WHEN + TimeLineSearch result = TimeLineSearch.extractFrom(filters); + // THEN + assertNotNull(result); + assertEquals(startDate, result.getStartDate()); + assertEquals(endDate, result.getEndDate()); + assertEquals(timeRange, result.getTimeRange()); + } + + @Test + void testExtractFromWithInvalidDuration() { + // GIVEN + List filters = List.of( + QueryFilter.builder().field(QueryFilter.Field.TIME_RANGE).operation(QueryFilter.Op.EQUALS).value("invalid-duration").build() + ); + // WHEN + Exception exception = assertThrows(IllegalArgumentException.class, () -> TimeLineSearch.extractFrom(filters)); + // THEN + assertTrue(exception.getMessage().contains("Invalid duration")); + } + + @Test + void testUpdateFiltersRemovesTimeRange() { + // GIVEN + ZonedDateTime startDate = ZonedDateTime.parse("2024-01-01T10:00:00Z"); + ZonedDateTime newStartDate = ZonedDateTime.parse("2024-01-02T10:00:00Z"); + + List filters = List.of( + QueryFilter.builder().field(QueryFilter.Field.START_DATE).operation(QueryFilter.Op.EQUALS).value(startDate.toString()).build(), + QueryFilter.builder().field(QueryFilter.Field.TIME_RANGE).operation(QueryFilter.Op.EQUALS).value(Duration.ofHours(24).toString()).build() + ); + // WHEN + List updatedFilters = QueryFilterUtils.updateFilters(filters, newStartDate); + // THEN + assertEquals(1, updatedFilters.size()); // TIME_RANGE filter should be removed + assertEquals(QueryFilter.Field.START_DATE, updatedFilters.get(0).field()); + assertEquals(newStartDate.toString(), updatedFilters.get(0).value()); + } + + @Test + void testUpdateFiltersKeepsUnrelatedFilters() { + // GIVEN + ZonedDateTime startDate = ZonedDateTime.parse("2024-01-01T10:00:00Z"); + ZonedDateTime newStartDate = ZonedDateTime.parse("2024-01-02T10:00:00Z"); + + List filters = List.of( + QueryFilter.builder().field(QueryFilter.Field.START_DATE).operation(QueryFilter.Op.EQUALS).value(startDate.toString()).build(), + QueryFilter.builder().field(QueryFilter.Field.END_DATE).operation(QueryFilter.Op.EQUALS).value("2024-01-03T10:00:00Z").build(), + QueryFilter.builder().field(QueryFilter.Field.TIME_RANGE).operation(QueryFilter.Op.EQUALS).value(Duration.ofHours(24).toString()).build() + ); + // WHEN + List updatedFilters = QueryFilterUtils.updateFilters(filters, newStartDate); + // THEN + assertEquals(2, updatedFilters.size()); // TIME_RANGE should be removed, others should stay + assertTrue(updatedFilters.stream().anyMatch(f -> f.field() == QueryFilter.Field.START_DATE)); + assertTrue(updatedFilters.stream().anyMatch(f -> f.field() == QueryFilter.Field.END_DATE)); + } +} \ No newline at end of file