chore: release 0.2.0 #5

Merged
KofTwentyTwo merged 4 commits from release/0.2.0 into main 2026-03-30 11:48:59 +00:00
19 changed files with 2072 additions and 47 deletions

No files matched your search

+80 -5
View File
@@ -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/
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-quick-search</artifactId>
<version>0.1.0-SNAPSHOT</version>
<version>0.2.0-SNAPSHOT</version>
</dependency>
```
@@ -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<SearchableFieldConfig> 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<Class<?>>` | required | Entity classes with `@QuickSearchable` |
| `searchableEntityClasses` | `List<Class<?>>` | none | Entity classes with `@QuickSearchable` |
| `searchableTables` | `List<SearchableTableConfig>` | 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`
+3 -3
View File
@@ -18,7 +18,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-RC.1</revision>
<revision>0.2.0</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<opensearch.version>2.10.0</opensearch.version>
@@ -27,7 +27,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>1.4.0</version>
<version>1.6.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -78,7 +78,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>0.40.0-SNAPSHOT</version>
<version>0.40.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -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<Class<?>> searchableEntityClasses;
private List<Class<?>> searchableEntityClasses;
private List<SearchableTableConfig> 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<String> 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<SearchableTableConfig> getSearchableTables()
{
return (this.searchableTables);
}
/***************************************************************************
** Setter for searchableTables
***************************************************************************/
public void setSearchableTables(List<SearchableTableConfig> searchableTables)
{
this.searchableTables = searchableTables;
}
/***************************************************************************
** Fluent setter for searchableTables
***************************************************************************/
public QuickSearchQBitConfig withSearchableTables(List<SearchableTableConfig> 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<SearchableFieldConfig> fields)
{
return withSearchableTable(new SearchableTableConfig(tableName, fields));
}
}
@@ -122,7 +122,34 @@ public class QuickSearchQBitProducer
////////////////////////////////////////////////////
// 2. Discover searchable tables from annotations //
////////////////////////////////////////////////////
List<QuickSearchableTableConfig> discoveredTables = discoverSearchableTables(qInstance);
List<QuickSearchableTableConfig> annotationTables = discoverSearchableTables(qInstance);
////////////////////////////////////////////////////
// 2b. Convert config-driven searchable tables //
////////////////////////////////////////////////////
List<QuickSearchableTableConfig> 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<QuickSearchableTableConfig> 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<QuickSearchableTableConfig> convertConfigDrivenTables(QInstance qInstance)
{
List<QuickSearchableTableConfig> tables = new ArrayList<>();
List<SearchableTableConfig> searchableTables = config.getSearchableTables();
if(searchableTables == null || searchableTables.isEmpty())
{
return (tables);
}
for(SearchableTableConfig stc : searchableTables)
{
List<String> searchableFields = new ArrayList<>();
Map<String, Integer> fieldWeights = new LinkedHashMap<>();
Map<String, Boolean> 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.
***************************************************************************/
@@ -37,6 +37,8 @@ public class QuickSearchableTableConfig
private Integer basepullIntervalMinutes;
private String basepullTimestampField;
private Boolean enabledByDefault;
private String recordLabelFormat;
private List<String> 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<String> getRecordLabelFields()
{
return (this.recordLabelFields);
}
/***************************************************************************
** Setter for recordLabelFields
***************************************************************************/
public void setRecordLabelFields(List<String> recordLabelFields)
{
this.recordLabelFields = recordLabelFields;
}
/***************************************************************************
** Fluent setter for recordLabelFields
***************************************************************************/
public QuickSearchableTableConfig withRecordLabelFields(List<String> recordLabelFields)
{
this.recordLabelFields = recordLabelFields;
return (this);
}
}
@@ -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);
}
}
@@ -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<SearchableFieldConfig> fields;
private Integer basepullIntervalMinutes;
private String basepullTimestampField = "modifyDate";
private Boolean enabledByDefault = true;
private String recordLabelFormat;
private List<String> recordLabelFields;
/***************************************************************************
** Constructor that takes the required tableName and fields.
***************************************************************************/
public SearchableTableConfig(String tableName, List<SearchableFieldConfig> 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<SearchableFieldConfig> getFields()
{
return (this.fields);
}
/***************************************************************************
** Setter for fields
***************************************************************************/
public void setFields(List<SearchableFieldConfig> fields)
{
this.fields = fields;
}
/***************************************************************************
** Fluent setter for fields
***************************************************************************/
public SearchableTableConfig withFields(List<SearchableFieldConfig> 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<String> 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<String> recordLabelFields)
{
this.recordLabelFields = recordLabelFields;
}
/***************************************************************************
** Fluent setter for recordLabelFields
***************************************************************************/
public SearchableTableConfig withRecordLabelFields(List<String> recordLabelFields)
{
this.recordLabelFields = recordLabelFields;
return (this);
}
}
@@ -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);
}
}
@@ -169,13 +169,7 @@ public class FullReindexStep extends AbstractIndexingStep
List<OpenSearchDocument> 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);
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<SearchableTableConfig> 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<SearchableTableConfig> 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<String> 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<String> 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<String> 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.
***************************************************************************/
@@ -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<String, com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference> 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.
***************************************************************************/
@@ -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<String> 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);
}
}
@@ -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();
}
}
@@ -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<SearchableFieldConfig> 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<SearchableFieldConfig> fields = List.of(new SearchableFieldConfig("name"));
List<SearchableFieldConfig> 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<SearchableFieldConfig> 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<SearchableFieldConfig> 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<SearchableFieldConfig> 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<SearchableFieldConfig> fields = List.of(new SearchableFieldConfig("a"));
List<SearchableFieldConfig> newFields = List.of(new SearchableFieldConfig("b"));
List<String> 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);
}
}
@@ -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();
}
}
@@ -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));