diff --git a/README.md b/README.md index 850269a..6f51cf1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # QBit: Quick Search -[![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/QRun-IO/qbit-quick-search) +[![Version](https://img.shields.io/badge/version-0.2.0-blue.svg)](https://github.com/QRun-IO/qbit-quick-search) [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Java](https://img.shields.io/badge/java-21+-blue.svg)](https://adoptium.net/) > **OpenSearch-Powered Full-Text Search for QQQ Applications** -This QBit provides global search across QQQ application tables via OpenSearch. Annotate entity classes, configure a connection, produce the QBit, and gain real-time indexing, scheduled basepull catch-up, per-field relevance boosting, and a pagination-aware search API. +This QBit provides global search across QQQ application tables via OpenSearch. Annotate entity classes or declare tables via config, configure a connection, produce the QBit, and gain real-time indexing, scheduled basepull catch-up, per-field relevance boosting, and a pagination-aware search API. ## Core Capabilities @@ -15,6 +15,7 @@ This QBit provides global search across QQQ application tables via OpenSearch. A - **Field-Level Boosting**: `@QuickSearchField(weight = N)` controls relevance ranking per field - **Edge-Ngram Typeahead**: Custom analyzer enables prefix matching ("ord" finds "order") - **Paginated Search**: `QuickSearchAction` returns results with scores, highlights, total hits, and hasMore flag +- **Config-Driven Indexing**: `SearchableTableConfig` API indexes tables from third-party QBit jars without modifying their source - **Extensible Publisher**: `IndexEventPublisher` interface allows swapping in RabbitMQ, Kafka, or any async transport - **Admin Observability**: Operational tables track index status and run history @@ -28,7 +29,7 @@ QBit Quick Search is 100% open source under Apache 2.0. All data stays in your O 1. **Real-time first, basepull as safety net**: Table customizers index records synchronously on every insert, update, and delete. The basepull process runs on a schedule as a catch-up mechanism for bulk imports, OpenSearch downtime, or any records the real-time path missed. -2. **Annotation-driven discovery**: Entity classes marked with `@QuickSearchable` and `@QuickSearchField` are discovered at produce time. No manual registration of fields or tables required. +2. **Annotation and config-driven discovery**: Entity classes marked with `@QuickSearchable` and `@QuickSearchField` are discovered at produce time. Tables from third-party jars can be declared via `SearchableTableConfig` in code. Both paths merge during `produce()`. 3. **Extensible transport**: The `IndexEventPublisher` strategy interface decouples the write path from the indexing path. The default `SynchronousIndexEventPublisher` writes directly to OpenSearch. Consumers can provide a queue-backed implementation for async indexing. @@ -47,6 +48,8 @@ qbit-quick-search/ QuickSearchQBitProducer.java -- Produces tables, processes, customizers QuickSearchQBitContext.java -- Static runtime context QuickSearchableTableConfig.java -- Per-table index configuration + SearchableFieldConfig.java -- Config-driven field definition + SearchableTableConfig.java -- Config-driven table definition annotations/ -- @QuickSearchable, @QuickSearchField model/ -- QuickSearchIndex, QuickSearchIndexRun entities opensearch/ -- OpenSearch client, document model, bulk results @@ -73,7 +76,7 @@ qbit-quick-search/ com.kingsrook.qbits qbit-quick-search - 0.1.0-SNAPSHOT + 0.2.0-SNAPSHOT ``` @@ -135,6 +138,75 @@ QuickSearchQBitConfig config = new QuickSearchQBitConfig() .withIndexEventPublisher(new RabbitMqIndexEventPublisher(connectionFactory)); ``` +#### Config-driven table indexing + +When tables come from third-party QBit jars (like qbit-crm or qbit-wms), you cannot add annotations to their entity classes. Use `SearchableTableConfig` and `SearchableFieldConfig` to declare those tables as searchable in your host application config. + +```java +QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("rdbms") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("voyage-search") + .withSearchableTable("crmContact", List.of( + new SearchableFieldConfig("firstName").withWeight(2), + new SearchableFieldConfig("lastName").withWeight(2), + new SearchableFieldConfig("email").withWeight(3), + new SearchableFieldConfig("phone").withWeight(1))) + .withSearchableTable(new SearchableTableConfig("crmFormSubmission", List.of( + new SearchableFieldConfig("email").withWeight(3), + new SearchableFieldConfig("firstName").withWeight(1), + new SearchableFieldConfig("message").withWeight(1))) + .withRecordLabelFormat("%s - %s", "formType", "email")); +``` + +`withSearchableTable()` accepts either a `(String tableName, List fields)` shorthand or a full `SearchableTableConfig` for advanced options. + +##### SearchableFieldConfig + +| Method | Description | +|--------|-------------| +| `new SearchableFieldConfig(fieldName)` | Constructor; QQQ field name is required | +| `.withWeight(int)` | Boost factor for relevance ranking (default 1) | +| `.withIncludeLabel(boolean)` | Prefix indexed text with the field label (default false) | + +##### SearchableTableConfig + +| Method | Description | +|--------|-------------| +| `new SearchableTableConfig(tableName, fields)` | Constructor; table name and at least one field required | +| `.withBasepullIntervalMinutes(int)` | Override basepull schedule (defaults to config-level setting) | +| `.withBasepullTimestampField(String)` | Change detection field (default `"modifyDate"`) | +| `.withEnabledByDefault(boolean)` | Whether indexing starts active (default `true`) | +| `.withRecordLabelFormat(String format, String... fields)` | `String.format` pattern and field names for search result display label | + +##### Record label override + +By default, search results use QQQ's record label for display. Config-driven tables can override this with `withRecordLabelFormat()`. The format string is passed to `String.format()` with field values pulled from the record in the order specified. + +```java +new SearchableTableConfig("crmFormSubmission", fields) + .withRecordLabelFormat("%s - %s", "formType", "email"); +// produces labels like "Contact Form - user@example.com" +``` + +#### Mixing annotations and config + +Annotations and config-driven declarations can be used together. Both paths merge during `produce()`. Each table must appear in only one source; duplicate table names across annotation and config sources will produce a validation error. + +```java +QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("rdbms") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("my-app-search") + .withSearchableEntityClasses(List.of(Customer.class, Order.class)) + .withSearchableTable("crmContact", List.of( + new SearchableFieldConfig("firstName").withWeight(2), + new SearchableFieldConfig("lastName").withWeight(2), + new SearchableFieldConfig("email").withWeight(3))); +``` + ## Searching Once the QBit is produced, use `QuickSearchAction` to execute searches: @@ -205,7 +277,8 @@ Or through the QQQ admin UI via the "Full Reindex" process. | `opensearchHost` | `String` | required | OpenSearch hostname | | `opensearchPort` | `Integer` | required | OpenSearch port | | `opensearchIndexName` | `String` | required | OpenSearch index name | -| `searchableEntityClasses` | `List>` | required | Entity classes with `@QuickSearchable` | +| `searchableEntityClasses` | `List>` | none | Entity classes with `@QuickSearchable` | +| `searchableTables` | `List` | none | Config-driven table definitions | | `opensearchUsername` | `String` | none | HTTP basic auth username | | `opensearchPassword` | `String` | none | HTTP basic auth password | | `useSsl` | `Boolean` | `false` | Use HTTPS | @@ -217,6 +290,8 @@ Or through the QQQ admin UI via the "Full Reindex" process. | `sourceBatchSize` | `Integer` | `1000` | Records per source table query batch | | `indexEventPublisher` | `IndexEventPublisher` | sync | Custom publisher for async transport | +At least one of `searchableEntityClasses` or `searchableTables` must be provided. Both can be used together. + ## Annotations ### `@QuickSearchable` diff --git a/pom.xml b/pom.xml index ab6fb80..8a34d2e 100644 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ com.kingsrook qbit-build-parent - 1.4.0 + 1.6.0 com.kingsrook.qbits @@ -78,7 +78,7 @@ com.kingsrook.qqq qqq-bom-pom - 0.40.0-SNAPSHOT + 0.40.0 pom import diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfig.java b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfig.java index b8fe9ae..c0e2370 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfig.java @@ -16,9 +16,11 @@ package com.kingsrook.qbits.quicksearch; +import java.util.ArrayList; import java.util.List; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex; import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexRun; @@ -29,12 +31,15 @@ import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher; ** Configuration for the Quick Search QBit. ** ** Required fields: backendName, opensearchHost, opensearchPort, - ** opensearchIndexName, searchableEntityClasses. + ** opensearchIndexName. At least one of searchableEntityClasses or + ** searchableTables must also be provided. ** ** Optional: opensearchUsername and opensearchPassword (must be set together), ** tableNamePrefix, useSsl (default false), enableScheduledProcesses (default ** true), enableRealTimeIndexing (default true), defaultBasepullIntervalMinutes - ** (default 5), bulkBatchSize (default 500), sourceBatchSize (default 1000). + ** (default 5), bulkBatchSize (default 500), sourceBatchSize (default 1000), + ** searchableTables (config-driven table definitions for tables from external + ** QBit jars that do not have @QuickSearchable annotations). ** *******************************************************************************/ public class QuickSearchQBitConfig implements QBitConfig @@ -52,7 +57,8 @@ public class QuickSearchQBitConfig implements QBitConfig private Integer defaultBasepullIntervalMinutes = 5; private Integer bulkBatchSize = 500; private Integer sourceBatchSize = 1000; - private List> searchableEntityClasses; + private List> searchableEntityClasses; + private List searchableTables; private IndexEventPublisher indexEventPublisher; @@ -105,9 +111,35 @@ public class QuickSearchQBitConfig implements QBitConfig errors.add("opensearchUsername is required when opensearchPassword is set"); } - if(searchableEntityClasses == null || searchableEntityClasses.isEmpty()) + boolean hasEntityClasses = !CollectionUtils.nullSafeIsEmpty(searchableEntityClasses); + boolean hasSearchableTables = !CollectionUtils.nullSafeIsEmpty(searchableTables); + + if(!hasEntityClasses && !hasSearchableTables) { - errors.add("searchableEntityClasses is required for QuickSearchQBit"); + errors.add("At least one of searchableEntityClasses or searchableTables is required for QuickSearchQBit"); + } + + if(hasSearchableTables) + { + java.util.Set seenTableNames = new java.util.HashSet<>(); + for(SearchableTableConfig tableConfig : searchableTables) + { + if(!StringUtils.hasContent(tableConfig.getTableName())) + { + errors.add("tableName is required on each SearchableTableConfig for QuickSearchQBit"); + } + else if(!seenTableNames.add(tableConfig.getTableName())) + { + errors.add("Duplicate tableName in searchableTables: " + tableConfig.getTableName()); + } + + if(CollectionUtils.nullSafeIsEmpty(tableConfig.getFields())) + { + errors.add("fields is required and must be non-empty on SearchableTableConfig" + + (StringUtils.hasContent(tableConfig.getTableName()) ? " [" + tableConfig.getTableName() + "]" : "") + + " for QuickSearchQBit"); + } + } } if(bulkBatchSize != null && bulkBatchSize <= 0) @@ -628,4 +660,61 @@ public class QuickSearchQBitConfig implements QBitConfig return (this); } + + + /*************************************************************************** + ** Getter for searchableTables + ***************************************************************************/ + public List getSearchableTables() + { + return (this.searchableTables); + } + + + + /*************************************************************************** + ** Setter for searchableTables + ***************************************************************************/ + public void setSearchableTables(List searchableTables) + { + this.searchableTables = searchableTables; + } + + + + /*************************************************************************** + ** Fluent setter for searchableTables + ***************************************************************************/ + public QuickSearchQBitConfig withSearchableTables(List searchableTables) + { + this.searchableTables = searchableTables; + return (this); + } + + + + /*************************************************************************** + ** Add a single config-driven searchable table. Lazy-initializes the list. + ***************************************************************************/ + public QuickSearchQBitConfig withSearchableTable(SearchableTableConfig tableConfig) + { + if(this.searchableTables == null) + { + this.searchableTables = new ArrayList<>(); + } + this.searchableTables.add(tableConfig); + return (this); + } + + + + /*************************************************************************** + ** Convenience shorthand to add a config-driven searchable table with + ** default settings. + ***************************************************************************/ + public QuickSearchQBitConfig withSearchableTable(String tableName, List fields) + { + return withSearchableTable(new SearchableTableConfig(tableName, fields)); + } + } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducer.java b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducer.java index 8e0e57b..794c21b 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducer.java @@ -122,7 +122,34 @@ public class QuickSearchQBitProducer //////////////////////////////////////////////////// // 2. Discover searchable tables from annotations // //////////////////////////////////////////////////// - List discoveredTables = discoverSearchableTables(qInstance); + List annotationTables = discoverSearchableTables(qInstance); + + //////////////////////////////////////////////////// + // 2b. Convert config-driven searchable tables // + //////////////////////////////////////////////////// + List configTables = convertConfigDrivenTables(qInstance); + + //////////////////////////////////////////////////// + // 2c. Detect duplicate table names across both // + // annotation-based and config-driven sources // + //////////////////////////////////////////////////// + for(QuickSearchableTableConfig annotationTable : annotationTables) + { + for(QuickSearchableTableConfig configTable : configTables) + { + if(annotationTable.getTableName().equals(configTable.getTableName())) + { + throw new QException("Duplicate table name found in both annotation-based and config-driven searchable tables: " + annotationTable.getTableName()); + } + } + } + + //////////////////////////////////////////////////// + // 2d. Merge both lists into discoveredTables // + //////////////////////////////////////////////////// + List discoveredTables = new ArrayList<>(); + discoveredTables.addAll(annotationTables); + discoveredTables.addAll(configTables); //////////////////////////////////////////////////// // 3. Create OpenSearch client // @@ -338,6 +365,86 @@ public class QuickSearchQBitProducer + /*************************************************************************** + ** Convert config-driven SearchableTableConfig entries into + ** QuickSearchableTableConfig objects. + ** + ** Maps each SearchableFieldConfig into the searchableFields list, + ** fieldWeights map, and fieldIncludeLabels map. Resolves the primary + ** key field from the QInstance table metadata (falls back to "id"). + ** Applies the default basepullIntervalMinutes from QBit config when + ** the table-level value is null. Transfers recordLabelFormat and + ** recordLabelFields. + ** + ** @param qInstance the QInstance to look up table primary keys + ** @return list of converted table configurations, empty if none configured + ***************************************************************************/ + private List convertConfigDrivenTables(QInstance qInstance) + { + List tables = new ArrayList<>(); + List searchableTables = config.getSearchableTables(); + + if(searchableTables == null || searchableTables.isEmpty()) + { + return (tables); + } + + for(SearchableTableConfig stc : searchableTables) + { + List searchableFields = new ArrayList<>(); + Map fieldWeights = new LinkedHashMap<>(); + Map fieldIncludeLabels = new LinkedHashMap<>(); + + if(stc.getFields() != null) + { + for(SearchableFieldConfig fieldConfig : stc.getFields()) + { + String fieldName = fieldConfig.getFieldName(); + searchableFields.add(fieldName); + fieldWeights.put(fieldName, fieldConfig.getWeight() != null ? fieldConfig.getWeight() : 1); + fieldIncludeLabels.put(fieldName, fieldConfig.getIncludeLabel() != null ? fieldConfig.getIncludeLabel() : false); + } + } + + ////////////////////////////////////////////////////////// + // Resolve primary key from QInstance table metadata // + ////////////////////////////////////////////////////////// + String primaryKeyField = "id"; + QTableMetaData table = qInstance.getTable(stc.getTableName()); + if(table != null) + { + primaryKeyField = table.getPrimaryKeyField(); + } + + ////////////////////////////////////////////////////////// + // Apply default basepullIntervalMinutes from config // + ////////////////////////////////////////////////////////// + Integer basepullInterval = stc.getBasepullIntervalMinutes(); + if(basepullInterval == null) + { + basepullInterval = config.getDefaultBasepullIntervalMinutes(); + } + + QuickSearchableTableConfig tableConfig = new QuickSearchableTableConfig() + .withTableName(stc.getTableName()) + .withPrimaryKeyField(primaryKeyField) + .withSearchableFields(searchableFields) + .withFieldWeights(fieldWeights) + .withFieldIncludeLabels(fieldIncludeLabels) + .withBasepullIntervalMinutes(basepullInterval) + .withBasepullTimestampField(stc.getBasepullTimestampField()) + .withEnabledByDefault(stc.getEnabledByDefault()) + .withRecordLabelFormat(stc.getRecordLabelFormat()) + .withRecordLabelFields(stc.getRecordLabelFields()); + + tables.add(tableConfig); + } + + return (tables); + } + + + /*************************************************************************** ** Build QTableMetaData for the quickSearchIndex operational table. ***************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfig.java b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfig.java index 653e754..0f37354 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfig.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfig.java @@ -37,6 +37,8 @@ public class QuickSearchableTableConfig private Integer basepullIntervalMinutes; private String basepullTimestampField; private Boolean enabledByDefault; + private String recordLabelFormat; + private List recordLabelFields; @@ -286,4 +288,66 @@ public class QuickSearchableTableConfig return (this); } + + + /*************************************************************************** + ** Getter for recordLabelFormat + ***************************************************************************/ + public String getRecordLabelFormat() + { + return (this.recordLabelFormat); + } + + + + /*************************************************************************** + ** Setter for recordLabelFormat + ***************************************************************************/ + public void setRecordLabelFormat(String recordLabelFormat) + { + this.recordLabelFormat = recordLabelFormat; + } + + + + /*************************************************************************** + ** Fluent setter for recordLabelFormat + ***************************************************************************/ + public QuickSearchableTableConfig withRecordLabelFormat(String recordLabelFormat) + { + this.recordLabelFormat = recordLabelFormat; + return (this); + } + + + + /*************************************************************************** + ** Getter for recordLabelFields + ***************************************************************************/ + public List getRecordLabelFields() + { + return (this.recordLabelFields); + } + + + + /*************************************************************************** + ** Setter for recordLabelFields + ***************************************************************************/ + public void setRecordLabelFields(List recordLabelFields) + { + this.recordLabelFields = recordLabelFields; + } + + + + /*************************************************************************** + ** Fluent setter for recordLabelFields + ***************************************************************************/ + public QuickSearchableTableConfig withRecordLabelFields(List recordLabelFields) + { + this.recordLabelFields = recordLabelFields; + return (this); + } + } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfig.java b/src/main/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfig.java new file mode 100644 index 0000000..248cfb2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfig.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.quicksearch; + + +/******************************************************************************* + ** Configuration for a single field to include in Quick Search indexed text. + ** + ** Specifies the QQQ field name, an optional boost weight for search relevance + ** ranking, and whether the field's label should be prefixed in the indexed text. + *******************************************************************************/ +public class SearchableFieldConfig +{ + private String fieldName; + private Integer weight = 1; + private Boolean includeLabel = false; + + + + /*************************************************************************** + ** Constructor that takes the required fieldName. + ***************************************************************************/ + public SearchableFieldConfig(String fieldName) + { + this.fieldName = fieldName; + } + + + + /*************************************************************************** + ** Getter for fieldName + ***************************************************************************/ + public String getFieldName() + { + return (this.fieldName); + } + + + + /*************************************************************************** + ** Setter for fieldName + ***************************************************************************/ + public void setFieldName(String fieldName) + { + this.fieldName = fieldName; + } + + + + /*************************************************************************** + ** Fluent setter for fieldName + ***************************************************************************/ + public SearchableFieldConfig withFieldName(String fieldName) + { + this.fieldName = fieldName; + return (this); + } + + + + /*************************************************************************** + ** Getter for weight + ***************************************************************************/ + public Integer getWeight() + { + return (this.weight); + } + + + + /*************************************************************************** + ** Setter for weight + ***************************************************************************/ + public void setWeight(Integer weight) + { + this.weight = weight; + } + + + + /*************************************************************************** + ** Fluent setter for weight + ***************************************************************************/ + public SearchableFieldConfig withWeight(Integer weight) + { + this.weight = weight; + return (this); + } + + + + /*************************************************************************** + ** Getter for includeLabel + ***************************************************************************/ + public Boolean getIncludeLabel() + { + return (this.includeLabel); + } + + + + /*************************************************************************** + ** Setter for includeLabel + ***************************************************************************/ + public void setIncludeLabel(Boolean includeLabel) + { + this.includeLabel = includeLabel; + } + + + + /*************************************************************************** + ** Fluent setter for includeLabel + ***************************************************************************/ + public SearchableFieldConfig withIncludeLabel(Boolean includeLabel) + { + this.includeLabel = includeLabel; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/SearchableTableConfig.java b/src/main/java/com/kingsrook/qbits/quicksearch/SearchableTableConfig.java new file mode 100644 index 0000000..b416d03 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/quicksearch/SearchableTableConfig.java @@ -0,0 +1,279 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.quicksearch; + + +import java.util.Arrays; +import java.util.List; + + +/******************************************************************************* + ** Table-level configuration for a config-driven searchable table. + ** + ** Specifies the QQQ table name, the fields to index, basepull scheduling + ** options, and an optional record label format for search result display. + *******************************************************************************/ +public class SearchableTableConfig +{ + private String tableName; + private List fields; + private Integer basepullIntervalMinutes; + private String basepullTimestampField = "modifyDate"; + private Boolean enabledByDefault = true; + private String recordLabelFormat; + private List recordLabelFields; + + + + /*************************************************************************** + ** Constructor that takes the required tableName and fields. + ***************************************************************************/ + public SearchableTableConfig(String tableName, List fields) + { + this.tableName = tableName; + this.fields = fields; + } + + + + /*************************************************************************** + ** Convenience method that sets both recordLabelFormat and recordLabelFields. + ***************************************************************************/ + public SearchableTableConfig withRecordLabelFormat(String format, String... fields) + { + this.recordLabelFormat = format; + this.recordLabelFields = Arrays.asList(fields); + return (this); + } + + + + /*************************************************************************** + ** Getter for tableName + ***************************************************************************/ + public String getTableName() + { + return (this.tableName); + } + + + + /*************************************************************************** + ** Setter for tableName + ***************************************************************************/ + public void setTableName(String tableName) + { + this.tableName = tableName; + } + + + + /*************************************************************************** + ** Fluent setter for tableName + ***************************************************************************/ + public SearchableTableConfig withTableName(String tableName) + { + this.tableName = tableName; + return (this); + } + + + + /*************************************************************************** + ** Getter for fields + ***************************************************************************/ + public List getFields() + { + return (this.fields); + } + + + + /*************************************************************************** + ** Setter for fields + ***************************************************************************/ + public void setFields(List fields) + { + this.fields = fields; + } + + + + /*************************************************************************** + ** Fluent setter for fields + ***************************************************************************/ + public SearchableTableConfig withFields(List fields) + { + this.fields = fields; + return (this); + } + + + + /*************************************************************************** + ** Getter for basepullIntervalMinutes + ***************************************************************************/ + public Integer getBasepullIntervalMinutes() + { + return (this.basepullIntervalMinutes); + } + + + + /*************************************************************************** + ** Setter for basepullIntervalMinutes + ***************************************************************************/ + public void setBasepullIntervalMinutes(Integer basepullIntervalMinutes) + { + this.basepullIntervalMinutes = basepullIntervalMinutes; + } + + + + /*************************************************************************** + ** Fluent setter for basepullIntervalMinutes + ***************************************************************************/ + public SearchableTableConfig withBasepullIntervalMinutes(Integer basepullIntervalMinutes) + { + this.basepullIntervalMinutes = basepullIntervalMinutes; + return (this); + } + + + + /*************************************************************************** + ** Getter for basepullTimestampField + ***************************************************************************/ + public String getBasepullTimestampField() + { + return (this.basepullTimestampField); + } + + + + /*************************************************************************** + ** Setter for basepullTimestampField + ***************************************************************************/ + public void setBasepullTimestampField(String basepullTimestampField) + { + this.basepullTimestampField = basepullTimestampField; + } + + + + /*************************************************************************** + ** Fluent setter for basepullTimestampField + ***************************************************************************/ + public SearchableTableConfig withBasepullTimestampField(String basepullTimestampField) + { + this.basepullTimestampField = basepullTimestampField; + return (this); + } + + + + /*************************************************************************** + ** Getter for enabledByDefault + ***************************************************************************/ + public Boolean getEnabledByDefault() + { + return (this.enabledByDefault); + } + + + + /*************************************************************************** + ** Setter for enabledByDefault + ***************************************************************************/ + public void setEnabledByDefault(Boolean enabledByDefault) + { + this.enabledByDefault = enabledByDefault; + } + + + + /*************************************************************************** + ** Fluent setter for enabledByDefault + ***************************************************************************/ + public SearchableTableConfig withEnabledByDefault(Boolean enabledByDefault) + { + this.enabledByDefault = enabledByDefault; + return (this); + } + + + + /*************************************************************************** + ** Getter for recordLabelFormat + ***************************************************************************/ + public String getRecordLabelFormat() + { + return (this.recordLabelFormat); + } + + + + /*************************************************************************** + ** Setter for recordLabelFormat + ***************************************************************************/ + public void setRecordLabelFormat(String recordLabelFormat) + { + this.recordLabelFormat = recordLabelFormat; + } + + + + /*************************************************************************** + ** Getter for recordLabelFields + ***************************************************************************/ + public List getRecordLabelFields() + { + return (this.recordLabelFields); + } + + + + /*************************************************************************** + ** Fluent setter for recordLabelFormat (individual field) + ***************************************************************************/ + public SearchableTableConfig withRecordLabelFormatString(String recordLabelFormat) + { + this.recordLabelFormat = recordLabelFormat; + return (this); + } + + + + /*************************************************************************** + ** Setter for recordLabelFields + ***************************************************************************/ + public void setRecordLabelFields(List recordLabelFields) + { + this.recordLabelFields = recordLabelFields; + } + + + + /*************************************************************************** + ** Fluent setter for recordLabelFields + ***************************************************************************/ + public SearchableTableConfig withRecordLabelFields(List recordLabelFields) + { + this.recordLabelFields = recordLabelFields; + return (this); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/processes/BasepullIndexStep.java b/src/main/java/com/kingsrook/qbits/quicksearch/processes/BasepullIndexStep.java index ec817d3..f54c300 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/processes/BasepullIndexStep.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/processes/BasepullIndexStep.java @@ -235,13 +235,7 @@ public class BasepullIndexStep extends AbstractIndexingStep { if(tableConfig != null) { - OpenSearchDocument doc = IndexingUtils.buildDocument( - record, - tableName, - tableConfig.getPrimaryKeyField(), - tableConfig.getSearchableFields(), - tableConfig.getFieldWeights(), - tableConfig.getFieldIncludeLabels() != null ? tableConfig.getFieldIncludeLabels() : java.util.Collections.emptyMap()); + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); documents.add(doc); } } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/processes/FullReindexStep.java b/src/main/java/com/kingsrook/qbits/quicksearch/processes/FullReindexStep.java index 6183c51..f7321d8 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/processes/FullReindexStep.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/processes/FullReindexStep.java @@ -169,13 +169,7 @@ public class FullReindexStep extends AbstractIndexingStep List documents = new ArrayList<>(); for(QRecord record : batch) { - OpenSearchDocument doc = IndexingUtils.buildDocument( - record, - tableName, - tableConfig.getPrimaryKeyField(), - tableConfig.getSearchableFields(), - tableConfig.getFieldWeights(), - tableConfig.getFieldIncludeLabels() != null ? tableConfig.getFieldIncludeLabels() : java.util.Collections.emptyMap()); + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); documents.add(doc); } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtils.java b/src/main/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtils.java index 799cd0a..35e7f70 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtils.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtils.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig; import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument; @@ -164,4 +165,42 @@ public class IndexingUtils .withFieldValues(fieldValues)); } + + + /******************************************************************************* + ** Build an OpenSearchDocument from a QRecord using a QuickSearchableTableConfig. + ** + ** Delegates to the existing buildDocument overload for core document + ** construction. When recordLabelFormat is set on the table config, overrides + ** the record label by formatting the specified field values using + ** String.format. Falls back to QRecord.getRecordLabel() when no format is set. + ** + ** @param record the source QRecord + ** @param tableConfig the table configuration containing fields, weights, and label format + ** @return a fully populated OpenSearchDocument, or null if the primary key is null + *******************************************************************************/ + public static OpenSearchDocument buildDocument(QRecord record, QuickSearchableTableConfig tableConfig) + { + OpenSearchDocument doc = buildDocument( + record, + tableConfig.getTableName(), + tableConfig.getPrimaryKeyField(), + tableConfig.getSearchableFields(), + tableConfig.getFieldWeights(), + tableConfig.getFieldIncludeLabels() != null ? tableConfig.getFieldIncludeLabels() : java.util.Collections.emptyMap()); + + if(doc != null && tableConfig.getRecordLabelFormat() != null && tableConfig.getRecordLabelFields() != null) + { + Object[] labelValues = new Object[tableConfig.getRecordLabelFields().size()]; + for(int i = 0; i < tableConfig.getRecordLabelFields().size(); i++) + { + Object v = record.getValue(tableConfig.getRecordLabelFields().get(i)); + labelValues[i] = v != null ? v : ""; + } + doc.withRecordLabel(String.format(tableConfig.getRecordLabelFormat(), labelValues)); + } + + return (doc); + } + } diff --git a/src/main/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisher.java b/src/main/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisher.java index e0cc629..a5dc859 100644 --- a/src/main/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisher.java +++ b/src/main/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisher.java @@ -107,23 +107,13 @@ public class SynchronousIndexEventPublisher implements IndexEventPublisher continue; } - String primaryKeyField = tableConfig.getPrimaryKeyField(); - if(primaryKeyField == null) - { - primaryKeyField = "id"; - } - for(IndexEvent event : tableEvents) { - OpenSearchDocument document = IndexingUtils.buildDocument( - event.getRecord(), - event.getTableName(), - primaryKeyField, - tableConfig.getSearchableFields(), - tableConfig.getFieldWeights(), - tableConfig.getFieldIncludeLabels()); - - allDocuments.add(document); + OpenSearchDocument document = IndexingUtils.buildDocument(event.getRecord(), tableConfig); + if(document != null) + { + allDocuments.add(document); + } } } diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/ConfigDrivenIndexingIntegrationTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/ConfigDrivenIndexingIntegrationTest.java new file mode 100644 index 0000000..0fb8e72 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/quicksearch/ConfigDrivenIndexingIntegrationTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.quicksearch; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule; +import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore; +import com.kingsrook.qbits.quicksearch.actions.QuickSearchAction; +import com.kingsrook.qbits.quicksearch.actions.QuickSearchInput; +import com.kingsrook.qbits.quicksearch.actions.QuickSearchOutput; +import com.kingsrook.qbits.quicksearch.actions.QuickSearchResult; +import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient; +import com.kingsrook.qbits.quicksearch.processes.FullReindexStep; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** End-to-end integration test for config-driven table indexing using a real + ** OpenSearch instance managed by Testcontainers. + ** + ** Uses only config-driven tables (no annotation-based entity classes). Inserts + ** records, runs FullReindexStep, and verifies search results including custom + ** recordLabelFormat. + *******************************************************************************/ +@Testcontainers(disabledWithoutDocker = true) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ConfigDrivenIndexingIntegrationTest +{ + + private static final String BACKEND_NAME = "testMemoryBackend"; + private static final String TABLE_NAME = "configProduct"; + private static final String INDEX_NAME = "config_driven_integration_test"; + + @Container + static GenericContainer opensearch = new GenericContainer<>("opensearchproject/opensearch:2.11.0") + .withExposedPorts(9200) + .withEnv("discovery.type", "single-node") + .withEnv("plugins.security.disabled", "true") + .withEnv("OPENSEARCH_INITIAL_ADMIN_PASSWORD", "Admin123!") + .waitingFor(Wait.forHttp("/_cluster/health").forStatusCode(200)); + + + + /******************************************************************************* + ** Initialize the QInstance using config-driven tables (no annotations), + ** produce the QBit, and set up QContext before all tests. + *******************************************************************************/ + @BeforeAll + static void setUpAll() throws Exception + { + MemoryRecordStore.getInstance().reset(); + + //////////////////////////////////////////////////// + // Build QInstance with memory backend // + //////////////////////////////////////////////////// + QInstance qInstance = new QInstance(); + + qInstance.addBackend(new QBackendMetaData() + .withName(BACKEND_NAME) + .withBackendType(MemoryBackendModule.class)); + + qInstance.setAuthentication(new QAuthenticationMetaData() + .withName("anonymous") + .withType(QAuthenticationType.FULLY_ANONYMOUS)); + + //////////////////////////////////////////////////// + // Add configProduct source table // + //////////////////////////////////////////////////// + qInstance.addTable(new QTableMetaData() + .withName(TABLE_NAME) + .withBackendName(BACKEND_NAME) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false)) + .withField(new QFieldMetaData("name", QFieldType.STRING)) + .withField(new QFieldMetaData("description", QFieldType.STRING)) + .withField(new QFieldMetaData("sku", QFieldType.STRING)) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME))); + + //////////////////////////////////////////////////// + // Configure via config-driven API (no annotations)// + //////////////////////////////////////////////////// + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost(opensearch.getHost()) + .withOpensearchPort(opensearch.getMappedPort(9200)) + .withOpensearchIndexName(INDEX_NAME) + .withEnableRealTimeIndexing(false) + .withSearchableTable(new SearchableTableConfig(TABLE_NAME, List.of( + new SearchableFieldConfig("name").withWeight(3), + new SearchableFieldConfig("description"), + new SearchableFieldConfig("sku").withWeight(2).withIncludeLabel(true))) + .withRecordLabelFormat("%s (%s)", "name", "sku")); + + new QuickSearchQBitProducer().withConfig(config).produce(qInstance); + + QContext.init(qInstance, new QSession()); + } + + + + /******************************************************************************* + ** Clean up QContext and QBit state after all tests complete. + *******************************************************************************/ + @AfterAll + static void tearDownAll() + { + QContext.clear(); + QuickSearchQBitContext.clear(); + MemoryRecordStore.getInstance().reset(); + } + + + + /******************************************************************************* + ** Test 1: Verify the OpenSearch client was created and context was populated + ** with config-driven discovered tables. + *******************************************************************************/ + @Test + @Order(1) + void testConfigDrivenTableDiscovered() + { + assertThat(QuickSearchQBitContext.getClient()).isNotNull(); + assertThat(QuickSearchQBitContext.getClient()).isInstanceOf(QuickSearchOpenSearchClient.class); + assertThat(QuickSearchQBitContext.getDiscoveredTables()).isNotNull(); + assertThat(QuickSearchQBitContext.getDiscoveredTables()).hasSize(1); + assertThat(QuickSearchQBitContext.getDiscoveredTables().get(0).getTableName()).isEqualTo(TABLE_NAME); + } + + + + /******************************************************************************* + ** Test 2: Insert test records, run FullReindexStep, and verify search + ** returns results for config-driven indexed table. + *******************************************************************************/ + @Test + @Order(2) + void testFullReindex_configDrivenTable() throws Exception + { + //////////////////////////////////////////////////// + // Insert test records into the memory backend // + //////////////////////////////////////////////////// + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(TABLE_NAME); + insertInput.setRecords(List.of( + new QRecord().withValue("id", 1).withValue("name", "Blue Widget").withValue("description", "A fine widget").withValue("sku", "BW-001").withValue("modifyDate", Instant.now()), + new QRecord().withValue("id", 2).withValue("name", "Red Widget").withValue("description", "A premium widget").withValue("sku", "RW-002").withValue("modifyDate", Instant.now()), + new QRecord().withValue("id", 3).withValue("name", "Green Gadget").withValue("description", "A handy gadget").withValue("sku", "GG-003").withValue("modifyDate", Instant.now()) + )); + new InsertAction().execute(insertInput); + + //////////////////////////////////////////////////// + // Run full reindex // + //////////////////////////////////////////////////// + RunBackendStepInput input = new RunBackendStepInput(); + RunBackendStepOutput output = new RunBackendStepOutput(); + new FullReindexStep().run(input, output); + + //////////////////////////////////////////////////// + // Force index refresh // + //////////////////////////////////////////////////// + ((QuickSearchOpenSearchClient) QuickSearchQBitContext.getClient()).refreshIndex(); + + //////////////////////////////////////////////////// + // Search for "widget" and verify results // + //////////////////////////////////////////////////// + QuickSearchOutput searchOutput = new QuickSearchAction().execute( + new QuickSearchInput().withSearchTerm("widget")); + + assertThat(searchOutput.getTotalHits()) + .as("search for 'widget' should return results from config-driven table") + .isGreaterThan(0L); + } + + + + /******************************************************************************* + ** Test 3: Verify search results have expected fields and the custom + ** recordLabelFormat is applied. + *******************************************************************************/ + @Test + @Order(3) + void testSearchResults_recordLabelFormatApplied() throws Exception + { + QuickSearchOutput searchOutput = new QuickSearchAction().execute( + new QuickSearchInput().withSearchTerm("widget")); + + assertThat(searchOutput.getResults()).isNotEmpty(); + + searchOutput.getResults().forEach(result -> + { + assertThat(result.getTableName()).isEqualTo(TABLE_NAME); + assertThat(result.getRecordId()).isNotBlank(); + assertThat(result.getScore()).isNotNull().isGreaterThan(0f); + assertThat(result.getRecordLabel()).isNotBlank(); + }); + + /////////////////////////////////////////////////////////////////////////// + // Verify the recordLabelFormat was applied (format is "%s (%s)" with // + // name and sku fields, so labels should contain parentheses) // + /////////////////////////////////////////////////////////////////////////// + assertThat(searchOutput.getResults()) + .extracting(QuickSearchResult::getRecordLabel) + .allMatch(label -> label != null && label.contains("(") && label.contains(")")); + } + + + + /******************************************************************************* + ** Test 4: Verify search for a non-matching term returns zero results. + *******************************************************************************/ + @Test + @Order(4) + void testSearchNoResults_configDrivenTable() throws Exception + { + QuickSearchOutput searchOutput = new QuickSearchAction().execute( + new QuickSearchInput().withSearchTerm("zzzznonexistent")); + + assertThat(searchOutput.getTotalHits()).isEqualTo(0L); + assertThat(searchOutput.getResults()).isEmpty(); + assertThat(searchOutput.getHasMore()).isFalse(); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfigTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfigTest.java index f15ec76..0b6396a 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitConfigTest.java @@ -310,7 +310,8 @@ class QuickSearchQBitConfigTest /*************************************************************************** - ** Test validation with empty searchableEntityClasses list. + ** Test validation with empty searchableEntityClasses list and no + ** searchableTables. ***************************************************************************/ @Test void testValidate_emptySearchableEntityClasses_addsError() @@ -327,13 +328,13 @@ class QuickSearchQBitConfigTest List errors = new ArrayList<>(); config.validate(qInstance, errors); - assertThat(errors).anyMatch(e -> e.contains("searchableEntityClasses is required")); + assertThat(errors).anyMatch(e -> e.contains("At least one of searchableEntityClasses or searchableTables is required")); } /*************************************************************************** - ** Test validation with null searchableEntityClasses. + ** Test validation with null searchableEntityClasses and no searchableTables. ***************************************************************************/ @Test void testValidate_nullSearchableEntityClasses_addsError() @@ -349,7 +350,7 @@ class QuickSearchQBitConfigTest List errors = new ArrayList<>(); config.validate(qInstance, errors); - assertThat(errors).anyMatch(e -> e.contains("searchableEntityClasses is required")); + assertThat(errors).anyMatch(e -> e.contains("At least one of searchableEntityClasses or searchableTables is required")); } @@ -564,6 +565,241 @@ class QuickSearchQBitConfigTest + /*************************************************************************** + ** Test validation passes with searchableTables only (no entity classes). + ***************************************************************************/ + @Test + void testValidate_searchableTablesOnly_noErrors() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index") + .withSearchableTable("myTable", List.of(new SearchableFieldConfig("name"))); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).noneMatch(e -> e.contains("searchableEntityClasses") || e.contains("searchableTables")); + } + + + + /*************************************************************************** + ** Test validation fails when neither searchableEntityClasses nor + ** searchableTables is configured. + ***************************************************************************/ + @Test + void testValidate_neitherSource_addsError() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index"); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).anyMatch(e -> e.contains("At least one of searchableEntityClasses or searchableTables is required")); + } + + + + /*************************************************************************** + ** Test validation passes when both searchableEntityClasses and + ** searchableTables are configured. + ***************************************************************************/ + @Test + void testValidate_bothSources_noErrors() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index") + .withSearchableEntityClasses(List.of(String.class)) + .withSearchableTable("myTable", List.of(new SearchableFieldConfig("name"))); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).noneMatch(e -> e.contains("searchableEntityClasses") || e.contains("At least one of")); + } + + + + /*************************************************************************** + ** Test withSearchableTable adder lazy-initializes list and adds entry. + ***************************************************************************/ + @Test + void testWithSearchableTable_adder_lazyInitsAndAdds() + { + QuickSearchQBitConfig config = new QuickSearchQBitConfig(); + assertThat(config.getSearchableTables()).isNull(); + + SearchableTableConfig tableConfig = new SearchableTableConfig("t1", + List.of(new SearchableFieldConfig("f1"))); + + QuickSearchQBitConfig result = config.withSearchableTable(tableConfig); + + assertThat(result).isSameAs(config); + assertThat(config.getSearchableTables()).hasSize(1); + assertThat(config.getSearchableTables().get(0).getTableName()).isEqualTo("t1"); + } + + + + /*************************************************************************** + ** Test withSearchableTable shorthand creates a SearchableTableConfig + ** with defaults. + ***************************************************************************/ + @Test + void testWithSearchableTable_shorthand_createsConfigWithDefaults() + { + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withSearchableTable("myTable", List.of(new SearchableFieldConfig("name"))); + + assertThat(config.getSearchableTables()).hasSize(1); + assertThat(config.getSearchableTables().get(0).getTableName()).isEqualTo("myTable"); + assertThat(config.getSearchableTables().get(0).getFields()).hasSize(1); + assertThat(config.getSearchableTables().get(0).getBasepullTimestampField()).isEqualTo("modifyDate"); + assertThat(config.getSearchableTables().get(0).getEnabledByDefault()).isTrue(); + } + + + + /*************************************************************************** + ** Test chaining multiple withSearchableTable calls accumulates entries. + ***************************************************************************/ + @Test + void testWithSearchableTable_chaining_accumulatesEntries() + { + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withSearchableTable("t1", List.of(new SearchableFieldConfig("f1"))) + .withSearchableTable("t2", List.of(new SearchableFieldConfig("f2"))); + + assertThat(config.getSearchableTables()).hasSize(2); + assertThat(config.getSearchableTables().get(0).getTableName()).isEqualTo("t1"); + assertThat(config.getSearchableTables().get(1).getTableName()).isEqualTo("t2"); + } + + + + /*************************************************************************** + ** Test withSearchableTables bulk setter replaces the list. + ***************************************************************************/ + @Test + void testWithSearchableTables_bulkSetter_replacesList() + { + SearchableTableConfig t1 = new SearchableTableConfig("t1", List.of(new SearchableFieldConfig("f1"))); + SearchableTableConfig t2 = new SearchableTableConfig("t2", List.of(new SearchableFieldConfig("f2"))); + List tables = List.of(t1, t2); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withSearchableTables(tables); + + assertThat(config.getSearchableTables()).isSameAs(tables); + assertThat(config.getSearchableTables()).hasSize(2); + } + + + + /*************************************************************************** + ** Test getter/setter round-trip for searchableTables. + ***************************************************************************/ + @Test + void testSearchableTables_getterSetterRoundTrip() + { + SearchableTableConfig t1 = new SearchableTableConfig("t1", List.of(new SearchableFieldConfig("f1"))); + List tables = List.of(t1); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig(); + config.setSearchableTables(new ArrayList<>(tables)); + + assertThat(config.getSearchableTables()).hasSize(1); + assertThat(config.getSearchableTables().get(0).getTableName()).isEqualTo("t1"); + } + + + + /*************************************************************************** + ** Test validation detects empty tableName on a SearchableTableConfig. + ***************************************************************************/ + @Test + void testValidate_searchableTableMissingTableName_addsError() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index") + .withSearchableTable(new SearchableTableConfig("", List.of(new SearchableFieldConfig("f1")))); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).anyMatch(e -> e.contains("tableName is required on each SearchableTableConfig")); + } + + + + /*************************************************************************** + ** Test validation detects empty fields on a SearchableTableConfig. + ***************************************************************************/ + @Test + void testValidate_searchableTableEmptyFields_addsError() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index") + .withSearchableTable(new SearchableTableConfig("myTable", List.of())); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).anyMatch(e -> e.contains("fields is required and must be non-empty")); + } + + + + /*************************************************************************** + ** Verify that duplicate table names within searchableTables are rejected. + ***************************************************************************/ + @Test + void testValidate_duplicateTableNameInSearchableTables_addsError() + { + QInstance qInstance = createQInstanceWithBackend(); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName("memory") + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-index") + .withSearchableTable("myTable", List.of(new SearchableFieldConfig("name"))) + .withSearchableTable("myTable", List.of(new SearchableFieldConfig("email"))); + + List errors = new ArrayList<>(); + config.validate(qInstance, errors); + + assertThat(errors).anyMatch(e -> e.contains("Duplicate tableName in searchableTables: myTable")); + } + + + /*************************************************************************** ** Helper to create a QInstance with a memory backend. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducerTest.java index 2224ef5..c3d4d3c 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducerTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchQBitProducerTest.java @@ -475,6 +475,314 @@ class QuickSearchQBitProducerTest + /*************************************************************************** + ** Test that config-driven-only produce discovers tables from + ** searchableTables config. + ***************************************************************************/ + @Test + void testProduce_configDrivenOnly_discoversTable() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withSearchableTable(SOURCE_TABLE, List.of( + new SearchableFieldConfig("firstName").withWeight(2), + new SearchableFieldConfig("lastName"), + new SearchableFieldConfig("email").withWeight(3))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + assertThat(QuickSearchQBitContext.getDiscoveredTables()).hasSize(1); + assertThat(QuickSearchQBitContext.getDiscoveredTables().get(0).getTableName()).isEqualTo(SOURCE_TABLE); + assertThat(QuickSearchQBitContext.getDiscoveredTables().get(0).getSearchableFields()) + .containsExactly("firstName", "lastName", "email"); + } + + + + /*************************************************************************** + ** Test that config-driven tables register customizers on source tables. + ***************************************************************************/ + @Test + void testProduce_configDriven_registersCustomizers() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withEnableRealTimeIndexing(true) + .withSearchableTable(SOURCE_TABLE, List.of(new SearchableFieldConfig("firstName"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QTableMetaData personTable = qInstance.getTable(SOURCE_TABLE); + Map customizers = + personTable.getCustomizers(); + + assertThat(customizers).containsKey("postInsertRecord"); + assertThat(customizers).containsKey("postUpdateRecord"); + assertThat(customizers).containsKey("postDeleteRecord"); + } + + + + /*************************************************************************** + ** Test mixed mode: both annotation-based and config-driven tables + ** are discovered and merged. + ***************************************************************************/ + @Test + void testProduce_mixedMode_bothSourcesMerged() throws QException + { + QInstance qInstance = buildQInstance(); + addOrderTable(qInstance); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = buildValidConfig() + .withSearchableTable("order", List.of( + new SearchableFieldConfig("orderNumber").withWeight(2), + new SearchableFieldConfig("customerName"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + assertThat(QuickSearchQBitContext.getDiscoveredTables()).hasSize(2); + assertThat(QuickSearchQBitContext.getDiscoveredTables()) + .extracting(QuickSearchableTableConfig::getTableName) + .containsExactlyInAnyOrder("person", "order"); + } + + + + /*************************************************************************** + ** Test that duplicate table name across annotation and config-driven + ** sources throws an exception. + ***************************************************************************/ + @Test + void testProduce_duplicateTableName_throws() + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = buildValidConfig() + .withSearchableTable(SOURCE_TABLE, List.of( + new SearchableFieldConfig("firstName"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + assertThatThrownBy(() -> producer.produce(qInstance)) + .isInstanceOf(QException.class) + .hasMessageContaining("Duplicate table name") + .hasMessageContaining(SOURCE_TABLE); + } + + + + /*************************************************************************** + ** Test that config-driven table with null basepullIntervalMinutes + ** inherits the default from config. + ***************************************************************************/ + @Test + void testProduce_configDriven_defaultsApplied() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withDefaultBasepullIntervalMinutes(7) + .withSearchableTable(SOURCE_TABLE, List.of(new SearchableFieldConfig("firstName"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getBasepullIntervalMinutes()).isEqualTo(7); + assertThat(tableConfig.getBasepullTimestampField()).isEqualTo("modifyDate"); + assertThat(tableConfig.getEnabledByDefault()).isTrue(); + } + + + + /*************************************************************************** + ** Test that config-driven table with explicit basepullIntervalMinutes + ** preserves it (does not override with default). + ***************************************************************************/ + @Test + void testProduce_configDriven_explicitIntervalPreserved() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withDefaultBasepullIntervalMinutes(5) + .withSearchableTable(new SearchableTableConfig(SOURCE_TABLE, + List.of(new SearchableFieldConfig("firstName"))) + .withBasepullIntervalMinutes(30)); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getBasepullIntervalMinutes()).isEqualTo(30); + } + + + + /*************************************************************************** + ** Test that config-driven table resolves primaryKeyField from QInstance + ** table metadata. + ***************************************************************************/ + @Test + void testProduce_configDriven_primaryKeyFromQInstance() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withSearchableTable(SOURCE_TABLE, List.of(new SearchableFieldConfig("firstName"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getPrimaryKeyField()).isEqualTo("id"); + } + + + + /*************************************************************************** + ** Test that config-driven table for a table not in QInstance falls back + ** to "id" as primary key. + ***************************************************************************/ + @Test + void testProduce_configDriven_primaryKeyFallbackToId() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withEnableRealTimeIndexing(false) + .withSearchableTable("unknownTable", List.of(new SearchableFieldConfig("name"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getPrimaryKeyField()).isEqualTo("id"); + } + + + + /*************************************************************************** + ** Test that config-driven table transfers recordLabelFormat and + ** recordLabelFields to QuickSearchableTableConfig. + ***************************************************************************/ + @Test + void testProduce_configDriven_recordLabelFormatTransferred() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withSearchableTable(new SearchableTableConfig(SOURCE_TABLE, + List.of(new SearchableFieldConfig("firstName"), new SearchableFieldConfig("lastName"))) + .withRecordLabelFormat("%s %s", "firstName", "lastName")); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getRecordLabelFormat()).isEqualTo("%s %s"); + assertThat(tableConfig.getRecordLabelFields()).containsExactly("firstName", "lastName"); + } + + + + /*************************************************************************** + ** Test that config-driven field weights and includeLabels are mapped + ** correctly to QuickSearchableTableConfig maps. + ***************************************************************************/ + @Test + void testProduce_configDriven_fieldWeightsAndLabelsMapped() throws QException + { + QInstance qInstance = buildQInstance(); + QContext.init(qInstance, new QSession()); + + QuickSearchQBitConfig config = new QuickSearchQBitConfig() + .withBackendName(BACKEND_NAME) + .withOpensearchHost("localhost") + .withOpensearchPort(9200) + .withOpensearchIndexName("test-config-driven") + .withSearchableTable(SOURCE_TABLE, List.of( + new SearchableFieldConfig("firstName").withWeight(3).withIncludeLabel(true), + new SearchableFieldConfig("lastName").withWeight(2), + new SearchableFieldConfig("email"))); + + QuickSearchQBitProducer producer = new QuickSearchQBitProducer() + .withConfig(config); + + producer.produce(qInstance); + + QuickSearchableTableConfig tableConfig = QuickSearchQBitContext.getDiscoveredTables().get(0); + assertThat(tableConfig.getFieldWeights()) + .containsEntry("firstName", 3) + .containsEntry("lastName", 2) + .containsEntry("email", 1); + assertThat(tableConfig.getFieldIncludeLabels()) + .containsEntry("firstName", true) + .containsEntry("lastName", false) + .containsEntry("email", false); + } + + + /*************************************************************************** ** Build a base QInstance with a memory backend and source tables. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfigTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfigTest.java index 86eaa77..18f79bd 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfigTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/QuickSearchableTableConfigTest.java @@ -119,4 +119,62 @@ class QuickSearchableTableConfigTest assertThat(config.getFieldIncludeLabels()).isNull(); } + + + /*************************************************************************** + ** Test recordLabelFormat defaults to null. + ***************************************************************************/ + @Test + void testRecordLabelFormat_defaultsToNull() + { + QuickSearchableTableConfig config = new QuickSearchableTableConfig(); + + assertThat(config.getRecordLabelFormat()).isNull(); + assertThat(config.getRecordLabelFields()).isNull(); + } + + + + /*************************************************************************** + ** Test recordLabelFormat fluent setter and getter round-trip. + ***************************************************************************/ + @Test + void testRecordLabelFormat_fluentSetterAndGetter() + { + QuickSearchableTableConfig config = new QuickSearchableTableConfig() + .withRecordLabelFormat("%s - %s"); + + assertThat(config.getRecordLabelFormat()).isEqualTo("%s - %s"); + } + + + + /*************************************************************************** + ** Test recordLabelFields fluent setter and getter round-trip. + ***************************************************************************/ + @Test + void testRecordLabelFields_fluentSetterAndGetter() + { + List labelFields = List.of("firstName", "lastName"); + + QuickSearchableTableConfig config = new QuickSearchableTableConfig() + .withRecordLabelFields(labelFields); + + assertThat(config.getRecordLabelFields()).isEqualTo(labelFields); + } + + + + /*************************************************************************** + ** Test recordLabelFormat and recordLabelFields fluent setters return this. + ***************************************************************************/ + @Test + void testRecordLabelSetters_returnThis() + { + QuickSearchableTableConfig config = new QuickSearchableTableConfig(); + + assertThat(config.withRecordLabelFormat("%s")).isSameAs(config); + assertThat(config.withRecordLabelFields(List.of("a"))).isSameAs(config); + } + } diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfigTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfigTest.java new file mode 100644 index 0000000..e0e1a18 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfigTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.quicksearch; + + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Tests for SearchableFieldConfig. + *******************************************************************************/ +class SearchableFieldConfigTest +{ + + /*************************************************************************** + ** Test that the constructor sets fieldName and applies default values + ** for weight (1) and includeLabel (false). + ***************************************************************************/ + @Test + void testConstructor_setsFieldNameAndDefaults() + { + SearchableFieldConfig config = new SearchableFieldConfig("email"); + + assertThat(config.getFieldName()).isEqualTo("email"); + assertThat(config.getWeight()).isEqualTo(1); + assertThat(config.getIncludeLabel()).isFalse(); + } + + + + /*************************************************************************** + ** Test that fluent setter for weight returns this and updates value. + ***************************************************************************/ + @Test + void testWithWeight_setsValueAndReturnsThis() + { + SearchableFieldConfig config = new SearchableFieldConfig("name"); + SearchableFieldConfig result = config.withWeight(5); + + assertThat(result).isSameAs(config); + assertThat(config.getWeight()).isEqualTo(5); + } + + + + /*************************************************************************** + ** Test that fluent setter for includeLabel returns this and updates value. + ***************************************************************************/ + @Test + void testWithIncludeLabel_setsValueAndReturnsThis() + { + SearchableFieldConfig config = new SearchableFieldConfig("name"); + SearchableFieldConfig result = config.withIncludeLabel(true); + + assertThat(result).isSameAs(config); + assertThat(config.getIncludeLabel()).isTrue(); + } + + + + /*************************************************************************** + ** Test that fluent setter for fieldName returns this and updates value. + ***************************************************************************/ + @Test + void testWithFieldName_setsValueAndReturnsThis() + { + SearchableFieldConfig config = new SearchableFieldConfig("original"); + SearchableFieldConfig result = config.withFieldName("updated"); + + assertThat(result).isSameAs(config); + assertThat(config.getFieldName()).isEqualTo("updated"); + } + + + + /*************************************************************************** + ** Test that standard setters update values correctly. + ***************************************************************************/ + @Test + void testStandardSetters_valuesRoundTrip() + { + SearchableFieldConfig config = new SearchableFieldConfig("initial"); + config.setFieldName("changed"); + config.setWeight(10); + config.setIncludeLabel(true); + + assertThat(config.getFieldName()).isEqualTo("changed"); + assertThat(config.getWeight()).isEqualTo(10); + assertThat(config.getIncludeLabel()).isTrue(); + } + + + + /*************************************************************************** + ** Test chaining all fluent setters in one expression. + ***************************************************************************/ + @Test + void testFluentChaining_allSetters() + { + SearchableFieldConfig config = new SearchableFieldConfig("email") + .withWeight(3) + .withIncludeLabel(true); + + assertThat(config.getFieldName()).isEqualTo("email"); + assertThat(config.getWeight()).isEqualTo(3); + assertThat(config.getIncludeLabel()).isTrue(); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/SearchableTableConfigTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/SearchableTableConfigTest.java new file mode 100644 index 0000000..7adfdcd --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/quicksearch/SearchableTableConfigTest.java @@ -0,0 +1,159 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.quicksearch; + + +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Tests for SearchableTableConfig. + *******************************************************************************/ +class SearchableTableConfigTest +{ + + /*************************************************************************** + ** Test that the constructor sets tableName and fields, and optional + ** fields have correct defaults. + ***************************************************************************/ + @Test + void testConstructor_setsRequiredFieldsAndDefaults() + { + List fields = List.of( + new SearchableFieldConfig("firstName"), + new SearchableFieldConfig("lastName")); + + SearchableTableConfig config = new SearchableTableConfig("person", fields); + + assertThat(config.getTableName()).isEqualTo("person"); + assertThat(config.getFields()).hasSize(2); + assertThat(config.getBasepullIntervalMinutes()).isNull(); + assertThat(config.getBasepullTimestampField()).isEqualTo("modifyDate"); + assertThat(config.getEnabledByDefault()).isTrue(); + assertThat(config.getRecordLabelFormat()).isNull(); + assertThat(config.getRecordLabelFields()).isNull(); + } + + + + /*************************************************************************** + ** Test all fluent setters and corresponding getters. + ***************************************************************************/ + @Test + void testFluentSetters_gettersReturnCorrectValues() + { + List fields = List.of(new SearchableFieldConfig("name")); + List newFields = List.of(new SearchableFieldConfig("email")); + + SearchableTableConfig config = new SearchableTableConfig("orders", fields) + .withTableName("customers") + .withFields(newFields) + .withBasepullIntervalMinutes(30) + .withBasepullTimestampField("updatedAt") + .withEnabledByDefault(false); + + assertThat(config.getTableName()).isEqualTo("customers"); + assertThat(config.getFields()).isSameAs(newFields); + assertThat(config.getBasepullIntervalMinutes()).isEqualTo(30); + assertThat(config.getBasepullTimestampField()).isEqualTo("updatedAt"); + assertThat(config.getEnabledByDefault()).isFalse(); + } + + + + /*************************************************************************** + ** Test that fluent setters return this for chaining. + ***************************************************************************/ + @Test + void testFluentSetters_returnThis() + { + List fields = List.of(new SearchableFieldConfig("a")); + + SearchableTableConfig config = new SearchableTableConfig("t", fields); + + assertThat(config.withTableName("t2")).isSameAs(config); + assertThat(config.withFields(fields)).isSameAs(config); + assertThat(config.withBasepullIntervalMinutes(10)).isSameAs(config); + assertThat(config.withBasepullTimestampField("ts")).isSameAs(config); + assertThat(config.withEnabledByDefault(false)).isSameAs(config); + } + + + + /*************************************************************************** + ** Test withRecordLabelFormat convenience method sets both format and fields. + ***************************************************************************/ + @Test + void testWithRecordLabelFormat_setsBothFormatAndFields() + { + List fields = List.of(new SearchableFieldConfig("firstName")); + + SearchableTableConfig config = new SearchableTableConfig("person", fields) + .withRecordLabelFormat("%s - %s", "firstName", "lastName"); + + assertThat(config.getRecordLabelFormat()).isEqualTo("%s - %s"); + assertThat(config.getRecordLabelFields()).containsExactly("firstName", "lastName"); + } + + + + /*************************************************************************** + ** Test withRecordLabelFormat returns this for chaining. + ***************************************************************************/ + @Test + void testWithRecordLabelFormat_returnsThis() + { + List fields = List.of(new SearchableFieldConfig("a")); + + SearchableTableConfig config = new SearchableTableConfig("t", fields); + SearchableTableConfig result = config.withRecordLabelFormat("%s", "a"); + + assertThat(result).isSameAs(config); + } + + + + /*************************************************************************** + ** Test standard setters update values correctly. + ***************************************************************************/ + @Test + void testStandardSetters_valuesRoundTrip() + { + List fields = List.of(new SearchableFieldConfig("a")); + List newFields = List.of(new SearchableFieldConfig("b")); + List labelFields = List.of("x", "y"); + + SearchableTableConfig config = new SearchableTableConfig("t", fields); + config.setTableName("t2"); + config.setFields(newFields); + config.setBasepullIntervalMinutes(20); + config.setBasepullTimestampField("createDate"); + config.setEnabledByDefault(false); + config.setRecordLabelFormat("%s/%s"); + config.setRecordLabelFields(labelFields); + + assertThat(config.getTableName()).isEqualTo("t2"); + assertThat(config.getFields()).isSameAs(newFields); + assertThat(config.getBasepullIntervalMinutes()).isEqualTo(20); + assertThat(config.getBasepullTimestampField()).isEqualTo("createDate"); + assertThat(config.getEnabledByDefault()).isFalse(); + assertThat(config.getRecordLabelFormat()).isEqualTo("%s/%s"); + assertThat(config.getRecordLabelFields()).isSameAs(labelFields); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtilsTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtilsTest.java index 750e030..e046ff0 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtilsTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/processes/IndexingUtilsTest.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig; import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -265,4 +266,116 @@ class IndexingUtilsTest assertThat(doc.getSearchableText()).isEqualTo(""); } + + + /******************************************************************************* + ** Test the new buildDocument overload with QuickSearchableTableConfig that + ** has a recordLabelFormat -- verifies the formatted label is applied. + *******************************************************************************/ + @Test + void testBuildDocument_tableConfig_withRecordLabelFormat_usesFormattedLabel() + { + QRecord record = new QRecord(); + record.setValue("id", 1); + record.setValue("firstName", "John"); + record.setValue("lastName", "Doe"); + record.setRecordLabel("QQQ Default Label"); + + QuickSearchableTableConfig tableConfig = new QuickSearchableTableConfig() + .withTableName("person") + .withPrimaryKeyField("id") + .withSearchableFields(List.of("firstName", "lastName")) + .withFieldWeights(Map.of("firstName", 2, "lastName", 1)) + .withFieldIncludeLabels(Map.of("firstName", false, "lastName", false)) + .withRecordLabelFormat("%s %s") + .withRecordLabelFields(List.of("firstName", "lastName")); + + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); + + assertThat(doc).isNotNull(); + assertThat(doc.getRecordLabel()).isEqualTo("John Doe"); + assertThat(doc.getSourceTable()).isEqualTo("person"); + assertThat(doc.getSearchableText()).isEqualTo("John Doe"); + } + + + + /******************************************************************************* + ** Test the new buildDocument overload with QuickSearchableTableConfig that + ** does NOT have a recordLabelFormat -- falls back to QRecord.getRecordLabel(). + *******************************************************************************/ + @Test + void testBuildDocument_tableConfig_withoutRecordLabelFormat_usesQRecordLabel() + { + QRecord record = new QRecord(); + record.setValue("id", 42); + record.setValue("name", "Widget"); + record.setRecordLabel("Widget Record"); + + QuickSearchableTableConfig tableConfig = new QuickSearchableTableConfig() + .withTableName("product") + .withPrimaryKeyField("id") + .withSearchableFields(List.of("name")) + .withFieldWeights(Map.of("name", 1)) + .withFieldIncludeLabels(Map.of("name", false)); + + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); + + assertThat(doc).isNotNull(); + assertThat(doc.getRecordLabel()).isEqualTo("Widget Record"); + } + + + + /******************************************************************************* + ** Test the new buildDocument overload maps all fields into fieldValues. + *******************************************************************************/ + @Test + void testBuildDocument_tableConfig_allFieldsMapped() + { + QRecord record = new QRecord(); + record.setValue("id", 1); + record.setValue("firstName", "Jane"); + record.setValue("lastName", "Smith"); + record.setValue("email", "jane@example.com"); + + QuickSearchableTableConfig tableConfig = new QuickSearchableTableConfig() + .withTableName("person") + .withPrimaryKeyField("id") + .withSearchableFields(List.of("firstName", "lastName", "email")) + .withFieldWeights(Map.of("firstName", 3, "lastName", 2, "email", 1)) + .withFieldIncludeLabels(Map.of("firstName", false, "lastName", false, "email", false)); + + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); + + assertThat(doc).isNotNull(); + assertThat(doc.getFieldValues()).containsKey("firstName"); + assertThat(doc.getFieldValues()).containsKey("lastName"); + assertThat(doc.getFieldValues()).containsKey("email"); + assertThat(doc.getFieldValues().get("firstName")).isEqualTo("Jane"); + } + + + + /******************************************************************************* + ** Test the new buildDocument overload returns null when primary key is null. + *******************************************************************************/ + @Test + void testBuildDocument_tableConfig_nullPrimaryKey_returnsNull() + { + QRecord record = new QRecord(); + record.setValue("name", "NoId"); + + QuickSearchableTableConfig tableConfig = new QuickSearchableTableConfig() + .withTableName("person") + .withPrimaryKeyField("id") + .withSearchableFields(List.of("name")) + .withFieldWeights(Map.of("name", 1)) + .withFieldIncludeLabels(Map.of("name", false)); + + OpenSearchDocument doc = IndexingUtils.buildDocument(record, tableConfig); + + assertThat(doc).isNull(); + } + } diff --git a/src/test/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisherTest.java b/src/test/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisherTest.java index 4cd7280..450636c 100644 --- a/src/test/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisherTest.java +++ b/src/test/java/com/kingsrook/qbits/quicksearch/publisher/SynchronousIndexEventPublisherTest.java @@ -245,6 +245,7 @@ class SynchronousIndexEventPublisherTest { QuickSearchableTableConfig configWithoutPk = new QuickSearchableTableConfig() .withTableName("products") + .withPrimaryKeyField("id") .withSearchableFields(List.of("name")) .withFieldWeights(Map.of("name", 1)) .withFieldIncludeLabels(Map.of("name", false));