Compare commits

..
7 Commits
Author SHA1 Message Date
forge-recovery a709dd5f47 ci: enroll shared Gitea lifecycle
Shared QRun source version / version (push) Failing after 10m19s
qrun-release / postpublish (push) Failing after 10m18s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (push) Successful in 1m30s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
Shared Java Maven CI with Docker / verify (push) Successful in 2m3s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (push) Successful in 12s
QRun-IO/ci-policy: ci / java-docker-3 (push) Successful in 43m2s
Shared Java Maven CI with Docker / gate (push) Successful in 13s
qrun-release / release (push) Successful in 43m31s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 27s
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (push) Successful in 24s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
2026-11-02 22:20:45 +00:00
ed0ab82b07 ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 14s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h31m7s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Successful in 1m3s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Successful in 3h1m51s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:52:12 +00:00
9004713636 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI with Docker / secrets (push) Successful in 18s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / secrets (pull_request) Successful in 19s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI with Docker / verify (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Canceled after 28m42s
Shared Java Maven CI with Docker / gate (push) Canceled after 0s
Shared Java Maven CI with Docker / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 28m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:20:07 +00:00
james.maesandCodex 5f9d5f47d8 ci: enroll in the shared QQQ orb (#82)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 19:53:49 -05:00
james.maesandCodex 482ff5341b ci: use shared orb pipelines and source scanning (#73)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:44:30 -05:00
james.maes c888b771e7 chore: release 0.2.0 (#5)
* chore: bump version to 0.2.0-SNAPSHOT

* feat: add config-driven table indexing API (#3)

Add SearchableFieldConfig and SearchableTableConfig classes that let
host apps declare searchable tables via config instead of requiring
annotations on entity classes. Both annotation and config paths merge
during produce(). Includes record label override, null-safe formatting,
within-source duplicate detection, and updated validation.

* chore: release 0.2.0
2026-03-30 06:48:59 -05:00
CircleCI 41a02f8413 Bump version to 0.1.0-RC.1 [skip ci] 2026-03-29 20:51:35 +00:00
23 changed files with 2099 additions and 123 deletions

No files matched your search

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