diff --git a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/EnumerationMapField.groovy b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/EnumerationMapField.groovy index 127fa3b499..01d147feb8 100644 --- a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/EnumerationMapField.groovy +++ b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/EnumerationMapField.groovy @@ -11,12 +11,12 @@ class EnumerationMapField extends MapOptionsField { super() } - EnumerationMapField(Map choices) { - super(choices) + EnumerationMapField(Map options) { + super(options) } - EnumerationMapField(Map choices, String defaultValue) { - super(choices) + EnumerationMapField(Map options, String defaultValue) { + super(options) this.defaultValue = defaultValue } @@ -45,6 +45,22 @@ class EnumerationMapField extends MapOptionsField { super.setDefaultValue(defaultValue) } + /** + * Returns the internationalized string value corresponding to the currently selected option key. + *

+ * This method retrieves the {@link I18nString} from the options map that corresponds to the + * current value of this field. + *

+ * + * @return the {@link I18nString} object representing the internationalized value of the selected + * option, or {@code null} if the field's value is null or if no matching option exists. + */ + I18nString getI18nValue() { + if (this.getValue() == null) { + return null; + } + return this.options?.get(this.getValue()) + } @Override Field clone() { diff --git a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MultichoiceMapField.groovy b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MultichoiceMapField.groovy index 26c120330e..8e010f2673 100644 --- a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MultichoiceMapField.groovy +++ b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/MultichoiceMapField.groovy @@ -11,13 +11,13 @@ class MultichoiceMapField extends MapOptionsField() } - MultichoiceMapField(Map choices) { - super(choices) + MultichoiceMapField(Map options) { + super(options) this.defaultValue = new LinkedHashSet<>() } - MultichoiceMapField(Map choices, LinkedHashSet defaultValues) { - this(choices) + MultichoiceMapField(Map options, LinkedHashSet defaultValues) { + this(options) this.defaultValue = defaultValues } @@ -46,6 +46,24 @@ class MultichoiceMapField extends MapOptionsField + * This method maps each selected value (key) in the field's current value to its corresponding + * {@link I18nString} from the options map. + *

+ * + * @return a {@link LinkedHashSet} of {@link I18nString} objects representing the internationalized + * values of the selected options. Returns an empty set if options are null, empty, or if + * the field's value is null. + */ + Set getI18nValue() { + if (this.options == null || this.options.isEmpty() || this.getValue() == null) { + return new LinkedHashSet<>() + } + return this.getValue().collect { this.options[it] } as LinkedHashSet + } + @Override Field clone() { MultichoiceMapField clone = new MultichoiceMapField() diff --git a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy index afdbe5480a..0b6da43b1b 100644 --- a/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy +++ b/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy @@ -2541,6 +2541,7 @@ class ActionDelegate { *
      *     searchCase("case: processIdentifier eq 'query_test' and data.number_0.value == 3")
      *     searchCase("case: id eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
+     *     searchCase("id eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
      * 
* * @param query query language string starting with {@code case:} @@ -2560,6 +2561,7 @@ class ActionDelegate { *
      *     pagedSearchCases("cases: processIdentifier eq 'query_test' page 1 size 5 sort by title desc")
      *     pagedSearchCases("cases: author eq 'user@mail.com' and creationDate gt 2020-03-03")
+     *     pagedSearchCases("author eq 'user@mail.com' and creationDate gt 2020-03-03")
      * 
* * @param query query language string starting with {@code cases:} @@ -2579,6 +2581,7 @@ class ActionDelegate { *
      *     searchCases("cases: processIdentifier eq 'query_test' and data.boolean_0.value == true")
      *     searchCases("cases: title contains 'Test' sort by creationDate desc")
+     *     searchCases("title contains 'Test' sort by creationDate desc")
      * 
* * @param query query language string starting with {@code cases:} @@ -2597,6 +2600,7 @@ class ActionDelegate { *
      *     countCases("cases: processIdentifier eq 'query_test'")
      *     countCases("cases: data.boolean_0.value == true and data.text_0.value != '4'")
+     *     countCases("data.boolean_0.value == true and data.text_0.value != '4'")
      * 
* * @param query query language string starting with {@code cases:} @@ -2615,6 +2619,7 @@ class ActionDelegate { *
      *     existsCase("cases: processIdentifier eq 'query_test'")
      *     existsCase("cases: id in ('5f9b1c2d3e4f5a6b7c8d9e0f', '5f9b1c2d3e4f5a6b7c8d9e10')")
+     *     existsCase("id in ('5f9b1c2d3e4f5a6b7c8d9e0f', '5f9b1c2d3e4f5a6b7c8d9e10')")
      * 
* * @param query query language string starting with {@code cases:} @@ -2633,6 +2638,7 @@ class ActionDelegate { *
      *     searchTask("task: transitionId eq 't1' and caseId eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
      *     searchTask("task: id eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
+     *     searchTask("id eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
      * 
* * @param query query language string starting with {@code task:} @@ -2652,6 +2658,7 @@ class ActionDelegate { *
      *     pagedSearchTasks("tasks: title eq 'test' page 0 size 10 sort by lastFinish desc")
      *     pagedSearchTasks("tasks: userId eq 'user1' and state eq enabled")
+     *     pagedSearchTasks("userId eq 'user1' and state eq enabled")
      * 
* * @param query query language string starting with {@code tasks:} @@ -2671,6 +2678,7 @@ class ActionDelegate { *
      *     searchTasks("tasks: processId eq 'my_process' and userId in ('user1', 'user2')")
      *     searchTasks("tasks: title contains 'Approve' sort by title asc")
+     *     searchTasks("title contains 'Approve' sort by title asc")
      * 
* * @param query query language string starting with {@code tasks:} @@ -2689,6 +2697,7 @@ class ActionDelegate { *
      *     countTasks("tasks: caseId eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
      *     countTasks("tasks: transitionId eq 't1' and userId eq 'user1'")
+     *     countTasks("transitionId eq 't1' and userId eq 'user1'")
      * 
* * @param query query language string starting with {@code tasks:} @@ -2707,6 +2716,7 @@ class ActionDelegate { *
      *     existsTask("tasks: caseId eq '5f9b1c2d3e4f5a6b7c8d9e0f'")
      *     existsTask("tasks: transitionId eq 't1' and userId not eq 'user1'")
+     *     existsTask("transitionId eq 't1' and userId not eq 'user1'")
      * 
* * @param query query language string starting with {@code tasks:} @@ -2725,6 +2735,7 @@ class ActionDelegate { *
      *     searchProcess("process: identifier == 'query_test'")
      *     searchProcess("process: identifier eq 'my_process' and version eq 1.0.0")
+     *     searchProcess("identifier eq 'my_process' and version eq 1.0.0")
      * 
* * @param query query language string starting with {@code process:} @@ -2744,6 +2755,7 @@ class ActionDelegate { *
      *     pagedSearchProcesses("processes: identifier eq 'my_process' page 0 size 10 sort by version desc")
      *     pagedSearchProcesses("processes: version in (1.0.0 : 2.0.0)")
+     *     pagedSearchProcesses("version in (1.0.0 : 2.0.0)")
      * 
* * @param query query language string starting with {@code processes:} @@ -2763,6 +2775,7 @@ class ActionDelegate { *
      *     searchProcesses("processes: title contains 'Test' sort by identifier asc")
      *     searchProcesses("processes: identifier in ('process_a', 'process_b')")
+     *     searchProcesses("identifier in ('process_a', 'process_b')")
      * 
* * @param query query language string starting with {@code processes:} @@ -2781,6 +2794,7 @@ class ActionDelegate { *
      *     countProcesses("processes: identifier eq 'my_process'")
      *     countProcesses("processes: version gte 1.0.0")
+     *     countProcesses("version gte 1.0.0")
      * 
* * @param query query language string starting with {@code processes:} @@ -2799,6 +2813,7 @@ class ActionDelegate { *
      *     existsProcess("processes: identifier eq 'my_process'")
      *     existsProcess("processes: version eq 1.0.0")
+     *     existsProcess("version eq 1.0.0")
      * 
* * @param query query language string starting with {@code processes:} @@ -2817,6 +2832,7 @@ class ActionDelegate { *
      *     searchUser("user: email eq 'user@mail.com'")
      *     searchUser("user: name eq 'John' and surname eq 'Doe'")
+     *     searchUser("name eq 'John' and surname eq 'Doe'")
      * 
* * @param query query language string starting with {@code user:} @@ -2836,6 +2852,7 @@ class ActionDelegate { *
      *     pagedSearchUsers("users: name eq 'John' page 0 size 25 sort by surname asc")
      *     pagedSearchUsers("users: email contains '@company.com'")
+     *     pagedSearchUsers("email contains '@company.com'")
      * 
* * @param query query language string starting with {@code users:} @@ -2855,6 +2872,7 @@ class ActionDelegate { *
      *     searchUsers("users: surname eq 'Doe' sort by name asc")
      *     searchUsers("users: email in ('a@mail.com', 'b@mail.com')")
+     *     searchUsers("email in ('a@mail.com', 'b@mail.com')")
      * 
* * @param query query language string starting with {@code users:} @@ -2873,6 +2891,7 @@ class ActionDelegate { *
      *     countUsers("users: email contains '@company.com'")
      *     countUsers("users: name eq 'John'")
+     *     countUsers("name eq 'John'")
      * 
* * @param query query language string starting with {@code users:} @@ -2891,6 +2910,7 @@ class ActionDelegate { *
      *     existsUser("users: email eq 'user@mail.com'")
      *     existsUser("users: name eq 'John' and surname eq 'Doe'")
+     *     existsUser("name eq 'John' and surname eq 'Doe'")
      * 
* * @param query query language string starting with {@code users:} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/AbstractResourceSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/AbstractResourceSearchService.java new file mode 100644 index 0000000000..1c2c33319c --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/AbstractResourceSearchService.java @@ -0,0 +1,126 @@ +package com.netgrif.application.engine.pfql.service; + +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; + +import java.util.List; + +import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.*; +import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.buildResourcePrefix; + +/** + * Abstract base class for resource search services providing shared query pre-processing. + *

+ * Handles common pre-processing steps: + *

    + *
  1. Formatter bracket substitution – fills {@code {}} placeholders with provided arguments
  2. + *
  3. PFQL prefix check/injection – implemented individually by each subclass
  4. + *
+ */ +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractResourceSearchService implements IResourceSearchService { + + protected final QueryLangPlaceholderHandler placeholderHandler; + + /** + * Pre-processes the raw query string: fills {@code {}} placeholders and ensures a correct PFQL prefix. + * + * @param rawQuery the raw query string, possibly with {@code {}} placeholders + * @param isMulti if the prefix should address multiple resources + * @param args arguments to substitute into {@code {}} placeholders (in order) + * @return the fully pre-processed query string ready for evaluation + */ + protected String preProcess(String rawQuery, boolean isMulti, Object... args) { + String formatted = formatPlaceholders(rawQuery, placeholderHandler, args); + return ensurePrefix(formatted, isMulti); + } + + + protected abstract String ensurePrefix(String query, boolean isMulti); + + protected abstract Resource doSearchOne(QueryLangEvaluator evaluator); + + protected abstract Page doSearchAll(QueryLangEvaluator evaluator); + + protected abstract long doCount(QueryLangEvaluator evaluator); + + protected abstract boolean doExists(QueryLangEvaluator evaluator); + + /** + * Ensures the query string has the correct PFQL resource prefix. + * Each implementation defines which prefix is expected and how to inject it if missing. + * + * @param query the query string after placeholder substitution + * @param isMulti if the prefix should address multiple resources + * @param multiPrefixToken token of prefix to search multiple resources + * @param singlePrefixToken token of prefix to search single resource + * @return the query string with the correct prefix guaranteed + */ + protected String doEnsurePrefix(String query, boolean isMulti, int multiPrefixToken, int singlePrefixToken) { + if (query == null || hasResourcePrefix(query, List.of(multiPrefixToken, singlePrefixToken))) { + return query; + } + return buildResourcePrefix(isMulti ? multiPrefixToken : singlePrefixToken) + query; + } + + @Override + public Resource searchOne(String queryString, Object... args) { + final String processedQuery = preProcess(queryString, false, args); + log.debug("Searching one with query: {}", processedQuery); + return searchOne(evaluateQuery(processedQuery)); + } + + @Override + public Resource searchOne(QueryLangEvaluator evaluator) { + checkEvaluatorNotNull(evaluator); + checkEvaluatorIsSingle(evaluator); + checkEvaluatorResourceType(evaluator); + return doSearchOne(evaluator); + } + + @Override + public Page searchAll(String queryString, Object... args) { + final String processedQuery = preProcess(queryString, true, args); + log.debug("Searching all with query: {}", processedQuery); + return searchAll(evaluateQuery(processedQuery)); + } + + @Override + public Page searchAll(QueryLangEvaluator evaluator) { + checkEvaluatorNotNull(evaluator); + checkEvaluatorIsMultiple(evaluator); + checkEvaluatorResourceType(evaluator); + return doSearchAll(evaluator); + } + + @Override + public long count(String queryString, Object... args) { + final String processedQuery = preProcess(queryString, true, args); + log.debug("Counting with query: {}", processedQuery); + return count(evaluateQuery(processedQuery)); + } + + @Override + public long count(QueryLangEvaluator evaluator) { + checkEvaluatorNotNull(evaluator); + checkEvaluatorResourceType(evaluator); + return doCount(evaluator); + } + + @Override + public boolean exists(String queryString, Object... args) { + final String processedQuery = preProcess(queryString, false, args); + log.debug("Checking existence with query: {}", processedQuery); + return exists(evaluateQuery(processedQuery)); + } + + @Override + public boolean exists(QueryLangEvaluator evaluator) { + checkEvaluatorNotNull(evaluator); + checkEvaluatorResourceType(evaluator); + return doExists(evaluator); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/IResourceSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/IResourceSearchService.java index b097588e24..57846900b3 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/IResourceSearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/IResourceSearchService.java @@ -23,18 +23,134 @@ */ public interface IResourceSearchService { + /** + * Returns the resource type that this search service is designed to handle. + *

+ * This method identifies the specific {@link QueryType} associated with the resource + * managed by implementations of this service. It is used for validation to ensure + * that query evaluators match the expected resource type. + *

+ * + * @return the {@link QueryType} representing the resource type handled by this service + */ QueryType getQueryResourceType(); - Resource searchOne(String queryString); + /** + * Searches for a single resource using a query string with optional placeholder arguments. + *

+ * The query string may contain placeholders that will be replaced with the provided arguments. + * This method parses and evaluates the query string, then executes the search operation. + *

+ * + * @param queryString the query language expression to evaluate and execute + * @param args optional arguments to substitute into query placeholders + * @return the first resource matching the query, or null if no resource is found + * @throws IllegalArgumentException if the query is invalid or expects multiple results + */ + Resource searchOne(String queryString, Object... args); + + /** + * Searches for a single resource using a pre-evaluated query. + *

+ * This method executes a search operation using a {@link QueryLangEvaluator} that has + * already been evaluated and validated. The evaluator must be configured to expect + * a single result and match the service's resource type. + *

+ * + * @param evaluator the evaluated query object containing the search criteria and metadata + * @return the first resource matching the query, or null if no resource is found + * @throws IllegalArgumentException if the evaluator is null, not configured for single results, + * or has a resource type mismatch + */ Resource searchOne(QueryLangEvaluator evaluator); - Page searchAll(String queryString); + /** + * Searches for all resources matching a query string with pagination support and optional placeholder arguments. + *

+ * The query string may contain placeholders that will be replaced with the provided arguments. + * This method parses and evaluates the query string, then executes a paginated search operation. + * Results are returned in pages according to the pagination settings in the query. + *

+ * + * @param queryString the query language expression to evaluate and execute + * @param args optional arguments to substitute into query placeholders + * @return a page of resources matching the query with pagination information + * @throws IllegalArgumentException if the query is invalid or expects a single result + */ + Page searchAll(String queryString, Object... args); + + /** + * Searches for all resources matching a pre-evaluated query with pagination support. + *

+ * This method executes a paginated search operation using a {@link QueryLangEvaluator} that has + * already been evaluated and validated. The evaluator must be configured to expect + * multiple results and match the service's resource type. Pagination settings from the + * evaluator determine the page size and number. + *

+ * + * @param evaluator the evaluated query object containing the search criteria, pagination settings, and metadata + * @return a page of resources matching the query with pagination information + * @throws IllegalArgumentException if the evaluator is null, not configured for multiple results, + * or has a resource type mismatch + */ Page searchAll(QueryLangEvaluator evaluator); - long count(String queryString); + /** + * Counts the number of resources matching a query string with optional placeholder arguments. + *

+ * The query string may contain placeholders that will be replaced with the provided arguments. + * This method parses and evaluates the query string, then counts the matching resources + * without retrieving them. + *

+ * + * @param queryString the query language expression to evaluate and execute + * @param args optional arguments to substitute into query placeholders + * @return the number of resources matching the query + * @throws IllegalArgumentException if the query is invalid + */ + long count(String queryString, Object... args); + + /** + * Counts the number of resources matching a pre-evaluated query. + *

+ * This method executes a count operation using a {@link QueryLangEvaluator} that has + * already been evaluated and validated. The evaluator must match the service's resource type. + * This operation counts matching resources without retrieving them. + *

+ * + * @param evaluator the evaluated query object containing the search criteria and metadata + * @return the number of resources matching the query + * @throws IllegalArgumentException if the evaluator is null or has a resource type mismatch + */ long count(QueryLangEvaluator evaluator); - boolean exists(String queryString); + /** + * Checks if any resource exists that matches a query string with optional placeholder arguments. + *

+ * The query string may contain placeholders that will be replaced with the provided arguments. + * This method parses and evaluates the query string, then checks for the existence of at least + * one matching resource without retrieving it. + *

+ * + * @param queryString the query language expression to evaluate and execute + * @param args optional arguments to substitute into query placeholders + * @return true if at least one resource matching the query exists, false otherwise + * @throws IllegalArgumentException if the query is invalid + */ + boolean exists(String queryString, Object... args); + + /** + * Checks if any resource exists that matches a pre-evaluated query. + *

+ * This method executes an existence check using a {@link QueryLangEvaluator} that has + * already been evaluated and validated. The evaluator must match the service's resource type. + * This operation checks for the existence of at least one matching resource without retrieving it. + *

+ * + * @param evaluator the evaluated query object containing the search criteria and metadata + * @return true if at least one resource matching the query exists, false otherwise + * @throws IllegalArgumentException if the evaluator is null or has a resource type mismatch + */ boolean exists(QueryLangEvaluator evaluator); /** diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/ISearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/ISearchService.java index 1ea796443f..2e9847056c 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/ISearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/ISearchService.java @@ -1,11 +1,11 @@ package com.netgrif.application.engine.pfql.service; public interface ISearchService { - String explainQuery(String query); + String explainQuery(String query, Object... args); - Object search(String query); + Object search(String query, Object... args); - long count(String query); + long count(String query, Object... args); - boolean exists(String query); + boolean exists(String query, Object... args); } diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/SearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/SearchService.java index f9a2701701..0ef4f71c34 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/SearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/SearchService.java @@ -1,6 +1,7 @@ package com.netgrif.application.engine.pfql.service; import com.netgrif.application.engine.pfql.domain.enums.QueryType; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import com.netgrif.application.engine.pfql.service.utils.SearchUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,17 +12,19 @@ import java.util.stream.Collectors; import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; +import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.formatPlaceholders; @Slf4j @Service public class SearchService implements ISearchService { - private final Map> serviceRegistry; + protected final QueryLangPlaceholderHandler placeholderHandler; + protected final Map> serviceRegistry; - public SearchService(List> services) { + public SearchService(QueryLangPlaceholderHandler placeholderHandler, List> services) { + this.placeholderHandler = placeholderHandler; this.serviceRegistry = services.stream() .collect(Collectors.toMap(IResourceSearchService::getQueryResourceType, Function.identity())); - } /** @@ -31,9 +34,10 @@ public SearchService(List> services) { * @return a human-readable explanation of the query structure */ @Override - public String explainQuery(String input) { - log.debug("Explaining query: {}", input); - String explanation = SearchUtils.explainQuery(input); + public String explainQuery(String input, Object... args) { + final String processedQuery = formatPlaceholders(input, placeholderHandler, args); + log.debug("Explaining query: {}", processedQuery); + String explanation = SearchUtils.explainQuery(processedQuery); log.trace("Query explanation result: {}", explanation); return explanation; } @@ -47,9 +51,10 @@ public String explainQuery(String input) { * @return a single resource object or a page of resources depending on the query type */ @Override - public Object search(String input) { - log.debug("Executing search with query: {}", input); - QueryLangEvaluator evaluator = evaluateQuery(input); + public Object search(String input, Object... args) { + final String processedQuery = formatPlaceholders(input, placeholderHandler, args); + log.debug("Executing search with query: {}", processedQuery); + QueryLangEvaluator evaluator = evaluateQuery(processedQuery); log.trace("Evaluated query type: {}, multiple: {}", evaluator.getResourceType(), evaluator.getMultiple()); IResourceSearchService service = this.serviceRegistry.get(evaluator.getResourceType()); if (service == null) { @@ -67,9 +72,10 @@ public Object search(String input) { * @return the count of matching resources */ @Override - public long count(String input) { - log.debug("Counting resources with query: {}", input); - QueryLangEvaluator evaluator = evaluateQuery(input); + public long count(String input, Object... args) { + final String processedQuery = formatPlaceholders(input, placeholderHandler, args); + log.debug("Counting resources with query: {}", processedQuery); + QueryLangEvaluator evaluator = evaluateQuery(processedQuery); log.trace("Evaluated query type for count: {}", evaluator.getResourceType()); IResourceSearchService service = this.serviceRegistry.get(evaluator.getResourceType()); if (service == null) { @@ -87,9 +93,10 @@ public long count(String input) { * @return true if at least one matching resource exists, false otherwise */ @Override - public boolean exists(String input) { - log.debug("Checking existence with query: {}", input); - QueryLangEvaluator evaluator = evaluateQuery(input); + public boolean exists(String input, Object... args) { + final String processedQuery = formatPlaceholders(input, placeholderHandler, args); + log.debug("Checking existence with query: {}", processedQuery); + QueryLangEvaluator evaluator = evaluateQuery(processedQuery); log.trace("Evaluated query type for exists: {}", evaluator.getResourceType()); IResourceSearchService service = this.serviceRegistry.get(evaluator.getResourceType()); if (service == null) { diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/caseresource/CaseSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/caseresource/CaseSearchService.java index 1a480043c6..0b89150752 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/caseresource/CaseSearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/caseresource/CaseSearchService.java @@ -3,12 +3,13 @@ import com.netgrif.application.engine.auth.service.interfaces.IUserService; import com.netgrif.application.engine.elastic.service.interfaces.IElasticCaseService; import com.netgrif.application.engine.elastic.web.requestbodies.CaseSearchRequest; +import com.netgrif.application.engine.pfql.domain.antlr4.QueryLangParser; import com.netgrif.application.engine.pfql.domain.enums.QueryType; -import com.netgrif.application.engine.pfql.service.IResourceSearchService; +import com.netgrif.application.engine.pfql.service.AbstractResourceSearchService; import com.netgrif.application.engine.pfql.service.QueryLangEvaluator; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import com.netgrif.application.engine.workflow.domain.Case; import com.netgrif.application.engine.workflow.service.interfaces.IWorkflowService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.data.domain.Page; @@ -18,8 +19,6 @@ import java.util.List; -import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; - /** * Service implementation for searching and querying Case resources. * Supports both MongoDB and Elasticsearch-based searches depending on the query configuration. @@ -27,12 +26,19 @@ */ @Slf4j @Service -@RequiredArgsConstructor -public class CaseSearchService implements IResourceSearchService { - - private final IWorkflowService workflowService; - private final IElasticCaseService elasticCaseService; - private final IUserService userService; +public class CaseSearchService extends AbstractResourceSearchService { + + protected final IWorkflowService workflowService; + protected final IElasticCaseService elasticCaseService; + protected final IUserService userService; + + public CaseSearchService(QueryLangPlaceholderHandler placeholderHandler, IWorkflowService workflowService, + IElasticCaseService elasticCaseService, IUserService userService) { + super(placeholderHandler); + this.workflowService = workflowService; + this.elasticCaseService = elasticCaseService; + this.userService = userService; + } /** * Returns the query type handled by this service. @@ -44,17 +50,9 @@ public QueryType getQueryResourceType() { return QueryType.CASE; } - /** - * Searches for a single case matching the provided query string. - * The query string is evaluated and processed before execution. - * - * @param queryString the query string to be evaluated and executed - * @return the first matching Case, or null if no match is found - */ @Override - public Case searchOne(String queryString) { - log.debug("Searching for single case with query: {}", queryString); - return searchOne(evaluateQuery(queryString)); + protected String ensurePrefix(String query, boolean isMulti) { + return doEnsurePrefix(query, isMulti, QueryLangParser.CASES, QueryLangParser.CASE); } /** @@ -66,11 +64,7 @@ public Case searchOne(String queryString) { * @throws IllegalArgumentException if the evaluator is null or configured for multiple results */ @Override - public Case searchOne(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsSingle(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Case doSearchOne(QueryLangEvaluator evaluator) { log.debug("Searching for single case using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch query: {}", evaluator.getFullElasticQuery()); @@ -86,19 +80,6 @@ public Case searchOne(QueryLangEvaluator evaluator) { } } - /** - * Searches for all cases matching the provided query string. - * The query string is evaluated and processed before execution. - * - * @param queryString the query string to be evaluated and executed - * @return a Page containing all matching Cases - */ - @Override - public Page searchAll(String queryString) { - log.debug("Searching for all cases with query: {}", queryString); - return searchAll(evaluateQuery(queryString)); - } - /** * Searches for all cases using a pre-evaluated query evaluator. * Routes the search to either Elasticsearch or MongoDB based on the evaluator configuration. @@ -109,11 +90,7 @@ public Page searchAll(String queryString) { * @throws IllegalArgumentException if the evaluator is null or configured for single result */ @Override - public Page searchAll(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsMultiple(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Page doSearchAll(QueryLangEvaluator evaluator) { log.debug("Searching for all cases using {} with pagination: page={}, size={}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB", evaluator.getPageable().getPageNumber(), evaluator.getPageable().getPageSize()); @@ -130,19 +107,6 @@ public Page searchAll(QueryLangEvaluator evaluator) { } } - /** - * Counts the number of cases matching the provided query string. - * The query string is evaluated and processed before execution. - * - * @param queryString the query string to be evaluated and executed - * @return the count of matching cases - */ - @Override - public long count(String queryString) { - log.debug("Counting cases with query: {}", queryString); - return count(evaluateQuery(queryString)); - } - /** * Counts the number of cases using a pre-evaluated query evaluator. * Routes the count operation to either Elasticsearch or MongoDB based on the evaluator configuration. @@ -152,10 +116,7 @@ public long count(String queryString) { * @throws IllegalArgumentException if the evaluator is null */ @Override - public long count(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected long doCount(QueryLangEvaluator evaluator) { log.debug("Counting cases using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch count query: {}", evaluator.getFullElasticQuery()); @@ -170,19 +131,6 @@ public long count(QueryLangEvaluator evaluator) { } } - /** - * Checks whether any cases exist that match the provided query string. - * The query string is evaluated and processed before execution. - * - * @param queryString the query string to be evaluated and executed - * @return true if at least one matching case exists, false otherwise - */ - @Override - public boolean exists(String queryString) { - log.debug("Checking existence of case with query: {}", queryString); - return exists(evaluateQuery(queryString)); - } - /** * Checks whether any cases exist using a pre-evaluated query evaluator. * Routes the existence check to either Elasticsearch or MongoDB based on the evaluator configuration. @@ -192,10 +140,7 @@ public boolean exists(String queryString) { * @throws IllegalArgumentException if the evaluator is null */ @Override - public boolean exists(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected boolean doExists(QueryLangEvaluator evaluator) { log.debug("Checking existence of cases using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch exists query: {}", evaluator.getFullElasticQuery()); @@ -210,21 +155,21 @@ public boolean exists(QueryLangEvaluator evaluator) { } } - private long countCasesElastic(String elasticQuery) { + protected long countCasesElastic(String elasticQuery) { CaseSearchRequest caseSearchRequest = new CaseSearchRequest(); caseSearchRequest.query = elasticQuery; return elasticCaseService.count(List.of(caseSearchRequest), userService.getLoggedOrSystem().transformToLoggedUser(), LocaleContextHolder.getLocale(), false); } - private Page findCasesElastic(String elasticQuery, Pageable pageable) { + protected Page findCasesElastic(String elasticQuery, Pageable pageable) { CaseSearchRequest caseSearchRequest = new CaseSearchRequest(); caseSearchRequest.query = elasticQuery; return elasticCaseService.search(List.of(caseSearchRequest), userService.getLoggedOrSystem().transformToLoggedUser(), pageable, LocaleContextHolder.getLocale(), false); } - private boolean existsCasesElastic(String elasticQuery) { + protected boolean existsCasesElastic(String elasticQuery) { return countCasesElastic(elasticQuery) > 0; } } diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/BooleanPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/BooleanPlaceholderFormatter.java new file mode 100644 index 0000000000..7747723152 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/BooleanPlaceholderFormatter.java @@ -0,0 +1,24 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +/** + * Formatter implementation for Boolean placeholder values in PFQL queries. + *

+ * This formatter handles the conversion of Boolean objects into their string representation + * for use in query language placeholders. It supports both {@code true} and {@code false} values, + * converting them to their corresponding string literals. + *

+ * + * @see QueryLangPlaceholderFormatter + */ +public class BooleanPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof Boolean; + } + + @Override + public String format(Object value) { + return String.valueOf(value); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/CaseRefPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/CaseRefPlaceholderFormatter.java new file mode 100644 index 0000000000..090b2c1507 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/CaseRefPlaceholderFormatter.java @@ -0,0 +1,74 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import com.netgrif.application.engine.petrinet.domain.Component; +import com.netgrif.application.engine.petrinet.domain.dataset.CaseField; +import com.netgrif.application.engine.petrinet.domain.dataset.MapOptionsField; + +import java.util.stream.Collectors; + +/** + * Formatter implementation for CaseRef field placeholder values in PFQL queries. + *

+ * This formatter handles the conversion of case reference fields into their string representation + * for use in query language placeholders. It supports two types of case reference values: + *

+ *
    + *
  • {@link CaseField} - Direct case reference fields containing a list of case IDs
  • + *
  • {@link MapOptionsField} - Map-based option fields with a "caseref" component
  • + *
+ *

+ * The formatter converts case reference values into a comma-separated list of single-quoted + * strings enclosed in brackets, e.g., {@code ('case-id-1', 'case-id-2')}. + * Empty or null values are formatted as empty brackets {@code ()}. + *

+ * + * @see QueryLangPlaceholderFormatter + * @see CaseField + * @see MapOptionsField + */ +public class CaseRefPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return isValueCaseRef(value) || isOptionsCaseRef(value); + } + + @Override + public String format(Object value) { + if (isValueCaseRef(value)) { + CaseField field = (CaseField) value; + if (field.getValue() == null) { + return "()"; + } + return wrapInBrackets(field.getValue().stream() + .map(this::wrapInSingleQuotes) + .collect(Collectors.joining(", "))); + } + + MapOptionsField field = (MapOptionsField) value; + if (field.getOptions() == null) { + return "()"; + } + + return wrapInBrackets(field.getOptions().keySet().stream() + .map(this::wrapInSingleQuotes) + .collect(Collectors.joining(", "))); + } + + protected boolean isValueCaseRef(Object value) { + return value instanceof CaseField; + } + + protected boolean isOptionsCaseRef(Object value) { + boolean isOptionsField = value instanceof MapOptionsField; + if (isOptionsField) { + MapOptionsField field = (MapOptionsField) value; + Component component = field.getComponent(); + if (component == null) { + return false; + } + return component.getName() != null && component.getName().equals("caseref"); + } + return false; + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateListPlaceholderFormatter.java new file mode 100644 index 0000000000..79636e6b61 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateListPlaceholderFormatter.java @@ -0,0 +1,37 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for collections of date values in PFQL placeholders. + *

+ * This formatter extends {@link DatePlaceholderFormatter} to handle collections of date objects. + * It validates that all items in the collection are supported date types and formats them as a + * comma-separated list wrapped in brackets suitable for MongoDB query syntax. + *

+ *

+ * Example output: {@code [2023-01-15T10:30:00Z, 2023-02-20T14:45:00Z, 2023-03-25T08:15:00Z]} + *

+ * + * @see DatePlaceholderFormatter + */ +public class DateListPlaceholderFormatter extends DatePlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfDates = (Collection) value; + return wrapInBrackets(collOfDates.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DatePlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DatePlaceholderFormatter.java new file mode 100644 index 0000000000..5c6c28bc8b --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DatePlaceholderFormatter.java @@ -0,0 +1,61 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +/** + * Formatter for date placeholders in PFQL queries. + *

+ * This formatter handles date values that can be either {@link LocalDate} instances or + * string representations matching the ISO date format (yyyy-MM-dd). It validates and + * formats date values according to the {@code DATE} token specification defined in the + * QueryLang.g4 grammar. + *

+ *

+ * Supported date formats: + *

    + *
  • {@link LocalDate} objects - formatted to yyyy-MM-dd string
  • + *
  • String values matching pattern: yyyy-MM-dd (e.g., "2020-03-03")
  • + *
+ *

+ * + * @see QueryLangPlaceholderFormatter + * @see LocalDate + */ +public class DatePlaceholderFormatter implements QueryLangPlaceholderFormatter { + + /** + * Regex pattern derived from the {@code DATE} token defined in {@code QueryLang.g4}: + *
+     * DATE: DIGIT DIGIT DIGIT DIGIT '-' ('0' [1-9] | '1' [0-2]) '-' ('0' [1-9] | [12] DIGIT | '3' [01])
+     * 
+ * Example: {@code 2020-03-03} + *

+ * Note: If the grammar changes, this pattern must be updated accordingly. + *

+ */ + protected static final Pattern DATE_PATTERN = Pattern.compile("\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])"); + protected static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Override + public boolean supports(Object value) { + return isOfLocalDateType(value) || isOfStringType(value); + } + + @Override + public String format(Object value) { + if (isOfLocalDateType(value)) { + return ((LocalDate) value).format(DATE_FORMATTER); + } + return (String) value; + } + + protected boolean isOfStringType(Object value) { + return value instanceof String && DATE_PATTERN.matcher((String) value).matches(); + } + + protected boolean isOfLocalDateType(Object value) { + return value instanceof LocalDate; + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimeListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimeListPlaceholderFormatter.java new file mode 100644 index 0000000000..f35f57b65d --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimeListPlaceholderFormatter.java @@ -0,0 +1,37 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for handling collections of date-time values in PFQL placeholders. + *

+ * This formatter extends {@link DateTimePlaceholderFormatter} to support formatting of collections + * containing date-time objects. It validates that all items in the collection are supported date-time + * types and formats them into a bracketed, comma-separated string representation suitable for + * MongoDB queries. + *

+ *

+ * The formatter only supports non-empty collections where every element passes the parent class's + * {@link DateTimePlaceholderFormatter#supports(Object)} validation. + *

+ */ +public class DateTimeListPlaceholderFormatter extends DateTimePlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfDateTimes = (Collection) value; + return wrapInBrackets(collOfDateTimes.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimePlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimePlaceholderFormatter.java new file mode 100644 index 0000000000..2a232ec59b --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/DateTimePlaceholderFormatter.java @@ -0,0 +1,87 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.temporal.ChronoField; +import java.util.Date; +import java.util.regex.Pattern; + +/** + * Formatter for datetime placeholders in PFQL queries. + *

+ * This formatter handles datetime values in queries by converting them to a standardized string format + * that matches the {@code DATETIME} token defined in the QueryLang grammar. It supports conversion from + * multiple Java time types including {@link LocalDateTime}, {@link Date}, and pre-formatted datetime strings. + *

+ *

+ * The formatter produces datetime strings in ISO 8601-like format: {@code yyyy-MM-dd'T'HH:mm:ss[.nnnnnnnnn]}, + * where the fractional seconds part is optional and can have 1 to 9 digits. + *

+ *

+ * Examples of supported formats: + *

    + *
  • {@code 2020-03-03T20:00:00}
  • + *
  • {@code 2020-03-03T20:00:00.055}
  • + *
  • {@code 2026-09-11T14:30:45.123456789}
  • + *
+ *

+ * + * @see QueryLangPlaceholderFormatter + * @see LocalDateTime + * @see Date + */ +public class DateTimePlaceholderFormatter implements QueryLangPlaceholderFormatter { + + /** + * Regex pattern derived from the {@code DATETIME} token defined in {@code QueryLang.g4}: + *
+     * DATETIME: DATE 'T' ([01] DIGIT | '2' [0-3]) ':' [0-5] DIGIT ':' [0-5] DIGIT ('.' DIGIT+)?
+     * 
+ * Example: {@code 2020-03-03T20:00:00} or {@code 2020-03-03T20:00:00.055} + *

+ * Note: If the grammar changes, this pattern must be updated accordingly. + *

+ */ + protected static final Pattern DATETIME_PATTERN = Pattern.compile( + "\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])T([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(\\.\\d+)?" + ); + protected static final DateTimeFormatter DATETIME_FORMATTER = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd'T'HH:mm:ss") + .optionalStart() + .appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, true) + .optionalEnd() + .toFormatter(); + + @Override + public boolean supports(Object value) { + return isOfLocalDateTimeType(value) || isOfDateType(value) || isOfStringType(value); + } + + @Override + public String format(Object value) { + if (isOfLocalDateTimeType(value)) { + return ((LocalDateTime) value).format(DATETIME_FORMATTER); + } else if (isOfDateType(value)) { + return ((Date) value).toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .format(DATETIME_FORMATTER); + } + + return (String) value; + } + + protected boolean isOfStringType(Object value) { + return value instanceof String && DATETIME_PATTERN.matcher((String) value).matches(); + } + + protected boolean isOfLocalDateTimeType(Object value) { + return value instanceof LocalDateTime; + } + + protected boolean isOfDateType(Object value) { + return value instanceof Date; + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberListPlaceholderFormatter.java new file mode 100644 index 0000000000..b9b76fbfb3 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberListPlaceholderFormatter.java @@ -0,0 +1,35 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for collections of numeric values in PFQL placeholders. + *

+ * This formatter extends {@link NumberPlaceholderFormatter} to handle collections of numbers. + * It formats a collection of numeric values by converting each number individually using the + * parent formatter and joining them with commas, wrapped in brackets. + *

+ *

+ * Example: A collection [1, 2.5, 3] would be formatted as "(1, 2.5, 3)" + *

+ */ +public class NumberListPlaceholderFormatter extends NumberPlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfNumbers = (Collection) value; + return wrapInBrackets(collOfNumbers.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberPlaceholderFormatter.java new file mode 100644 index 0000000000..5782e1d559 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/NumberPlaceholderFormatter.java @@ -0,0 +1,22 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +/** + * Formatter for converting Number values to their string representation in PFQL queries. + *

+ * This formatter handles all numeric types (Integer, Long, Double, Float, etc.) by converting + * them to their string representation using {@link String#valueOf(Object)}. It is used during + * placeholder substitution in PFQL query processing to safely embed numeric values into queries. + *

+ */ +public class NumberPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof Number; + } + + @Override + public String format(Object value) { + return String.valueOf(value); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdListPlaceholderFormatter.java new file mode 100644 index 0000000000..199fdfa238 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdListPlaceholderFormatter.java @@ -0,0 +1,39 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for handling collections of ObjectId values in PFQL queries. + *

+ * This formatter extends {@link ObjectIdPlaceholderFormatter} to support formatting collections of ObjectId + * values. It validates that all items in the collection are valid + * ObjectId values and formats them as a comma-separated list wrapped in brackets. + *

+ *

+ * Example transformation: A collection containing ObjectId("507f1f77bcf86cd799439011") and + * ObjectId("507f191e810c19729de860ea") would be formatted as: + * ('507f1f77bcf86cd799439011', '507f191e810c19729de860ea') + *

+ * + * @see ObjectIdPlaceholderFormatter + */ +public class ObjectIdListPlaceholderFormatter extends ObjectIdPlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfNumbers = (Collection) value; + return wrapInBrackets(collOfNumbers.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdPlaceholderFormatter.java new file mode 100644 index 0000000000..c9a1eb411c --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/ObjectIdPlaceholderFormatter.java @@ -0,0 +1,31 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import org.bson.types.ObjectId; + +/** + * Formatter for converting MongoDB {@link ObjectId} instances into PFQL query string format. + *

+ * This formatter is part of the PFQL placeholder formatting system. + * It handles the conversion of {@link ObjectId} objects into their hexadecimal string representation + * wrapped in single quotes, making them suitable for use in MongoDB queries generated from PFQL expressions. + *

+ *

+ * When a PFQL query contains placeholder values that are {@link ObjectId} instances, this formatter + * ensures they are properly converted to their string format (e.g., {@code '507f1f77bcf86cd799439011'}) + *

+ * + * @see QueryLangPlaceholderFormatter + * @see ObjectId + */ +public class ObjectIdPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof ObjectId; + } + + @Override + public String format(Object value) { + return wrapInSingleQuotes(((ObjectId) value).toHexString()); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderFormatter.java new file mode 100644 index 0000000000..87f99763d0 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderFormatter.java @@ -0,0 +1,34 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +/** + * Interface for formatting placeholder values in PFQL queries. + *

+ * Implementations of this interface are responsible for converting Java objects into their + * string representations. Each formatter implementation + * supports specific types of objects and provides custom formatting logic for those types. + *

+ *

+ * The formatter provides utility methods for wrapping values in brackets and single quotes, + * which are commonly needed when constructing query strings. + *

+ * + * @see QueryLangPlaceholderHandler + */ +public interface QueryLangPlaceholderFormatter { + boolean supports(Object value); + String format(Object value); + + default String wrapInBrackets(String valueInBrackets) { + if (valueInBrackets == null) { + return "()"; + } + return "(" + valueInBrackets + ")"; + } + + default String wrapInSingleQuotes(Object valueToWrap) { + if (valueToWrap == null) { + return "''"; + } + return "'" + valueToWrap + "'"; + } +} \ No newline at end of file diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderHandler.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderHandler.java new file mode 100644 index 0000000000..df1960367f --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/QueryLangPlaceholderHandler.java @@ -0,0 +1,60 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service responsible for formatting placeholder values in PFQL queries based on their type. + *

+ * This handler maintains a list of specialized formatters for different data types and selects + * the appropriate formatter based on the runtime type of the value being formatted. Supported + * types include primitives (boolean, number), collections (lists), temporal types (date, datetime), + * identifiers (ObjectId, version), and reference types (case ref, task ref). + *

+ */ +@Service +public class QueryLangPlaceholderHandler { + private final List formatters; + + public QueryLangPlaceholderHandler() { + this.formatters = List.of( + new BooleanPlaceholderFormatter(), + new NumberPlaceholderFormatter(), + new NumberListPlaceholderFormatter(), + new ObjectIdPlaceholderFormatter(), + new ObjectIdListPlaceholderFormatter(), + new VersionPlaceholderFormatter(), + new VersionListPlaceholderFormatter(), + new DateTimePlaceholderFormatter(), + new DateTimeListPlaceholderFormatter(), + new DatePlaceholderFormatter(), + new DateListPlaceholderFormatter(), + new StringPlaceholderFormatter(), + new StringListPlaceholderFormatter(), + new CaseRefPlaceholderFormatter(), + new TaskRefPlaceholderFormatter() + ); + } + + /** + * Formats a placeholder value according to its runtime type. + *

+ * This method iterates through the registered formatters and uses the first one that + * supports the given value type. The formatted result is a string representation suitable + * for use in MongoDB queries. + *

+ * + * @param value the placeholder value to format; can be of various types including primitives, + * collections, temporal types, or reference types + * @return the formatted string representation of the value suitable for MongoDB queries + * @throws IllegalArgumentException if no formatter supports the given value type + */ + public String format(Object value) { + return formatters.stream() + .filter(formatter -> formatter.supports(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported placeholder value: " + value)) + .format(value); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringListPlaceholderFormatter.java new file mode 100644 index 0000000000..78c4b846d4 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringListPlaceholderFormatter.java @@ -0,0 +1,36 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for collections of strings in PFQL placeholder substitution. + *

+ * This formatter handles collections of string values by formatting each individual string + * using the parent {@link StringPlaceholderFormatter} logic and then combining them into + * a comma-separated list wrapped in brackets. + *

+ *

+ * The formatter only supports non-empty collections where all items are supported by the + * parent string formatter. + *

+ */ +public class StringListPlaceholderFormatter extends StringPlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfStrings = (Collection) value; + return wrapInBrackets(collOfStrings.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringPlaceholderFormatter.java new file mode 100644 index 0000000000..4359c1eb95 --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/StringPlaceholderFormatter.java @@ -0,0 +1,30 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +/** + * Formatter implementation for handling String value placeholders in PFQL queries. + *

+ * This formatter is responsible for converting String placeholder values into properly formatted + * query string representations by wrapping them in single quotes. It is part of the placeholder + * handling mechanism that ensures type-safe query construction. + *

+ *

+ * The formatter implements {@link QueryLangPlaceholderFormatter} and is automatically selected + * by {@link com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler} + * when processing String-typed placeholder values during query evaluation. + *

+ * + * @see QueryLangPlaceholderFormatter + * @see com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler + */ +public class StringPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof String; + } + + @Override + public String format(Object value) { + return wrapInSingleQuotes(value); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/TaskRefPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/TaskRefPlaceholderFormatter.java new file mode 100644 index 0000000000..f811ffc00e --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/TaskRefPlaceholderFormatter.java @@ -0,0 +1,36 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import com.netgrif.application.engine.petrinet.domain.dataset.TaskField; + +import java.util.stream.Collectors; + +/** + * Formatter for TaskField placeholders in PFQL queries. + *

+ * This formatter handles the conversion of {@link TaskField} values into properly formatted + * query strings. It wraps task reference values in brackets and quotes. + *

+ *

+ * If the TaskField contains null values, it returns an empty bracket pair "()". + * Otherwise, it formats each task reference value by wrapping it in single quotes and + * joining them with commas within brackets. + *

+ */ +public class TaskRefPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof TaskField; + } + + @Override + public String format(Object value) { + TaskField field = (TaskField) value; + if (field.getValue() == null) { + return "()"; + } + return wrapInBrackets(field.getValue().stream() + .map(this::wrapInSingleQuotes) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionListPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionListPlaceholderFormatter.java new file mode 100644 index 0000000000..8040d9c99a --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionListPlaceholderFormatter.java @@ -0,0 +1,38 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Formatter for collections of version values in PFQL queries. + *

+ * This formatter extends {@link VersionPlaceholderFormatter} to handle collections of version objects. + * It validates that all items in the collection are supported version values and formats them as a + * comma-separated list wrapped in brackets. + *

+ *

+ * The formatter only supports non-empty collections where every element is a valid version value + * as determined by the parent {@link VersionPlaceholderFormatter#supports(Object)} method. + *

+ * + * @see VersionPlaceholderFormatter + */ +public class VersionListPlaceholderFormatter extends VersionPlaceholderFormatter { + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public boolean supports(Object value) { + return value instanceof Collection + && !((Collection) value).isEmpty() + && ((Collection) value).stream().allMatch(item -> super.supports(item)); + } + + @Override + @SuppressWarnings("Convert2MethodRef") // method reference does not work to super calls + public String format(Object value) { + Collection collOfStrings = (Collection) value; + return wrapInBrackets(collOfStrings.stream() + .map(item -> super.format(item)) + .collect(Collectors.joining(", "))); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionPlaceholderFormatter.java b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionPlaceholderFormatter.java new file mode 100644 index 0000000000..1f6777acdd --- /dev/null +++ b/src/main/java/com/netgrif/application/engine/pfql/service/formatters/VersionPlaceholderFormatter.java @@ -0,0 +1,24 @@ +package com.netgrif.application.engine.pfql.service.formatters; + +import com.netgrif.application.engine.petrinet.domain.version.Version; + +/** + * Formatter for Version objects used in PFQL queries. + *

+ * This formatter handles the conversion of {@link Version} objects into their string + * representation for use in PFQL query placeholders. It supports Version objects and + * formats them by calling their {@code toString()} method. + *

+ */ +public class VersionPlaceholderFormatter implements QueryLangPlaceholderFormatter { + + @Override + public boolean supports(Object value) { + return value instanceof Version; + } + + @Override + public String format(Object value) { + return value.toString(); + } +} diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/processresource/ProcessSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/processresource/ProcessSearchService.java index 4f94533803..690b0ebfe4 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/processresource/ProcessSearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/processresource/ProcessSearchService.java @@ -2,25 +2,34 @@ import com.netgrif.application.engine.petrinet.domain.PetriNet; import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService; +import com.netgrif.application.engine.pfql.domain.antlr4.QueryLangParser; import com.netgrif.application.engine.pfql.domain.enums.QueryType; -import com.netgrif.application.engine.pfql.service.IResourceSearchService; +import com.netgrif.application.engine.pfql.service.AbstractResourceSearchService; import com.netgrif.application.engine.pfql.service.QueryLangEvaluator; -import lombok.RequiredArgsConstructor; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import java.util.Optional; - -import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; - +/** + * Service for searching and querying process resources using PFQL. + *

+ * This service provides methods to search for processes, count processes, and check process existence + * based on PFQL query strings or evaluated query objects. It delegates the actual MongoDB + * queries to the {@link IPetriNetService}. Future implementations will support Elasticsearch as an alternative + * search backend. + *

+ */ @Slf4j @Service -@RequiredArgsConstructor -public class ProcessSearchService implements IResourceSearchService { +public class ProcessSearchService extends AbstractResourceSearchService { - private final IPetriNetService petriNetService; + protected final IPetriNetService petriNetService; + + public ProcessSearchService(QueryLangPlaceholderHandler placeholderHandler, IPetriNetService petriNetService) { + super(placeholderHandler); + this.petriNetService = petriNetService; + } /** * Returns the query type handled by this service. @@ -32,21 +41,9 @@ public QueryType getQueryResourceType() { return QueryType.PROCESS; } - /** - * Searches for a single process that matches the provided query string. - *

- * This method parses the query string into an evaluator and delegates to - * {@link #searchOne(QueryLangEvaluator)} for execution. - *

- * - * @param queryString the query string to be evaluated and executed - * @return the matching {@link PetriNet} process, or null if no match is found - * @throws IllegalArgumentException if the query string results in a multiple-results query - */ @Override - public PetriNet searchOne(String queryString) { - log.debug("Searching for single process with query: {}", queryString); - return searchOne(evaluateQuery(queryString)); + protected String ensurePrefix(String query, boolean isMulti) { + return doEnsurePrefix(query, isMulti, QueryLangParser.PROCESSES, QueryLangParser.PROCESS); } /** @@ -62,11 +59,7 @@ public PetriNet searchOne(String queryString) { * @throws IllegalArgumentException if evaluator is null or configured for multiple results */ @Override - public PetriNet searchOne(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsSingle(evaluator); - checkEvaluatorResourceType(evaluator); - + protected PetriNet doSearchOne(QueryLangEvaluator evaluator) { // todo implement Elasticsearch search (service layer and evaluator layer) log.debug("Searching for single process using MongoDB"); @@ -76,23 +69,6 @@ public PetriNet searchOne(QueryLangEvaluator evaluator) { return result; } - /** - * Searches for all processes that match the provided query string. - *

- * This method parses the query string into an evaluator and delegates to - * {@link #searchAll(QueryLangEvaluator)} for execution. - *

- * - * @param queryString the query string to be evaluated and executed - * @return a page of matching {@link PetriNet} processes - * @throws IllegalArgumentException if the query string results in a single-result query - */ - @Override - public Page searchAll(String queryString) { - log.debug("Searching for all processes with query: {}", queryString); - return searchAll(evaluateQuery(queryString)); - } - /** * Searches for all processes using a pre-evaluated query expression. *

@@ -106,11 +82,7 @@ public Page searchAll(String queryString) { * @throws IllegalArgumentException if evaluator is null or configured for single result */ @Override - public Page searchAll(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsMultiple(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Page doSearchAll(QueryLangEvaluator evaluator) { // todo implement Elasticsearch search (service layer and evaluator layer) log.debug("Searching for all processes using MongoDB"); @@ -120,22 +92,6 @@ public Page searchAll(QueryLangEvaluator evaluator) { return result; } - /** - * Counts the number of processes that match the provided query string. - *

- * This method parses the query string into an evaluator and delegates to - * {@link #count(QueryLangEvaluator)} for execution. - *

- * - * @param queryString the query string to be evaluated and executed - * @return the count of matching processes - */ - @Override - public long count(String queryString) { - log.debug("Counting processes with query: {}", queryString); - return count(evaluateQuery(queryString)); - } - /** * Counts the number of processes using a pre-evaluated query expression. *

@@ -149,10 +105,7 @@ public long count(String queryString) { * @throws IllegalArgumentException if evaluator is null */ @Override - public long count(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected long doCount(QueryLangEvaluator evaluator) { // todo implement Elasticsearch search (service layer and evaluator layer) log.debug("Counting processes using MongoDB"); @@ -162,22 +115,6 @@ public long count(QueryLangEvaluator evaluator) { return result; } - /** - * Checks whether any processes exist that match the provided query string. - *

- * This method parses the query string into an evaluator and delegates to - * {@link #exists(QueryLangEvaluator)} for execution. - *

- * - * @param queryString the query string to be evaluated and executed - * @return true if at least one matching process exists, false otherwise - */ - @Override - public boolean exists(String queryString) { - log.debug("Checking existence of process with query: {}", queryString); - return exists(evaluateQuery(queryString)); - } - /** * Checks whether any processes exist using a pre-evaluated query expression. *

@@ -192,10 +129,7 @@ public boolean exists(String queryString) { * @throws IllegalArgumentException if evaluator is null */ @Override - public boolean exists(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected boolean doExists(QueryLangEvaluator evaluator) { // todo implement Elasticsearch search (service layer and evaluator layer) log.debug("Checking existence of processes using MongoDB"); diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/taskresource/TaskSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/taskresource/TaskSearchService.java index 4c53bba621..9e87c245bb 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/taskresource/TaskSearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/taskresource/TaskSearchService.java @@ -3,12 +3,13 @@ import com.netgrif.application.engine.auth.service.interfaces.IUserService; import com.netgrif.application.engine.elastic.service.interfaces.IElasticTaskService; import com.netgrif.application.engine.elastic.web.requestbodies.ElasticTaskSearchRequest; +import com.netgrif.application.engine.pfql.domain.antlr4.QueryLangParser; import com.netgrif.application.engine.pfql.domain.enums.QueryType; -import com.netgrif.application.engine.pfql.service.IResourceSearchService; +import com.netgrif.application.engine.pfql.service.AbstractResourceSearchService; import com.netgrif.application.engine.pfql.service.QueryLangEvaluator; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import com.netgrif.application.engine.workflow.domain.Task; import com.netgrif.application.engine.workflow.service.interfaces.ITaskService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.data.domain.Page; @@ -18,8 +19,6 @@ import java.util.List; -import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; - /** * Service implementation for searching Task resources using query language expressions. *

@@ -31,12 +30,18 @@ */ @Slf4j @Service -@RequiredArgsConstructor -public class TaskSearchService implements IResourceSearchService { - - private final ITaskService taskService; - private final IElasticTaskService elasticTaskService; - private final IUserService userService; +public class TaskSearchService extends AbstractResourceSearchService { + protected final ITaskService taskService; + protected final IElasticTaskService elasticTaskService; + protected final IUserService userService; + + public TaskSearchService(QueryLangPlaceholderHandler placeholderHandler, ITaskService taskService, + IElasticTaskService elasticTaskService, IUserService userService) { + super(placeholderHandler); + this.taskService = taskService; + this.elasticTaskService = elasticTaskService; + this.userService = userService; + } /** * Returns the query type handled by this service. @@ -48,16 +53,9 @@ public QueryType getQueryResourceType() { return QueryType.TASK; } - /** - * Searches for a single task matching the provided query string. - * - * @param queryString the query string to be evaluated and executed - * @return the matching task, or null if no task is found - */ @Override - public Task searchOne(String queryString) { - log.debug("Searching for single task with query: {}", queryString); - return searchOne(evaluateQuery(queryString)); + protected String ensurePrefix(String query, boolean isMulti) { + return doEnsurePrefix(query, isMulti, QueryLangParser.TASKS, QueryLangParser.TASK); } /** @@ -73,11 +71,7 @@ public Task searchOne(String queryString) { * @throws IllegalArgumentException if evaluator is null or if the query expects multiple results */ @Override - public Task searchOne(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsSingle(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Task doSearchOne(QueryLangEvaluator evaluator) { log.debug("Searching for single task using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch query: {}", evaluator.getFullElasticQuery()); @@ -93,18 +87,6 @@ public Task searchOne(QueryLangEvaluator evaluator) { } } - /** - * Searches for all tasks matching the provided query string. - * - * @param queryString the query string to be evaluated and executed - * @return a page of matching tasks - */ - @Override - public Page searchAll(String queryString) { - log.debug("Searching for all tasks with query: {}", queryString); - return searchAll(evaluateQuery(queryString)); - } - /** * Searches for all tasks using a pre-evaluated query expression. *

@@ -118,11 +100,7 @@ public Page searchAll(String queryString) { * @throws IllegalArgumentException if evaluator is null or if the query expects a single result */ @Override - public Page searchAll(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsMultiple(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Page doSearchAll(QueryLangEvaluator evaluator) { log.debug("Searching for all tasks using {} with pagination: page={}, size={}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB", evaluator.getPageable().getPageNumber(), evaluator.getPageable().getPageSize()); @@ -139,18 +117,6 @@ public Page searchAll(QueryLangEvaluator evaluator) { } } - /** - * Counts the number of tasks matching the provided query string. - * - * @param queryString the query string to be evaluated and executed - * @return the count of matching tasks - */ - @Override - public long count(String queryString) { - log.debug("Counting tasks with query: {}", queryString); - return count(evaluateQuery(queryString)); - } - /** * Counts the number of tasks using a pre-evaluated query expression. *

@@ -163,10 +129,7 @@ public long count(String queryString) { * @throws IllegalArgumentException if evaluator is null */ @Override - public long count(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected long doCount(QueryLangEvaluator evaluator) { log.debug("Counting tasks using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch count query: {}", evaluator.getFullElasticQuery()); @@ -181,18 +144,6 @@ public long count(QueryLangEvaluator evaluator) { } } - /** - * Checks whether any tasks exist that match the provided query string. - * - * @param queryString the query string to be evaluated and executed - * @return true if at least one matching task exists, false otherwise - */ - @Override - public boolean exists(String queryString) { - log.debug("Checking existence of task with query: {}", queryString); - return exists(evaluateQuery(queryString)); - } - /** * Checks whether any tasks exist using a pre-evaluated query expression. *

@@ -205,10 +156,7 @@ public boolean exists(String queryString) { * @throws IllegalArgumentException if evaluator is null */ @Override - public boolean exists(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected boolean doExists(QueryLangEvaluator evaluator) { log.debug("Checking existence of tasks using {}", evaluator.getSearchWithElastic() ? "Elasticsearch" : "MongoDB"); if (evaluator.getSearchWithElastic()) { log.trace("Executing Elasticsearch exists query: {}", evaluator.getFullElasticQuery()); @@ -229,7 +177,7 @@ public boolean exists(QueryLangEvaluator evaluator) { * @param elasticQuery the Elasticsearch query string * @return the count of matching tasks */ - private long countTasksElastic(String elasticQuery) { + protected long countTasksElastic(String elasticQuery) { ElasticTaskSearchRequest taskSearchRequest = new ElasticTaskSearchRequest(); taskSearchRequest.query = elasticQuery; return elasticTaskService.count(List.of(taskSearchRequest), userService.getLoggedOrSystem().transformToLoggedUser(), @@ -243,7 +191,7 @@ private long countTasksElastic(String elasticQuery) { * @param pageable the pagination information * @return a page of matching tasks */ - private Page findTasksElastic(String elasticQuery, Pageable pageable) { + protected Page findTasksElastic(String elasticQuery, Pageable pageable) { ElasticTaskSearchRequest taskSearchRequest = new ElasticTaskSearchRequest(); taskSearchRequest.query = elasticQuery; return elasticTaskService.search(List.of(taskSearchRequest), userService.getLoggedOrSystem().transformToLoggedUser(), @@ -256,7 +204,7 @@ private Page findTasksElastic(String elasticQuery, Pageable pageable) { * @param elasticQuery the Elasticsearch query string * @return true if at least one matching task exists, false otherwise */ - private boolean existsTasksElastic(String elasticQuery) { + protected boolean existsTasksElastic(String elasticQuery) { return countTasksElastic(elasticQuery) > 0; } diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/userresource/UserSearchService.java b/src/main/java/com/netgrif/application/engine/pfql/service/userresource/UserSearchService.java index ba659c3d50..eeca34b220 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/userresource/UserSearchService.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/userresource/UserSearchService.java @@ -2,16 +2,15 @@ import com.netgrif.application.engine.auth.domain.IUser; import com.netgrif.application.engine.auth.service.interfaces.IUserService; +import com.netgrif.application.engine.pfql.domain.antlr4.QueryLangParser; import com.netgrif.application.engine.pfql.domain.enums.QueryType; -import com.netgrif.application.engine.pfql.service.IResourceSearchService; +import com.netgrif.application.engine.pfql.service.AbstractResourceSearchService; import com.netgrif.application.engine.pfql.service.QueryLangEvaluator; -import lombok.RequiredArgsConstructor; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; -import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; - /** * Service for searching and querying user resources using PFQL (Process Flow Query Language). *

@@ -19,17 +18,17 @@ * based on PFQL query strings or evaluated query objects. It delegates the actual MongoDB * queries to the {@link IUserService}. *

- * - * @see IResourceSearchService - * @see IUserService - * @see QueryLangEvaluator */ @Slf4j @Service -@RequiredArgsConstructor -public class UserSearchService implements IResourceSearchService { +public class UserSearchService extends AbstractResourceSearchService { + + protected final IUserService userService; - private final IUserService userService; + public UserSearchService(QueryLangPlaceholderHandler placeholderHandler, IUserService userService) { + super(placeholderHandler); + this.userService = userService; + } /** * Returns the resource type handled by this search service. @@ -41,17 +40,9 @@ public QueryType getQueryResourceType() { return QueryType.USER; } - /** - * Searches for a single user using a PFQL query string. - * - * @param queryString the PFQL query string to search with (e.g., "user: email == 'user@example.com'") - * @return the first user matching the query, or null if no user is found - * @throws IllegalArgumentException if the query string is invalid or evaluates to a non-USER resource type - */ @Override - public IUser searchOne(String queryString) { - log.debug("Searching for single user with query: {}", queryString); - return searchOne(evaluateQuery(queryString)); + protected String ensurePrefix(String query, boolean isMulti) { + return doEnsurePrefix(query, isMulti, QueryLangParser.USERS, QueryLangParser.USER); } /** @@ -63,11 +54,7 @@ public IUser searchOne(String queryString) { * or has a resource type other than USER */ @Override - public IUser searchOne(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsSingle(evaluator); - checkEvaluatorResourceType(evaluator); - + protected IUser doSearchOne(QueryLangEvaluator evaluator) { log.debug("Searching for single user using MongoDB"); log.trace("Executing MongoDB query: {}", evaluator.getFullMongoQuery()); IUser result = userService.searchOne(evaluator.getFullMongoQuery()); @@ -75,19 +62,6 @@ public IUser searchOne(QueryLangEvaluator evaluator) { return result; } - /** - * Searches for all users matching a PFQL query string with pagination support. - * - * @param queryString the PFQL query string to search with (e.g., "users: email like '%@example.com'") - * @return a page of users matching the query - * @throws IllegalArgumentException if the query string is invalid or evaluates to a non-USER resource type - */ - @Override - public Page searchAll(String queryString) { - log.debug("Searching for all users with query: {}", queryString); - return searchAll(evaluateQuery(queryString)); - } - /** * Searches for all users matching a pre-evaluated query with pagination support. * @@ -97,11 +71,7 @@ public Page searchAll(String queryString) { * or has a resource type other than USER */ @Override - public Page searchAll(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorIsMultiple(evaluator); - checkEvaluatorResourceType(evaluator); - + protected Page doSearchAll(QueryLangEvaluator evaluator) { log.debug("Searching for all users using MongoDB with pagination: page={}, size={}", evaluator.getPageable().getPageNumber(), evaluator.getPageable().getPageSize()); log.trace("Executing MongoDB query: {}", evaluator.getFullMongoQuery()); @@ -110,19 +80,6 @@ public Page searchAll(QueryLangEvaluator evaluator) { return result; } - /** - * Counts the number of users matching a PFQL query string. - * - * @param queryString the PFQL query string to count with (e.g., "users: email like '%@example.com'") - * @return the number of users matching the query - * @throws IllegalArgumentException if the query string is invalid or evaluates to a non-USER resource type - */ - @Override - public long count(String queryString) { - log.debug("Counting users with query: {}", queryString); - return count(evaluateQuery(queryString)); - } - /** * Counts the number of users matching a pre-evaluated query. * @@ -131,10 +88,7 @@ public long count(String queryString) { * @throws IllegalArgumentException if the evaluator is null or has a resource type other than USER */ @Override - public long count(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected long doCount(QueryLangEvaluator evaluator) { log.debug("Counting users using MongoDB"); log.trace("Executing MongoDB count query: {}", evaluator.getFullMongoQuery()); long result = userService.count(evaluator.getFullMongoQuery()); @@ -142,19 +96,6 @@ public long count(QueryLangEvaluator evaluator) { return result; } - /** - * Checks if any user exists that matches a PFQL query string. - * - * @param queryString the PFQL query string to check with (e.g., "user: email == 'user@example.com'") - * @return true if at least one user matching the query exists, false otherwise - * @throws IllegalArgumentException if the query string is invalid or evaluates to a non-USER resource type - */ - @Override - public boolean exists(String queryString) { - log.debug("Checking existence of user with query: {}", queryString); - return exists(evaluateQuery(queryString)); - } - /** * Checks if any user exists that matches a pre-evaluated query. * @@ -163,10 +104,7 @@ public boolean exists(String queryString) { * @throws IllegalArgumentException if the evaluator is null or has a resource type other than USER */ @Override - public boolean exists(QueryLangEvaluator evaluator) { - checkEvaluatorNotNull(evaluator); - checkEvaluatorResourceType(evaluator); - + protected boolean doExists(QueryLangEvaluator evaluator) { log.debug("Checking existence of users using MongoDB"); log.trace("Executing MongoDB exists query: {}", evaluator.getFullMongoQuery()); boolean result = userService.exists(evaluator.getFullMongoQuery()); diff --git a/src/main/java/com/netgrif/application/engine/pfql/service/utils/SearchUtils.java b/src/main/java/com/netgrif/application/engine/pfql/service/utils/SearchUtils.java index b838499225..1837d23215 100644 --- a/src/main/java/com/netgrif/application/engine/pfql/service/utils/SearchUtils.java +++ b/src/main/java/com/netgrif/application/engine/pfql/service/utils/SearchUtils.java @@ -12,12 +12,14 @@ import com.netgrif.application.engine.pfql.service.QueryLangErrorListener; import com.netgrif.application.engine.pfql.service.QueryLangEvaluator; import com.netgrif.application.engine.pfql.service.QueryLangExplainEvaluator; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.Predicate; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.DateTimePath; import com.querydsl.core.types.dsl.StringPath; import lombok.extern.slf4j.Slf4j; +import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CharStreams; import org.antlr.v4.runtime.CommonTokenStream; import org.antlr.v4.runtime.Token; @@ -39,6 +41,8 @@ public class SearchUtils { public static final List validQueryResourcePrefixes = List.of("case", "cases", "task", "tasks", "process", "processes", "user", "users"); + private static final String QUERY_DELIMITER = ": "; + public static final Map> comparisonOperators = Map.of( ComparisonType.ID, List.of(QueryLangParser.EQ, QueryLangParser.NEQ, QueryLangParser.IN), ComparisonType.STRING, List.of(QueryLangParser.EQ, QueryLangParser.NEQ, QueryLangParser.CONTAINS, QueryLangParser.LT, QueryLangParser.LTE, QueryLangParser.GT, QueryLangParser.GTE), @@ -452,4 +456,87 @@ public static String buildElasticQueryInRange(String attribute, String leftValue + ")"; return not ? "NOT " + query : query; } + + /** + * Fills {@code {}} placeholders in the query string with the provided arguments, in order. + * Uses {@link String#format} semantics by replacing {@code {}} with {@code %s} internally. + * + * @param query the query string with optional {@code {}} placeholders + * @param args values to substitute + * @return the query string with placeholders filled + */ + public static String formatPlaceholders(String query, QueryLangPlaceholderHandler handler, Object... args) { + if (args == null || args.length == 0) { + return query; + } + if (query == null) { + throw new IllegalArgumentException("Query cannot be null when placeholder arguments are provided."); + } + + StringBuilder result = new StringBuilder(query); + int argIndex = 0; + int searchFrom = 0; + + while (argIndex < args.length) { + int idx = result.indexOf("{}", searchFrom); + if (idx == -1) { + break; + } + String replacement = handler.format(args[argIndex++]); + result.replace(idx, idx + 2, replacement); + searchFrom = idx + replacement.length(); + } + if (argIndex < args.length) { + throw new IllegalArgumentException( + "Too many placeholder arguments supplied: expected " + argIndex + " but got " + args.length + "."); + } + if (result.indexOf("{}", searchFrom) != -1) { + throw new IllegalArgumentException("Too many placeholders present: not enough arguments provided."); + } + return result.toString(); + } + + /** + * Checks if a PFQL query string begins with a resource token that matches one of the expected token types. + * The method tokenizes the trimmed query and compares the first token's type against the provided list. + * + * @param query the PFQL query string to check (will be trimmed before tokenization) + * @param expectedTokenTypes a list of token type constants (e.g., {@link QueryLangParser#CASE}, + * {@link QueryLangParser#TASK}) that are considered valid resource prefixes + * @return {@code true} if the first token of the query matches one of the expected types; {@code false} otherwise + * @see #buildResourcePrefix(int) + * @see #validQueryResourcePrefixes + */ + public static boolean hasResourcePrefix(String query, List expectedTokenTypes) { + CharStream input = CharStreams.fromString(query.trim()); + QueryLangLexer lexer = new QueryLangLexer(input); + lexer.removeErrorListeners(); + Token firstToken = lexer.nextToken(); + return expectedTokenTypes.contains(firstToken.getType()); + } + + /** + * Builds a canonical PFQL prefix string (resource keyword + delimiter). + * The keyword text is derived from the grammar via the lexer. + * The delimiter form ({@value QUERY_DELIMITER}) corresponds to the + * {@code SPACE? ':' SPACE} alternative of the {@code delimeter} rule. + * + * @param singularTokenType the singular resource token type (e.g. {@link QueryLangParser#CASE}) + * @return the canonical prefix string (e.g. {@code "case: "}) + */ + public static String buildResourcePrefix(int singularTokenType) { + String symbolicName = QueryLangParser.VOCABULARY.getSymbolicName(singularTokenType); + if (symbolicName == null) { + throw new IllegalArgumentException("Unknown token type: " + singularTokenType); + } + CharStream input = CharStreams.fromString(symbolicName.toLowerCase()); + QueryLangLexer lexer = new QueryLangLexer(input); + lexer.removeErrorListeners(); + Token token = lexer.nextToken(); + if (token.getType() != singularTokenType) { + throw new IllegalArgumentException( + "Symbolic name '" + symbolicName + "' does not tokenize to expected type " + singularTokenType); + } + return token.getText() + QUERY_DELIMITER; + } } diff --git a/src/test/java/com/netgrif/application/engine/pfql/CaseSearchServiceTest.java b/src/test/java/com/netgrif/application/engine/pfql/CaseSearchServiceTest.java index 6d34428a50..319b4546eb 100644 --- a/src/test/java/com/netgrif/application/engine/pfql/CaseSearchServiceTest.java +++ b/src/test/java/com/netgrif/application/engine/pfql/CaseSearchServiceTest.java @@ -21,6 +21,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -71,6 +72,26 @@ public void searchOneTest() throws InterruptedException { assertNotNull(result.getPetriNet()); assertEquals(testCase.getStringId(), result.getStringId()); + result = caseSearchService.searchOne("title eq 'test'"); + assertNotNull(result); + assertNotNull(result.getPetriNet()); + assertEquals(testCase.getStringId(), result.getStringId()); + + result = caseSearchService.searchOne("title eq {}", "test"); + assertNotNull(result); + assertNotNull(result.getPetriNet()); + assertEquals(testCase.getStringId(), result.getStringId()); + + result = caseSearchService.searchOne("title in ('test', 'test2')"); + assertNotNull(result); + assertNotNull(result.getPetriNet()); + assertEquals(testCase.getStringId(), result.getStringId()); + + result = caseSearchService.searchOne("title in {}", List.of("test", "test2")); + assertNotNull(result); + assertNotNull(result.getPetriNet()); + assertEquals(testCase.getStringId(), result.getStringId()); + login(mockService.mockLoggedUser()); result = caseSearchService.searchOne("case: title eq 'test'"); assertNull(result); @@ -99,6 +120,12 @@ public void searchAllTest() throws InterruptedException { assertNotNull(result.getContent().get(0).getPetriNet()); assertEquals(testCase.getStringId(), result.getContent().get(0).getStringId()); + result = caseSearchService.searchAll("title eq 'test'"); + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertNotNull(result.getContent().get(0).getPetriNet()); + assertEquals(testCase.getStringId(), result.getContent().get(0).getStringId()); + login(mockService.mockLoggedUser()); result = caseSearchService.searchAll("cases: title eq 'test'"); assertNotNull(result); @@ -126,6 +153,9 @@ public void countTest() throws InterruptedException { long result = caseSearchService.count("case: title eq 'test'"); assertEquals(1, result); + result = caseSearchService.count("title eq 'test'"); + assertEquals(1, result); + login(mockService.mockLoggedUser()); result = caseSearchService.count("case: title eq 'test'"); assertEquals(0, result); @@ -151,6 +181,9 @@ public void existsTest() throws InterruptedException { boolean result = caseSearchService.exists("case: title eq 'test'"); assertTrue(result); + result = caseSearchService.exists("title eq 'test'"); + assertTrue(result); + login(mockService.mockLoggedUser()); result = caseSearchService.exists("case: title eq 'test'"); assertFalse(result); diff --git a/src/test/java/com/netgrif/application/engine/pfql/ProcessSearchServiceTest.java b/src/test/java/com/netgrif/application/engine/pfql/ProcessSearchServiceTest.java index 44fefe6f18..9616cf36d3 100644 --- a/src/test/java/com/netgrif/application/engine/pfql/ProcessSearchServiceTest.java +++ b/src/test/java/com/netgrif/application/engine/pfql/ProcessSearchServiceTest.java @@ -57,6 +57,10 @@ public void searchOneTest() { assertNotNull(result); assertEquals(testNet.getStringId(), result.getStringId()); + result = processSearchService.searchOne("identifier eq 'query_lang_test'"); + assertNotNull(result); + assertEquals(testNet.getStringId(), result.getStringId()); + result = processSearchService.searchOne("process: identifier eq 'wrong'"); assertNull(result); } @@ -73,6 +77,12 @@ public void searchAllTest() { assertEquals(20, result.getPageable().getPageSize()); assertEquals(testNet.getStringId(), result.getContent().get(0).getStringId()); + result = processSearchService.searchAll("identifier eq 'query_lang_test'"); + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals(20, result.getPageable().getPageSize()); + assertEquals(testNet.getStringId(), result.getContent().get(0).getStringId()); + result = processSearchService.searchAll("processes: identifier eq 'query_lang_test' page 0 size 67"); assertNotNull(result); assertEquals(67, result.getPageable().getPageSize()); @@ -90,6 +100,9 @@ public void countTest() { long result = processSearchService.count("process: identifier eq 'query_lang_test'"); assertEquals(1, result); + result = processSearchService.count("identifier eq 'query_lang_test'"); + assertEquals(1, result); + result = processSearchService.count("processes: identifier eq 'query_lang_test'"); assertEquals(1, result); @@ -108,6 +121,9 @@ public void existsTest() { boolean result = processSearchService.exists("process: identifier eq 'query_lang_test'"); assertTrue(result); + result = processSearchService.exists("identifier eq 'query_lang_test'"); + assertTrue(result); + result = processSearchService.exists("processes: identifier eq 'query_lang_test'"); assertTrue(result); diff --git a/src/test/java/com/netgrif/application/engine/pfql/TaskSearchServiceTest.java b/src/test/java/com/netgrif/application/engine/pfql/TaskSearchServiceTest.java index 3166f9329d..e391bea1b1 100644 --- a/src/test/java/com/netgrif/application/engine/pfql/TaskSearchServiceTest.java +++ b/src/test/java/com/netgrif/application/engine/pfql/TaskSearchServiceTest.java @@ -73,6 +73,10 @@ public void searchOneTest() { assertNotNull(result); assertEquals(testTaskId, result.getStringId()); + result = taskSearchService.searchOne("id eq '" + testTaskId + "'"); + assertNotNull(result); + assertEquals(testTaskId, result.getStringId()); + login(mockService.mockLoggedUser()); result = taskSearchService.searchOne("task: id eq '" + testTaskId + "'"); assertNull(result); @@ -93,6 +97,11 @@ public void searchAllTest() { assertEquals(1, result.getTotalElements()); assertEquals(testTaskId, result.getContent().get(0).getStringId()); + result = taskSearchService.searchAll("id eq '" + testTaskId + "'"); + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals(testTaskId, result.getContent().get(0).getStringId()); + login(mockService.mockLoggedUser()); result = taskSearchService.searchAll("tasks: id eq '" + testTaskId + "'"); assertNotNull(result); @@ -112,6 +121,9 @@ public void countTest() { long result = taskSearchService.count("task: id eq '" + testTaskId + "'"); assertEquals(1, result); + result = taskSearchService.count("id eq '" + testTaskId + "'"); + assertEquals(1, result); + login(mockService.mockLoggedUser()); result = taskSearchService.count("task: id eq '" + testTaskId + "'"); assertEquals(0, result); @@ -132,6 +144,9 @@ public void existsTest() { boolean result = taskSearchService.exists("task: id eq '" + testTaskId + "'"); assertTrue(result); + result = taskSearchService.exists("id eq '" + testTaskId + "'"); + assertTrue(result); + login(mockService.mockLoggedUser()); result = taskSearchService.exists("task: id eq '" + testTaskId + "'"); assertFalse(result); diff --git a/src/test/java/com/netgrif/application/engine/pfql/UserSearchServiceTest.java b/src/test/java/com/netgrif/application/engine/pfql/UserSearchServiceTest.java index 4f3c0a9ec4..19501e5666 100644 --- a/src/test/java/com/netgrif/application/engine/pfql/UserSearchServiceTest.java +++ b/src/test/java/com/netgrif/application/engine/pfql/UserSearchServiceTest.java @@ -50,6 +50,10 @@ public void searchOneTest() { assertNotNull(result); assertEquals(superCreator.getSuperUser().getStringId(), result.getStringId()); + result = userSearchService.searchOne("email eq '" + superCreator.getSuperUser().getEmail() + "'"); + assertNotNull(result); + assertEquals(superCreator.getSuperUser().getStringId(), result.getStringId()); + result = userSearchService.searchOne("user: email eq 'wrong'"); assertNull(result); } @@ -65,6 +69,11 @@ public void searchAllTest() { assertEquals(1, result.getTotalElements()); assertEquals(superCreator.getSuperUser().getStringId(), result.getContent().get(0).getStringId()); + result = userSearchService.searchAll("email eq '" + superCreator.getSuperUser().getEmail() + "'"); + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + assertEquals(superCreator.getSuperUser().getStringId(), result.getContent().get(0).getStringId()); + result = userSearchService.searchAll("users: email eq 'wrong'"); assertNotNull(result); assertEquals(0, result.getTotalElements()); @@ -78,6 +87,9 @@ public void countTest() { long result = userSearchService.count("users: email eq '" + superCreator.getSuperUser().getEmail() + "'"); assertEquals(1, result); + result = userSearchService.count("email eq '" + superCreator.getSuperUser().getEmail() + "'"); + assertEquals(1, result); + result = userSearchService.count("users: email eq 'wrong'"); assertEquals(0, result); } @@ -90,6 +102,9 @@ public void existsTest() { boolean result = userSearchService.exists("users: email eq '" + superCreator.getSuperUser().getEmail() + "'"); assertTrue(result); + result = userSearchService.exists("email eq '" + superCreator.getSuperUser().getEmail() + "'"); + assertTrue(result); + result = userSearchService.exists("users: email eq 'wrong'"); assertFalse(result); } diff --git a/src/test/java/com/netgrif/application/engine/pfql/formatters/QueryLangPlaceholderHandlerTest.java b/src/test/java/com/netgrif/application/engine/pfql/formatters/QueryLangPlaceholderHandlerTest.java new file mode 100644 index 0000000000..865d0fcc87 --- /dev/null +++ b/src/test/java/com/netgrif/application/engine/pfql/formatters/QueryLangPlaceholderHandlerTest.java @@ -0,0 +1,588 @@ + +package com.netgrif.application.engine.pfql.formatters; + +import com.netgrif.application.engine.petrinet.domain.Component; +import com.netgrif.application.engine.petrinet.domain.I18nString; +import com.netgrif.application.engine.petrinet.domain.dataset.CaseField; +import com.netgrif.application.engine.petrinet.domain.dataset.EnumerationMapField; +import com.netgrif.application.engine.petrinet.domain.dataset.TaskField; +import com.netgrif.application.engine.petrinet.domain.version.Version; +import com.netgrif.application.engine.pfql.service.formatters.QueryLangPlaceholderHandler; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; + +import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.evaluateQuery; +import static com.netgrif.application.engine.pfql.service.utils.SearchUtils.formatPlaceholders; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles({"test"}) +@ExtendWith(SpringExtension.class) +public class QueryLangPlaceholderHandlerTest { + + @Autowired + private QueryLangPlaceholderHandler placeholderHandler; + + // ========================================================================= + // CASE queries + // ========================================================================= + + @Test + public void testCase_String() { + String query = formatPlaceholders("case: title eq {}", placeholderHandler, "my-title"); + assertEquals("case: title eq 'my-title'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_StringList() { + String query = formatPlaceholders("case: title in {}", placeholderHandler, + List.of("title-a", "title-b", "title-c")); + assertEquals("case: title in ('title-a', 'title-b', 'title-c')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_Boolean() { + String query = formatPlaceholders("case: data.active.value eq {}", placeholderHandler, true); + assertEquals("case: data.active.value eq true", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_Number() { + String query = formatPlaceholders("case: data.count.value eq {}", placeholderHandler, 42); + assertEquals("case: data.count.value eq 42", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_NumberDouble() { + String query = formatPlaceholders("case: data.price.value gt {}", placeholderHandler, 3.14); + assertEquals("case: data.price.value gt 3.14", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_NumberList() { + String query = formatPlaceholders("case: data.score.value in {}", placeholderHandler, + List.of(1, 2, 3)); + assertEquals("case: data.score.value in (1, 2, 3)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_ObjectId() { + ObjectId id = new ObjectId("507f1f77bcf86cd799439011"); + String query = formatPlaceholders("case: id eq {}", placeholderHandler, id); + assertEquals("case: id eq '507f1f77bcf86cd799439011'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_ObjectIdList() { + ObjectId id1 = new ObjectId("507f1f77bcf86cd799439011"); + ObjectId id2 = new ObjectId("507f1f77bcf86cd799439012"); + String query = formatPlaceholders("case: id in {}", placeholderHandler, List.of(id1, id2)); + assertEquals("case: id in ('507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_DateTime_LocalDateTime() { + LocalDateTime dt = LocalDateTime.of(2024, 3, 15, 10, 30, 0, 500_000_000); + String query = formatPlaceholders("case: creationDate gt {}", placeholderHandler, dt); + assertEquals("case: creationDate gt 2024-03-15T10:30:00.5", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_DateTime_String() { + String query = formatPlaceholders("case: creationDate lte {}", placeholderHandler, "2024-03-15T10:30:00"); + assertEquals("case: creationDate lte 2024-03-15T10:30:00", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_DateTimeList() { + LocalDateTime dt1 = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + LocalDateTime dt2 = LocalDateTime.of(2024, 6, 1, 12, 0, 0); + Date dt3 = new GregorianCalendar(2024, Calendar.DECEMBER, 1, 12, 0).getTime(); + String query = formatPlaceholders("case: creationDate in {}", placeholderHandler, List.of(dt1, dt2, dt3)); + assertEquals("case: creationDate in (2024-01-01T00:00:00.0, 2024-06-01T12:00:00.0, 2024-12-01T12:00:00.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_Date_LocalDate() { + LocalDate date = LocalDate.of(2024, 5, 20); + String query = formatPlaceholders("case: creationDate gte {}", placeholderHandler, date); + assertEquals("case: creationDate gte 2024-05-20", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_Date_String() { + String query = formatPlaceholders("case: creationDate lt {}", placeholderHandler, "2024-05-20"); + assertEquals("case: creationDate lt 2024-05-20", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_DateList() { + LocalDate d1 = LocalDate.of(2024, 1, 10); + LocalDate d2 = LocalDate.of(2024, 2, 20); + LocalDate d3 = LocalDate.of(2024, 3, 30); + String query = formatPlaceholders("case: creationDate in {}", placeholderHandler, List.of(d1, d2, d3)); + assertEquals("case: creationDate in (2024-01-10, 2024-02-20, 2024-03-30)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_CaseRef() { + CaseField caseField = new CaseField(); + caseField.setValue(List.of("507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012")); + String query = formatPlaceholders("case: id in {}", placeholderHandler, caseField); + assertEquals("case: id in ('507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + + EnumerationMapField caseOptionField = new EnumerationMapField(); + caseOptionField.setComponent(new Component("caseref")); + caseOptionField.setOptions(Map.of("507f1f77bcf86cd799439011", new I18nString(), "507f1f77bcf86cd799439012", new I18nString())); + String query2 = formatPlaceholders("case: id in {}", placeholderHandler, caseOptionField); + assertTrue(query2.equals("case: id in ('507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012')") + || query2.equals("case: id in ('507f1f77bcf86cd799439012', '507f1f77bcf86cd799439011')")); + assertDoesNotThrow(() -> evaluateQuery(query2)); + } + + @Test + public void testCasePlaceholder_MultiplePlaceholders() { + String query = formatPlaceholders( + "case: processIdentifier eq {} and title eq {} and data.count.value gt {}", + placeholderHandler, + "my-process", "My Case", 5 + ); + assertEquals("case: processIdentifier eq 'my-process' and title eq 'My Case' and data.count.value gt 5", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_MultiplePlaceholders_ObjectIdAndDateAndStringList() { + ObjectId oid = new ObjectId("507f1f77bcf86cd799439011"); + LocalDate date = LocalDate.of(2023, 6, 1); + String query = formatPlaceholders( + "case: id eq {} and creationDate gte {} and title in {}", + placeholderHandler, + oid, date, List.of("Alpha", "Beta") + ); + assertEquals( + "case: id eq '507f1f77bcf86cd799439011' and creationDate gte 2023-06-01 and title in ('Alpha', 'Beta')", + query + ); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testCase_MultiplePlaceholders_DateTimeRange() { + LocalDateTime from = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + String query = formatPlaceholders( + "case: creationDate in ({} : {})", + placeholderHandler, + from, to + ); + assertEquals("case: creationDate in (2023-01-01T00:00:00.0 : 2024-01-01T00:00:00.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + // ========================================================================= + // TASK queries + // ========================================================================= + + @Test + public void testTask_String() { + String query = formatPlaceholders("task: transitionId eq {}", placeholderHandler, "t1"); + assertEquals("task: transitionId eq 't1'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_StringList() { + String query = formatPlaceholders("task: transitionId in {}", placeholderHandler, + List.of("t1", "t2", "t3")); + assertEquals("task: transitionId in ('t1', 't2', 't3')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_ObjectId() { + ObjectId id = new ObjectId("507f1f77bcf86cd799439022"); + String query = formatPlaceholders("task: id eq {}", placeholderHandler, id); + assertEquals("task: id eq '507f1f77bcf86cd799439022'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_ObjectIdList() { + ObjectId id1 = new ObjectId("507f1f77bcf86cd799439022"); + ObjectId id2 = new ObjectId("507f1f77bcf86cd799439033"); + String query = formatPlaceholders("task: id in {}", placeholderHandler, List.of(id1, id2)); + assertEquals("task: id in ('507f1f77bcf86cd799439022', '507f1f77bcf86cd799439033')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_DateTime_LocalDateTime() { + LocalDateTime dt = LocalDateTime.of(2024, 6, 10, 8, 0, 0); + String query = formatPlaceholders("task: lastAssign gt {}", placeholderHandler, dt); + assertEquals("task: lastAssign gt 2024-06-10T08:00:00.0", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_DateTime_String() { + String query = formatPlaceholders("task: lastFinish lte {}", placeholderHandler, "2024-11-30T23:59:59"); + assertEquals("task: lastFinish lte 2024-11-30T23:59:59", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_DateTimeList() { + LocalDateTime dt1 = LocalDateTime.of(2024, 1, 1, 12, 0, 0); + LocalDateTime dt2 = LocalDateTime.of(2024, 12, 31, 12, 0, 0); + String query = formatPlaceholders("task: lastAssign in {}", placeholderHandler, List.of(dt1, dt2)); + assertEquals("task: lastAssign in (2024-01-01T12:00:00.0, 2024-12-31T12:00:00.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_Date_LocalDate() { + LocalDate date = LocalDate.of(2024, 9, 1); + String query = formatPlaceholders("task: lastAssign gte {}", placeholderHandler, date); + assertEquals("task: lastAssign gte 2024-09-01", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_Date_String() { + String query = formatPlaceholders("task: lastFinish lt {}", placeholderHandler, "2024-12-31"); + assertEquals("task: lastFinish lt 2024-12-31", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_DateList() { + LocalDate d1 = LocalDate.of(2024, 3, 1); + LocalDate d2 = LocalDate.of(2024, 9, 1); + String query = formatPlaceholders("task: lastFinish in {}", placeholderHandler, List.of(d1, d2)); + assertEquals("task: lastFinish in (2024-03-01, 2024-09-01)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_TaskRef() { + TaskField taskField = new TaskField(); + taskField.setValue(List.of("507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012", "507f1f77bcf86cd799439013")); + String query = formatPlaceholders("task: id in {}", placeholderHandler, taskField); + assertEquals("task: id in ('507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012', '507f1f77bcf86cd799439013')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_MultiplePlaceholders_StringAndObjectId() { + ObjectId id = new ObjectId("507f1f77bcf86cd799439022"); + String query = formatPlaceholders( + "task: processId eq {} and id eq {}", + placeholderHandler, + "507f1f77bcf86cd799439011", id + ); + assertEquals("task: processId eq '507f1f77bcf86cd799439011' and id eq '507f1f77bcf86cd799439022'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_MultiplePlaceholders_ThreeStrings() { + String query = formatPlaceholders( + "task: processId eq {} and userId eq {} and transitionId eq {}", + placeholderHandler, + "507f1f77bcf86cd799439011", "507f1f77bcf86cd799439012", "t1" + ); + assertEquals("task: processId eq '507f1f77bcf86cd799439011' and userId eq '507f1f77bcf86cd799439012' and transitionId eq 't1'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testTask_MultiplePlaceholders_DateTimeRange() { + LocalDateTime from = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2024, 12, 31, 23, 59, 59); + String query = formatPlaceholders( + "task: transitionId eq {} and lastAssign in ({} : {})", + placeholderHandler, + "transition-1", from, to + ); + assertEquals( + "task: transitionId eq 'transition-1' and lastAssign in (2024-01-01T00:00:00.0 : 2024-12-31T23:59:59.0)", + query + ); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + // ========================================================================= + // PROCESS queries + // ========================================================================= + + @Test + public void testProcess_String() { + String query = formatPlaceholders("process: identifier eq {}", placeholderHandler, "my-process"); + assertEquals("process: identifier eq 'my-process'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_StringList() { + String query = formatPlaceholders("process: identifier in {}", placeholderHandler, + List.of("proc-a", "proc-b")); + assertEquals("process: identifier in ('proc-a', 'proc-b')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_Number() { + String query = formatPlaceholders("process: version eq {}.{}.{}", placeholderHandler, 1, 2, 3); + assertEquals("process: version eq 1.2.3", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_NumberList() { + String query = formatPlaceholders("process: version in ({}.{}.{}, {}.{}.{})", placeholderHandler, + 1, 0, 0, 2, 0, 0); + assertEquals("process: version in (1.0.0, 2.0.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_Version() { + String query = formatPlaceholders("process: version eq {}", placeholderHandler, new Version(1, 2, 3)); + assertEquals("process: version eq 1.2.3", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_VersionList() { + String query = formatPlaceholders("process: version in {}", placeholderHandler, + List.of(new Version(1, 0, 0), new Version(2, 0, 0))); + assertEquals("process: version in (1.0.0, 2.0.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_ObjectId() { + ObjectId id = new ObjectId("507f1f77bcf86cd799439055"); + String query = formatPlaceholders("process: id eq {}", placeholderHandler, id); + assertEquals("process: id eq '507f1f77bcf86cd799439055'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_ObjectIdList() { + ObjectId id1 = new ObjectId("507f1f77bcf86cd799439055"); + ObjectId id2 = new ObjectId("507f1f77bcf86cd799439066"); + String query = formatPlaceholders("process: id in {}", placeholderHandler, List.of(id1, id2)); + assertEquals("process: id in ('507f1f77bcf86cd799439055', '507f1f77bcf86cd799439066')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_DateTime_LocalDateTime() { + LocalDateTime dt = LocalDateTime.of(2023, 11, 1, 9, 0, 0); + String query = formatPlaceholders("process: creationDate eq {}", placeholderHandler, dt); + assertEquals("process: creationDate eq 2023-11-01T09:00:00.0", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_DateTime_String() { + String query = formatPlaceholders("process: creationDate gte {}", placeholderHandler, "2023-01-01T00:00:00"); + assertEquals("process: creationDate gte 2023-01-01T00:00:00", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_DateTimeList() { + LocalDateTime dt1 = LocalDateTime.of(2022, 1, 1, 0, 0, 0); + LocalDateTime dt2 = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + String query = formatPlaceholders("process: creationDate in {}", placeholderHandler, List.of(dt1, dt2)); + assertEquals("process: creationDate in (2022-01-01T00:00:00.0, 2023-01-01T00:00:00.0)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_Date_LocalDate() { + LocalDate date = LocalDate.of(2024, 12, 1); + String query = formatPlaceholders("process: creationDate lte {}", placeholderHandler, date); + assertEquals("process: creationDate lte 2024-12-01", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_Date_String() { + String query = formatPlaceholders("process: creationDate lt {}", placeholderHandler, "2024-12-31"); + assertEquals("process: creationDate lt 2024-12-31", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_DateList() { + LocalDate d1 = LocalDate.of(2023, 3, 1); + LocalDate d2 = LocalDate.of(2023, 6, 1); + String query = formatPlaceholders("process: creationDate in {}", placeholderHandler, List.of(d1, d2)); + assertEquals("process: creationDate in (2023-03-01, 2023-06-01)", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_MultiplePlaceholders_TwoStrings() { + String query = formatPlaceholders( + "process: identifier eq {} and title eq {}", + placeholderHandler, + "my-process", "My Process Title" + ); + assertEquals("process: identifier eq 'my-process' and title eq 'My Process Title'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_MultiplePlaceholders_ObjectIdAndDateRange() { + ObjectId oid = new ObjectId("507f1f77bcf86cd799439099"); + LocalDateTime from = LocalDateTime.of(2023, 1, 1, 0, 0, 0); + LocalDateTime to = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + String query = formatPlaceholders( + "process: id eq {} and creationDate in ({} : {})", + placeholderHandler, + oid, from, to + ); + assertEquals( + "process: id eq '507f1f77bcf86cd799439099' and creationDate in (2023-01-01T00:00:00.0 : 2024-01-01T00:00:00.0)", + query + ); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testProcess_MultiplePlaceholders_StringListAndDateList() { + LocalDate d1 = LocalDate.of(2023, 1, 1); + LocalDate d2 = LocalDate.of(2024, 1, 1); + String query = formatPlaceholders( + "process: identifier in {} and creationDate in {}", + placeholderHandler, + List.of("proc-a", "proc-b"), List.of(d1, d2) + ); + assertEquals( + "process: identifier in ('proc-a', 'proc-b') and creationDate in (2023-01-01, 2024-01-01)", + query + ); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + // ========================================================================= + // USER queries + // ========================================================================= + + @Test + public void testUser_String() { + String query = formatPlaceholders("user: email eq {}", placeholderHandler, "user@example.com"); + assertEquals("user: email eq 'user@example.com'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_StringContains() { + String query = formatPlaceholders("user: name contains {}", placeholderHandler, "John"); + assertEquals("user: name contains 'John'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_StringList() { + String query = formatPlaceholders("user: email in {}", placeholderHandler, + List.of("a@example.com", "b@example.com", "c@example.com")); + assertEquals("user: email in ('a@example.com', 'b@example.com', 'c@example.com')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_ObjectId() { + ObjectId id = new ObjectId("507f1f77bcf86cd799439077"); + String query = formatPlaceholders("user: id eq {}", placeholderHandler, id); + assertEquals("user: id eq '507f1f77bcf86cd799439077'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_ObjectIdList() { + ObjectId id1 = new ObjectId("507f1f77bcf86cd799439077"); + ObjectId id2 = new ObjectId("507f1f77bcf86cd799439088"); + String query = formatPlaceholders("user: id in {}", placeholderHandler, List.of(id1, id2)); + assertEquals("user: id in ('507f1f77bcf86cd799439077', '507f1f77bcf86cd799439088')", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_MultiplePlaceholders_ThreeStrings() { + String query = formatPlaceholders( + "user: name eq {} and surname eq {} and email eq {}", + placeholderHandler, + "John", "Doe", "john.doe@example.com" + ); + assertEquals("user: name eq 'John' and surname eq 'Doe' and email eq 'john.doe@example.com'", query); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + @Test + public void testUser_MultiplePlaceholders_ObjectIdAndStringList() { + ObjectId oid = new ObjectId("507f1f77bcf86cd799439077"); + String query = formatPlaceholders( + "user: id eq {} and email in {}", + placeholderHandler, + oid, List.of("alice@example.com", "bob@example.com") + ); + assertEquals( + "user: id eq '507f1f77bcf86cd799439077' and email in ('alice@example.com', 'bob@example.com')", + query + ); + assertDoesNotThrow(() -> evaluateQuery(query)); + } + + // ========================================================================= + // Edge cases + // ========================================================================= + + @Test + public void testUnsupportedType_ThrowsException() { + assertThrows(IllegalArgumentException.class, () -> formatPlaceholders("user: name contains {}", placeholderHandler, new Object())); + } + + @Test + public void testWrongNumOfArgs_ThrowsException() { + assertThrows(IllegalArgumentException.class, () -> formatPlaceholders("user: name contains {}", placeholderHandler, "John", "Small")); + assertThrows(IllegalArgumentException.class, () -> formatPlaceholders("user: name contains {} {}", placeholderHandler, "John")); + } + + @Test + public void testNoPlaceholders_QueryUnchanged() { + String original = "case: title eq 'fixed-title'"; + String result = formatPlaceholders(original, placeholderHandler); + assertEquals(original, result); + assertDoesNotThrow(() -> evaluateQuery(result)); + } +} \ No newline at end of file