Compare commits
7
Commits
release/0.1.0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a709dd5f47
|
||
|
|
ed0ab82b07
|
||
|
|
9004713636
|
||
|
|
5f9d5f47d8 | ||
|
|
482ff5341b | ||
|
|
c888b771e7 | ||
|
|
41a02f8413 |
No files matched your search
+11
-48
@@ -1,51 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.5.2
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|main|release\/.*|hotfix\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
only: [main]
|
||||
tags:
|
||||
only: [/v.*/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /v.*/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
docker-runner: container-build
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: true
|
||||
release_tag_policy: v-prefix
|
||||
test_branch_policy: non-release-no-main-hotfix
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
hotfix: true
|
||||
asciidoc: false
|
||||
@@ -1,12 +1,12 @@
|
||||
# QBit: Quick Search
|
||||
|
||||
[](https://github.com/QRun-IO/qbit-quick-search)
|
||||
[](https://github.com/QRun-IO/qbit-quick-search)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
[](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`
|
||||
|
||||
@@ -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</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);
|
||||
}
|
||||
|
||||
}
|
||||
+5
-15
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+262
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user