From a84fb1e98a37f4eb1f541740f85c6b23350964b5 Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 30 Mar 2026 05:35:55 -0500 Subject: [PATCH] feat: add config-driven table indexing API Add SearchableFieldConfig and SearchableTableConfig classes that let host apps declare searchable tables via config instead of requiring annotations on entity classes. Both annotation and config paths merge during produce(). Includes record label override, null-safe formatting, within-source duplicate detection, and updated validation. --- README.md | 85 ++++- pom.xml | 4 +- .../quicksearch/QuickSearchQBitConfig.java | 99 +++++- .../quicksearch/QuickSearchQBitProducer.java | 109 ++++++- .../QuickSearchableTableConfig.java | 64 ++++ .../quicksearch/SearchableFieldConfig.java | 134 ++++++++ .../quicksearch/SearchableTableConfig.java | 279 ++++++++++++++++ .../processes/BasepullIndexStep.java | 8 +- .../processes/FullReindexStep.java | 8 +- .../quicksearch/processes/IndexingUtils.java | 39 +++ .../SynchronousIndexEventPublisher.java | 20 +- .../ConfigDrivenIndexingIntegrationTest.java | 262 +++++++++++++++ .../QuickSearchQBitConfigTest.java | 244 +++++++++++++- .../QuickSearchQBitProducerTest.java | 308 ++++++++++++++++++ .../QuickSearchableTableConfigTest.java | 58 ++++ .../SearchableFieldConfigTest.java | 123 +++++++ .../SearchableTableConfigTest.java | 159 +++++++++ .../processes/IndexingUtilsTest.java | 113 +++++++ .../SynchronousIndexEventPublisherTest.java | 1 + 19 files changed, 2071 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfig.java create mode 100644 src/main/java/com/kingsrook/qbits/quicksearch/SearchableTableConfig.java create mode 100644 src/test/java/com/kingsrook/qbits/quicksearch/ConfigDrivenIndexingIntegrationTest.java create mode 100644 src/test/java/com/kingsrook/qbits/quicksearch/SearchableFieldConfigTest.java create mode 100644 src/test/java/com/kingsrook/qbits/quicksearch/SearchableTableConfigTest.java 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)); -- 2.54.0